feat(k1): complete primary acquisition lifecycle
This commit is contained in:
@@ -30,6 +30,10 @@ import { ObservationSessionSelect } from "./components/ObservationSessionSelect"
|
||||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||
import type { ViewerSettings } from "./core/runtime/contracts";
|
||||
import {
|
||||
SPATIAL_SOURCE_SWITCH_BLOCKED_REASON,
|
||||
isSpatialSourceSwitchBlocked,
|
||||
} from "./core/runtime/acquisitionGuard";
|
||||
import {
|
||||
createLatestAsyncCommitter,
|
||||
type LatestAsyncCommitter,
|
||||
@@ -144,6 +148,12 @@ export default function App() {
|
||||
const [layoutSaveNotice, setLayoutSaveNotice] = useState<string | null>(null);
|
||||
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings);
|
||||
const [displayDraft, setDisplayDraft] = useState<SceneSettings>(defaultSceneSettings);
|
||||
const sourceSwitchBlocked = isSpatialSourceSwitchBlocked(runtime.state);
|
||||
const sourceSwitchBlockedReason = sourceSwitchBlocked
|
||||
? SPATIAL_SOURCE_SWITCH_BLOCKED_REASON
|
||||
: null;
|
||||
const sourceSwitchBlockedRef = useRef(sourceSwitchBlocked);
|
||||
sourceSwitchBlockedRef.current = sourceSwitchBlocked;
|
||||
const appliedProfileKeyRef = useRef<string | null>(null);
|
||||
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
@@ -423,6 +433,9 @@ export default function App() {
|
||||
};
|
||||
|
||||
const beginRecordedReplaySwitch = useCallback(async () => {
|
||||
if (sourceSwitchBlockedRef.current) {
|
||||
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
|
||||
}
|
||||
// useObservationSessions calls this only after backend preparation has
|
||||
// produced a validated launch descriptor. Keep the old scene mounted
|
||||
// before this point; now perform one controlled receiver teardown before
|
||||
@@ -437,6 +450,9 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
const acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => {
|
||||
if (sourceSwitchBlockedRef.current) {
|
||||
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
|
||||
}
|
||||
setRecordedReplay(launch);
|
||||
setSourceUrl(launch.sourceUrl);
|
||||
setSourceDraft(launch.sourceUrl);
|
||||
@@ -447,6 +463,17 @@ export default function App() {
|
||||
if (outcome !== "accepted") setReplayTransitioning(false);
|
||||
}, []);
|
||||
|
||||
const activateAutomaticSpatialSource = useCallback(() => {
|
||||
// A plugin-owned live/file-replay start must release any host-selected
|
||||
// archive or manual URL before the new acquisition becomes non-terminal.
|
||||
// This transition is an internal start boundary, not an operator source
|
||||
// switch, so it intentionally does not consult the acquisition guard.
|
||||
setReplayTransitioning(false);
|
||||
setRecordedReplay(null);
|
||||
setSourceUrl("");
|
||||
setSourceDraft("");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// ObservationSessionSelect owns the cancellable request and unmounts when
|
||||
// the operator leaves the spatial workspace. Its unmount cannot safely
|
||||
@@ -637,7 +664,8 @@ export default function App() {
|
||||
<div className="observation-header-tools">
|
||||
<ObservationSessionSelect
|
||||
limit={3}
|
||||
disabled={runtime.pendingAction !== null}
|
||||
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
|
||||
blockedReason={sourceSwitchBlockedReason}
|
||||
onReplayBegin={beginRecordedReplaySwitch}
|
||||
onReplayAccepted={(_session, launch) => acceptRecordedReplay(launch)}
|
||||
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
|
||||
@@ -667,6 +695,7 @@ export default function App() {
|
||||
{activeDefinition.kind === "device" ? (
|
||||
<DeviceWorkspace
|
||||
onOpenSpatialScene={() => openView("spatial-scene")}
|
||||
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
|
||||
/>
|
||||
) : (
|
||||
<WorkspaceRenderer
|
||||
@@ -682,11 +711,18 @@ export default function App() {
|
||||
stageDisplayPatch({ accumulationSeconds })}
|
||||
onAccumulationCommit={flushDisplaySettings}
|
||||
observationLayout={observationLayout}
|
||||
spatialControls={selection?.SpatialControlsView
|
||||
? {
|
||||
View: selection.SpatialControlsView,
|
||||
model: selection.model,
|
||||
}
|
||||
: null}
|
||||
navigation={{
|
||||
openView,
|
||||
openSource,
|
||||
openDisplay,
|
||||
openLayers,
|
||||
activateAutomaticSpatialSource,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -713,7 +749,10 @@ export default function App() {
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={sourceSwitchBlocked}
|
||||
title={sourceSwitchBlockedReason ?? undefined}
|
||||
onClick={() => {
|
||||
if (sourceSwitchBlockedRef.current) return;
|
||||
setSourceDraft("");
|
||||
setSourceUrl("");
|
||||
setRecordedReplay(null);
|
||||
@@ -724,8 +763,10 @@ export default function App() {
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
disabled={!sourceDraft.trim()}
|
||||
disabled={sourceSwitchBlocked || !sourceDraft.trim()}
|
||||
title={sourceSwitchBlockedReason ?? undefined}
|
||||
onClick={() => {
|
||||
if (sourceSwitchBlockedRef.current) return;
|
||||
setSourceUrl(sourceDraft.trim());
|
||||
setRecordedReplay(null);
|
||||
}}
|
||||
@@ -766,9 +807,10 @@ export default function App() {
|
||||
hint="необязательно"
|
||||
value={sourceDraft}
|
||||
onChange={(event) => setSourceDraft(event.target.value)}
|
||||
disabled={sourceSwitchBlocked}
|
||||
spellCheck={false}
|
||||
placeholder="rerun+http://127.0.0.1:9876/proxy"
|
||||
description="Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD."
|
||||
description={sourceSwitchBlockedReason ?? "Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD."}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -93,12 +93,14 @@ function sessionDescription(session: ObservationSessionSummary): string {
|
||||
export function ObservationSessionSelect({
|
||||
limit = 3,
|
||||
disabled = false,
|
||||
blockedReason = null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
}: {
|
||||
limit?: number;
|
||||
disabled?: boolean;
|
||||
blockedReason?: string | null;
|
||||
onReplayBegin?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
@@ -114,6 +116,7 @@ export function ObservationSessionSelect({
|
||||
}) {
|
||||
const sessions = useObservationSessions({
|
||||
limit,
|
||||
replayEnabled: blockedReason === null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
@@ -123,6 +126,7 @@ export function ObservationSessionSelect({
|
||||
: sessions.state === "loading"
|
||||
? "Загружаем сессии…"
|
||||
: "Сохранённые сессии";
|
||||
const presentedTriggerCopy = blockedReason ?? triggerCopy;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
@@ -147,10 +151,11 @@ export function ObservationSessionSelect({
|
||||
aria-expanded={open}
|
||||
aria-controls={surfaceId}
|
||||
disabled={disabled}
|
||||
title={blockedReason ?? undefined}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Icon name="database" size={15} />
|
||||
<span>{triggerCopy}</span>
|
||||
<span>{presentedTriggerCopy}</span>
|
||||
{sessions.state === "ready" ? <small>{sessions.items.length}</small> : null}
|
||||
<Icon name="chevron-down" size={14} />
|
||||
</button>
|
||||
|
||||
@@ -94,6 +94,7 @@ export function isDevicePluginManifestV1Alpha2(
|
||||
|
||||
export interface DevicePluginHostActions {
|
||||
openSpatialScene: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}
|
||||
|
||||
export interface DevicePluginConnectionProps {
|
||||
@@ -109,10 +110,12 @@ export interface DeviceUiPlugin {
|
||||
children: ReactNode;
|
||||
}>;
|
||||
connectionViews: Readonly<Record<string, ComponentType<DevicePluginConnectionProps>>>;
|
||||
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
|
||||
}
|
||||
|
||||
export interface RegisteredDeviceModel {
|
||||
plugin: DeviceUiPlugin;
|
||||
model: DeviceModelDefinition;
|
||||
ConnectionView: ComponentType<DevicePluginConnectionProps>;
|
||||
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,14 @@ export function createDevicePluginRegistry(
|
||||
`Плагин ${manifest.metadata.id} не реализует UI ${model.ui.componentKey}.`,
|
||||
);
|
||||
}
|
||||
models.push({ plugin, model, ConnectionView });
|
||||
models.push({
|
||||
plugin,
|
||||
model,
|
||||
ConnectionView,
|
||||
...(plugin.SpatialControlsView
|
||||
? { SpatialControlsView: plugin.SpatialControlsView }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (isDevicePluginManifestV1Alpha2(manifest)) {
|
||||
|
||||
@@ -101,9 +101,11 @@ export function createObservationReplayCoordinator(): ObservationReplayCoordinat
|
||||
};
|
||||
},
|
||||
cancel() {
|
||||
sequence += 1;
|
||||
active?.abort();
|
||||
active = null;
|
||||
// Keep ownership until the cancelled attempt reaches `finish()`. This
|
||||
// lets its finally block settle a replacement that already passed
|
||||
// onReplayBegin, while `isCurrent()` still fails immediately because the
|
||||
// signal is aborted. A later `begin()` replaces and invalidates it.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -359,11 +361,13 @@ export function clearObservationReplayPreparation(
|
||||
|
||||
export function useObservationSessions({
|
||||
limit = 3,
|
||||
replayEnabled = true,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
}: {
|
||||
limit?: number;
|
||||
replayEnabled?: boolean;
|
||||
/** Called only after the archive is ready, immediately before replacing the old viewer. */
|
||||
onReplayBegin?: (
|
||||
session: ObservationSessionSummary,
|
||||
@@ -388,11 +392,23 @@ export function useObservationSessions({
|
||||
const mounted = useRef(true);
|
||||
const catalogSequence = useRef(0);
|
||||
const reattachStarted = useRef(false);
|
||||
const replayEnabledRef = useRef(replayEnabled);
|
||||
replayEnabledRef.current = replayEnabled;
|
||||
const preparationPollSequence = useRef(0);
|
||||
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
|
||||
if (replayCoordinator.current === null) {
|
||||
replayCoordinator.current = createObservationReplayCoordinator();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (replayEnabled) return;
|
||||
// Cancels only this browser's polling/selection attempt. The shared
|
||||
// backend preparation remains untouched and can be selected again later.
|
||||
reattachStarted.current = true;
|
||||
replayCoordinator.current?.cancel();
|
||||
setReplayingSessionId(null);
|
||||
setReplayProgress(null);
|
||||
}, [replayEnabled]);
|
||||
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 3;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -466,6 +482,7 @@ export function useObservationSessions({
|
||||
session: ObservationSessionSummary,
|
||||
resumedPreparation?: ObservationSessionPreparation,
|
||||
) => {
|
||||
if (!replayEnabledRef.current) return false;
|
||||
const attempt = replayCoordinator.current!.begin();
|
||||
setReplayingSessionId(session.id);
|
||||
setFailedSessionId(null);
|
||||
@@ -502,14 +519,26 @@ export function useObservationSessions({
|
||||
signal: attempt.signal,
|
||||
onUpdate,
|
||||
});
|
||||
if (!mounted.current || !attempt.isCurrent()) return false;
|
||||
if (
|
||||
!mounted.current ||
|
||||
!attempt.isCurrent() ||
|
||||
!replayEnabledRef.current
|
||||
) return false;
|
||||
|
||||
// The current scene stays mounted throughout preparation. Only now that
|
||||
// the launch descriptor exists do we release the previous viewer.
|
||||
await onReplayBegin?.(session, launch);
|
||||
if (!mounted.current || !attempt.isCurrent()) return false;
|
||||
if (
|
||||
!mounted.current ||
|
||||
!attempt.isCurrent() ||
|
||||
!replayEnabledRef.current
|
||||
) return false;
|
||||
await onReplayAccepted?.(session, launch);
|
||||
if (!mounted.current || !attempt.isCurrent()) return false;
|
||||
if (
|
||||
!mounted.current ||
|
||||
!attempt.isCurrent() ||
|
||||
!replayEnabledRef.current
|
||||
) return false;
|
||||
outcome = "accepted";
|
||||
try {
|
||||
clearObservationReplayPreparation();
|
||||
@@ -543,6 +572,7 @@ export function useObservationSessions({
|
||||
}, [onReplayAccepted, onReplayBegin, onReplaySettled]);
|
||||
|
||||
const replay = useCallback(async (sessionId: string) => {
|
||||
if (!replayEnabledRef.current) return false;
|
||||
const session = items.find((candidate) => candidate.id === sessionId);
|
||||
if (!session || !session.replayable) return false;
|
||||
reattachStarted.current = true;
|
||||
@@ -550,7 +580,7 @@ export function useObservationSessions({
|
||||
}, [executeReplay, items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready" || reattachStarted.current) return;
|
||||
if (!replayEnabled || state !== "ready" || reattachStarted.current) return;
|
||||
reattachStarted.current = true;
|
||||
let stored: ObservationSessionPreparation | null = null;
|
||||
try {
|
||||
@@ -569,7 +599,7 @@ export function useObservationSessions({
|
||||
return;
|
||||
}
|
||||
void executeReplay(session, stored);
|
||||
}, [executeReplay, items, state]);
|
||||
}, [executeReplay, items, replayEnabled, state]);
|
||||
|
||||
const retry = useCallback(async () => {
|
||||
if (!failedSessionId) return false;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MissionRuntimeState } from "./contracts";
|
||||
|
||||
const TERMINAL_ACQUISITION_STATES = new Set([
|
||||
"completed",
|
||||
"failed",
|
||||
"aborted",
|
||||
"interrupted",
|
||||
]);
|
||||
|
||||
export const SPATIAL_SOURCE_SWITCH_BLOCKED_REASON =
|
||||
"Завершите текущий приём перед сменой источника.";
|
||||
|
||||
/**
|
||||
* An acquisition snapshot is blocking unless its state is explicitly known to
|
||||
* be terminal. Unknown future states therefore fail closed.
|
||||
*/
|
||||
export function isSpatialSourceSwitchBlocked(
|
||||
state: MissionRuntimeState | null | undefined,
|
||||
): boolean {
|
||||
const acquisition = state?.acquisition;
|
||||
return Boolean(
|
||||
acquisition &&
|
||||
(acquisition.cleanupPending === true ||
|
||||
!TERMINAL_ACQUISITION_STATES.has(acquisition.state)),
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,9 @@ export interface StreamMetrics {
|
||||
frameRateHz?: number | null;
|
||||
pointCount?: number | null;
|
||||
droppedPreviewFrames?: number | null;
|
||||
elapsedSeconds?: number | null;
|
||||
routeDistanceMeters?: number | null;
|
||||
speedMetersPerSecond?: number | null;
|
||||
}
|
||||
|
||||
export interface ActiveDeviceSnapshot {
|
||||
@@ -55,6 +58,7 @@ export interface RuntimeAcquisitionSnapshot {
|
||||
state: string;
|
||||
stateRevision: number;
|
||||
operatorInstructions: readonly string[];
|
||||
cleanupPending?: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeOperationSnapshot {
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.scene-device-controls {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: 0.85rem;
|
||||
left: 50%;
|
||||
max-width: calc(100% - 9rem);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger,
|
||||
.scene-source-control,
|
||||
.scene-focus-exit {
|
||||
|
||||
@@ -168,6 +168,11 @@
|
||||
left: 0.6rem;
|
||||
}
|
||||
|
||||
.scene-device-controls {
|
||||
top: 0.6rem;
|
||||
max-width: calc(100% - 8rem);
|
||||
}
|
||||
|
||||
.scene-adapter-note {
|
||||
right: 0.75rem;
|
||||
left: 0.75rem;
|
||||
|
||||
@@ -40,7 +40,13 @@ function ModelCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceWorkspace({ onOpenSpatialScene }: { onOpenSpatialScene: () => void }) {
|
||||
export function DeviceWorkspace({
|
||||
onOpenSpatialScene,
|
||||
onActivateAutomaticSpatialSource,
|
||||
}: {
|
||||
onOpenSpatialScene: () => void;
|
||||
onActivateAutomaticSpatialSource: () => void;
|
||||
}) {
|
||||
const {
|
||||
registry,
|
||||
selection,
|
||||
@@ -115,7 +121,10 @@ export function DeviceWorkspace({ onOpenSpatialScene }: { onOpenSpatialScene: ()
|
||||
</div>
|
||||
<ConnectionView
|
||||
model={selection.model}
|
||||
host={{ openSpatialScene: onOpenSpatialScene }}
|
||||
host={{
|
||||
openSpatialScene: onOpenSpatialScene,
|
||||
activateAutomaticSpatialSource: onActivateAutomaticSpatialSource,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
@@ -21,6 +28,10 @@ import type {
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
|
||||
import type {
|
||||
DeviceModelDefinition,
|
||||
DevicePluginConnectionProps,
|
||||
} from "../core/device-plugins/contracts";
|
||||
import type {
|
||||
BackendStatus,
|
||||
MissionRuntimeState,
|
||||
@@ -98,6 +109,7 @@ export interface WorkspaceNavigation {
|
||||
openSource: () => void;
|
||||
openDisplay: () => void;
|
||||
openLayers: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
@@ -113,6 +125,10 @@ export interface WorkspaceRendererProps {
|
||||
onAccumulationCommit: () => void;
|
||||
observationLayout: ObservationLayoutController;
|
||||
navigation: WorkspaceNavigation;
|
||||
spatialControls: {
|
||||
View: ComponentType<DevicePluginConnectionProps>;
|
||||
model: DeviceModelDefinition;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function OverviewWorkspace({
|
||||
@@ -271,6 +287,7 @@ function SpatialWorkspace({
|
||||
onAccumulationCommit,
|
||||
observationLayout,
|
||||
navigation,
|
||||
spatialControls,
|
||||
}: WorkspaceRendererProps) {
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [viewerMessage, setViewerMessage] = useState("");
|
||||
@@ -435,6 +452,18 @@ function SpatialWorkspace({
|
||||
<EmptySpatialStage settings={sceneSettings} />
|
||||
)}
|
||||
|
||||
{spatialControls && !recordedSource ? (
|
||||
<div className="scene-device-controls">
|
||||
<spatialControls.View
|
||||
model={spatialControls.model}
|
||||
host={{
|
||||
openSpatialScene: () => navigation.openView("spatial-scene"),
|
||||
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pointCloudFocused ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user