fix(lab): separate direct device launches from planning profiles

This commit is contained in:
DCCONSTRUCTIONS
2026-09-21 09:19:14 +03:00
parent 76dc9f19c9
commit c804d89b18
12 changed files with 222 additions and 29 deletions
+19 -3
View File
@@ -69,6 +69,9 @@ import {
type SceneSettings,
} from "./sceneSettings";
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
import { PlanningConnectionWindow } from "./components/missions/PlanningConnectionWindow";
import { PlanningCaptureGuard } from "./components/missions/PlanningCaptureGuard";
import { workspaceLaunchProfile, type WorkspaceLaunchProfile } from "./core/observation/workspaceLaunch";
import { WorkspaceRenderer } from "./workspaces/Workspaces";
import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader";
import "./styles/scene-windows.css";
@@ -151,6 +154,7 @@ export default function App() {
polygonDatasetRoute.active ? "data" : null,
);
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
const [sourceUrl, setSourceUrl] = useState("");
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
@@ -431,9 +435,10 @@ export default function App() {
}
};
const openView = (viewId: string) => {
const openView = (viewId: string, profile?: WorkspaceLaunchProfile) => {
const definition = workspaceById(viewId);
if (!definition) return;
setLaunchProfile(current => workspaceLaunchProfile(current, definition.kind, profile));
setActiveRoot(definition.root);
workspace.openView(viewId);
};
@@ -806,15 +811,25 @@ export default function App() {
utilityActions={contentActions}
onClose={workspace.closeView}
>
<PlanningCaptureGuard
enabled={launchProfile === 'direct' && (activeDefinition.kind === 'device' || activeDefinition.kind === 'spatial')}
onResume={() => openView('local-device', 'planning')}
>
{activeDefinition.kind === "device" ? (
<DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene")}
launchProfile === 'planning' ? <PlanningConnectionWindow>
<DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene", 'planning')}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/>
</PlanningConnectionWindow> : <DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene", 'direct')}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/>
) : activeDefinition.kind === "recordings" && sessionOverview.open && recordedReplay && replayPresented ? (
<SessionOverviewWorkspace key={recordedReplay.sessionId} sessionId={recordedReplay.sessionId} />
) : (
<WorkspaceRenderer
launchProfile={launchProfile}
definition={activeDefinition}
fleetCreateRequest={fleetCreateRequest}
headerToolsHost={workspaceHeaderToolsHost}
@@ -858,6 +873,7 @@ export default function App() {
}}
/>
)}
</PlanningCaptureGuard>
</ApplicationPanel>
) : null}
/>
@@ -0,0 +1,23 @@
import type {ReactNode} from 'react';
import {Button, StatusBadge} from '@nodedc/ui-react';
import {planningAwaitsCapture, usePlanningTest} from '../../core/missions/PlanningTestContext';
/** Composition-level handoff; never sends a device or acquisition command. */
export function PlanningCaptureGuard({enabled, onResume, children}: {
enabled: boolean;
onResume: () => void;
children: ReactNode;
}) {
const planning = usePlanningTest();
if (!enabled || !planningAwaitsCapture(planning.test)) return <>{children}</>;
return <div className="mission-planner__check">
<StatusBadge tone="warning">Исследование ожидает новую запись</StatusBadge>
<p>{planning.test!.draft.name}</p>
<p>Для самостоятельной съёмки завершите подготовленное исследование. Запись и подключение сканера не будут остановлены.</p>
{planning.error && <p role="alert">{planning.error}</p>}
<div className="mission-planner__actions">
<Button loading={planning.busy} onClick={() => void planning.finish()}>Завершить исследование</Button>
<Button disabled={planning.busy} onClick={onResume}>Вернуться к исследованию</Button>
</div>
</div>;
}
@@ -8,11 +8,11 @@ export function PlanningConnectionWindow({children}:{children:ReactNode}){
const p=usePlanningTest(); const [open,setOpen]=useState(true);
const {selection,registry,selectModel,selectionTransitionPending}=useDevicePluginHost();
useEffect(()=>{
if(!p.selected||!p.test||selection||selectionTransitionPending)return;
if(!p.test||planningTestTerminal(p.test)||selection||selectionTransitionPending)return;
const models=registry.models.filter(m=>m.plugin.manifest.metadata.id===p.test!.plugin_id);
if(models.length===1)void selectModel(models[0].model.id);
},[p.selected,p.test?.plugin_id,selection,registry,selectModel,selectionTransitionPending]);
if(!p.selected||!p.test)return <>{children}</>;
},[p.test?.plugin_id,p.test?.state,selection,registry,selectModel,selectionTransitionPending]);
if(!p.test)return <>{children}</>;
const t=p.test, terminal=planningTestTerminal(t);
const details=<><StatusBadge tone="neutral">Профиль · Планирование</StatusBadge>
<p>{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м</p>
@@ -18,14 +18,13 @@ export interface PlanningRunSummary {id:string;name:string;state:string;query_se
const endpoint = plannerBase+'/live-tests';
const terminal = new Set(['completed','cancelled','error','interrupted']);
const Context = createContext<{
test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise<void>; selected: boolean; busy:boolean; error:string|null;
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>; resume:()=>void;
test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise<boolean>; busy:boolean; error:string|null;
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>;
}|null>(null);
/** Server owns the run; navigation and reload retain its frozen reference. */
/** Server owns the run and its frozen reference, never the workspace launch profile. */
export function PlanningTestProvider({children}:{children:ReactNode}) {
const [test,setTest]=useState<PlanningLiveTest|null>(null);
const [dismissed,setDismissed]=useState<string|null>(null);
const [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null);
const [pollError,setPollError]=useState<string|null>(null);
const [history,setHistory]=useState<PlanningRunSummary[]>([]);
@@ -33,8 +32,8 @@ export function PlanningTestProvider({children}:{children:ReactNode}) {
useEffect(()=>{let disposed=false;void plannerRequest<{items:PlanningRunSummary[]}>(endpoint).then(r=>{if(!disposed)setHistory(r.items);}).catch(()=>{});return()=>{disposed=true;};},[test?.id,test?.state]);
const select=useCallback(async(id:string)=>{
generation.current+=1;setBusy(true);setError(null);
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));setDismissed(null);}
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');}
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));return true;}
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');return false;}
finally{generation.current+=1;setBusy(false);}
},[]);
useEffect(()=>{
@@ -48,7 +47,7 @@ export function PlanningTestProvider({children}:{children:ReactNode}) {
},[]);
const begin=useCallback(async(draft:Draft)=>{
generation.current+=1;setBusy(true);setError(null);
try {const next=await plannerRequest<PlanningLiveTest>(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);setDismissed(null);sessionStorage.removeItem('planning-dismissed');return next;}
try {const next=await plannerRequest<PlanningLiveTest>(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);return next;}
catch(e){setError(e instanceof Error?e.message:'Не удалось подготовить тест.');return null;}
finally{generation.current+=1;setBusy(false);}
},[]);
@@ -64,8 +63,12 @@ export function PlanningTestProvider({children}:{children:ReactNode}) {
catch(e){setError(e instanceof Error?e.message:'Не удалось переинициализировать привязку.');}
finally{generation.current+=1;setBusy(false);}
},[test]);
const resume=useCallback(()=>{setDismissed(null);sessionStorage.removeItem('planning-dismissed');},[]);
return <Context.Provider value={{test,history,select,selected:!!test&&dismissed!==test.id,busy,error:error??pollError,begin,finish,retryInitialization,resume}}>{children}</Context.Provider>;
return <Context.Provider value={{test,history,select,busy,error:error??pollError,begin,finish,retryInitialization}}>{children}</Context.Provider>;
}
export function usePlanningTest(){const value=useContext(Context);if(!value)throw new Error('PlanningTestProvider missing');return value;}
export function planningTestTerminal(test:PlanningLiveTest){return terminal.has(test.state);}
/** A prepared consumer owns the next capture until explicitly finished or used. */
export function planningAwaitsCapture(test:PlanningLiveTest|null){
return !!test&&!planningTestTerminal(test)&&!test.query_session_id;
}
@@ -0,0 +1,10 @@
/** Entry intent belongs to application navigation, not a retained server job. */
export type WorkspaceLaunchProfile = 'direct' | 'planning';
export function workspaceLaunchProfile(
current: WorkspaceLaunchProfile,
kind: string,
requested: WorkspaceLaunchProfile = 'direct',
): WorkspaceLaunchProfile {
return kind === 'device' || kind === 'spatial' ? requested : current;
}
@@ -1,4 +1,3 @@
import { PlanningConnectionWindow } from "../components/missions/PlanningConnectionWindow";
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
import { useDevicePluginHost } from "../core/device-plugins/DevicePluginHost";
@@ -41,7 +40,7 @@ function ModelCard({
);
}
function DeviceConnectionBody({
export function DeviceWorkspace({
onOpenSpatialScene,
onActivateAutomaticSpatialSource,
}: {
@@ -129,7 +128,3 @@ function DeviceConnectionBody({
</div>
);
}
export function DeviceWorkspace(props: {onOpenSpatialScene:()=>void;onActivateAutomaticSpatialSource:()=>void}) {
return <PlanningConnectionWindow><DeviceConnectionBody {...props}/></PlanningConnectionWindow>;
}
@@ -1,5 +1,4 @@
import {SpatialWorkspace} from "./spatial/SpatialWorkspace";
import { usePlanningTest } from "../core/missions/PlanningTestContext";
import { PlanningSpatialWorkspace } from "./missions/PlanningSpatialWorkspace";
import { MissionPlannerWorkspace } from "./missions/MissionPlannerWorkspace";
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
@@ -416,10 +415,9 @@ function RecordingsWorkspace(props: WorkspaceRendererProps) {
}
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
const planning = usePlanningTest();
switch (props.definition.kind) {
case "spatial":
return planning.selected ? <PlanningSpatialWorkspace {...props}/> : <SpatialWorkspace {...props} />;
return props.launchProfile === 'planning' ? <PlanningSpatialWorkspace {...props}/> : <SpatialWorkspace {...props} />;
case "recordings":
return <RecordingsWorkspace {...props} />;
case "cameras":
@@ -14,9 +14,10 @@ import type {
} from "../core/runtime/contracts";
import type { WorkspaceDefinition } from "../productModel";
import type { SceneSettings } from "../sceneSettings";
import type { WorkspaceLaunchProfile } from "../core/observation/workspaceLaunch";
export interface WorkspaceNavigation {
openView: (viewId: string) => void;
openView: (viewId: string, profile?: WorkspaceLaunchProfile) => void;
openSource: () => void;
openDisplay: () => void;
openLayers: () => void;
@@ -35,6 +36,7 @@ export interface LaboratoryViewAction {
}
export interface WorkspaceRendererProps {
launchProfile?: WorkspaceLaunchProfile;
fleetCreateRequest?: number;
headerToolsHost?: HTMLElement | null;
definition: WorkspaceDefinition;
@@ -15,7 +15,7 @@ import { PlanningProjectSelect } from '../../components/missions/PlanningProject
import '../../styles/session-overview.css';
import '../../styles/mission-planner.css';
export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string)=>void;headerToolsHost?:HTMLElement|null}) {
export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string,profile?:'planning')=>void;headerToolsHost?:HTMLElement|null}) {
const live=usePlanningTest(), p=useMissionPlanner(), projects=usePlanningProjects();
const t=useRegistrationTest(p.saved);
const [creating,setCreating]=useState(false), [settingsOpen,setSettingsOpen]=useState(false);
@@ -49,7 +49,7 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
if(!draft)return;
if(mode==='scanner') {
const next=await live.begin(draft);
if(next){projects.select('live:'+next.id);openView('local-device');}
if(next){projects.select('live:'+next.id);openView('local-device','planning');}
} else {
const result=await t.run(draft);
if(result){setCreating(false);projects.select('recorded:'+result.id);setSettingsOpen(true);}
@@ -98,7 +98,7 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
className="planning-project__inspector">
{editing?<PlanningProjectSettings p={p} t={t} mode={mode} setMode={setMode} onStart={()=>void start()} starting={starting}/>
:project?<PlanningProjectResult project={project}/>:<p>Выберите проект или создайте новый кнопкой «+».</p>}
{project?.kind==='live'&&planningProjectPending(project)&&<Button disabled={live.busy} onClick={async()=>{await live.select(project.id);live.resume();openView(project.state==='preparing'||project.state==='waiting'?'local-device':'spatial-scene');}}>Открыть текущий проход</Button>}
{project?.kind==='live'&&planningProjectPending(project)&&<Button disabled={live.busy} onClick={async()=>{if(await live.select(project.id))openView(project.state==='preparing'||project.state==='waiting'?'local-device':'spatial-scene','planning');}}>Открыть текущий проход</Button>}
{live.error&&editing&&<p role="alert">{live.error}</p>}
</WorkspaceWindow>}
</div>
@@ -39,6 +39,7 @@ export interface SpatialWorkspaceProfile {
activity?: import("../../core/device-plugins/contracts").SpatialActivityPresentation;
}
export function SpatialWorkspace({
launchProfile = 'direct',
state,
sourceUrl,
requestedPlaybackSeconds,
@@ -441,7 +442,7 @@ export function SpatialWorkspace({
model={spatialControls.model}
spatialActivity={visualProfile?.activity}
host={{
openSpatialScene: () => navigation.openView("spatial-scene"),
openSpatialScene: () => navigation.openView("spatial-scene", launchProfile),
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
}}
/>):null}
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import React, {createElement} from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {readFileSync} from 'node:fs';
import {before, after, test} from 'node:test';
import {createServer} from 'vite';
let server, resolve, awaitsCapture, Guard, Provider, Device, Host;
before(async () => {
server = await createServer({appType:'custom', logLevel:'silent', server:{middlewareMode:true}});
({workspaceLaunchProfile:resolve} = await server.ssrLoadModule('/src/core/observation/workspaceLaunch.ts'));
({planningAwaitsCapture:awaitsCapture, PlanningTestProvider:Provider} = await server.ssrLoadModule('/src/core/missions/PlanningTestContext.tsx'));
({PlanningCaptureGuard:Guard} = await server.ssrLoadModule('/src/components/missions/PlanningCaptureGuard.tsx'));
({DeviceWorkspace:Device} = await server.ssrLoadModule('/src/workspaces/DeviceWorkspace.tsx'));
({DevicePluginHostProvider:Host} = await server.ssrLoadModule('/src/core/device-plugins/DevicePluginHost.tsx'));
});
after(async () => { await server?.close(); });
test('direct entry overrides an earlier planning profile for both shared surfaces', () => {
for (const kind of ['device','spatial']) {
assert.equal(resolve('planning',kind),'direct');
assert.equal(resolve('direct',kind),'direct');
assert.equal(resolve('direct',kind,'planning'),'planning');
assert.equal(resolve('planning',kind,'planning'),'planning');
}
assert.equal(resolve('planning','missions'),'planning');
assert.equal(resolve('direct','missions'),'direct');
});
test('a prepared consumer cannot silently claim a direct capture; old/bound runs do not block it', () => {
assert.equal(awaitsCapture(null),false);
for (const state of ['completed','cancelled','interrupted','error']) {
for (const query_session_id of [null,'recording']) assert.equal(awaitsCapture({state,query_session_id}),false);
}
for (const state of ['preparing','waiting','running']) {
assert.equal(awaitsCapture({state,query_session_id:null}),true);
assert.equal(awaitsCapture({state,query_session_id:'recording'}),false);
}
});
function withHooks(hooks, fn) {
// Same bounded pre-effect harness used by observatoryHooks.test.mjs.
const internals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
const previous = internals.H;
internals.H = hooks;
try { return fn(); } finally { internals.H = previous; }
}
test('retained completed run renders the ordinary connection, without issuing any commands', () => {
let calls = 0;
const child = createElement('div',null,'ORDINARY DEVICE');
const tree = withHooks({useContext:()=>({test:{state:'completed',query_session_id:'old'},finish:()=>{calls++;}})},
() => Guard({enabled:true,onResume:()=>{calls++;},children:child}));
assert.match(renderToStaticMarkup(tree),/ORDINARY DEVICE/);
assert.equal(calls,0);
});
test('pending run handoff is explicit and only finishes the research, not the scanner', () => {
let stopped = 0, resumed = 0;
const context = {test:{state:'waiting',query_session_id:null,draft:{name:'PENDING'}},busy:false,error:null,finish:()=>{stopped++;}};
const child = createElement('div',null,'ORDINARY DEVICE');
const tree = withHooks({useContext:()=>context}, () => Guard({enabled:true,onResume:()=>{resumed++;},children:child}));
assert.doesNotMatch(renderToStaticMarkup(tree),/ORDINARY DEVICE/);
assert.equal(stopped,0);
const actions = tree.props.children.at(-1).props.children;
actions[0].props.onClick(); assert.equal(stopped,1); assert.equal(resumed,0);
actions[1].props.onClick(); assert.equal(resumed,1);
const planningTree = withHooks({useContext:()=>context}, () => Guard({enabled:false,onResume:()=>{},children:child}));
assert.match(renderToStaticMarkup(planningTree),/ORDINARY DEVICE/);
});
test('generic device catalog renders without any PlanningTestProvider', () => {
const html = renderToStaticMarkup(createElement(Host,{plugins:[]},
createElement(Device,{onOpenSpatialScene:()=>{},onActivateAutomaticSpatialSource:()=>{}})));
assert.match(html,/Выберите модель устройства/);
assert.doesNotMatch(html,/Профиль · Планирование|Подключение сканера · Планирование/);
});
test('failed planner selection does not grant a successful launch', async () => {
const writes = [];
const context = withHooks({
useState:initial=>[typeof initial==='function'?initial():initial,value=>writes.push(value)],
useRef:value=>({current:value}),useCallback:fn=>fn,useEffect:()=>{},
}, () => Provider({children:null}).props.value);
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = async () => new Response(JSON.stringify({detail:'Выбранный проход недоступен'}),{status:409});
assert.equal(await context.select('missing'),false);
assert.ok(writes.includes('Выбранный проход недоступен'));
assert.equal('selected' in context,false);
assert.equal('resume' in context,false);
} finally { globalThis.fetch = originalFetch; }
});
test('composition owns explicit profile propagation; server polling cannot restore it', () => {
const source = path => readFileSync(new URL('../src/'+path,import.meta.url),'utf8');
assert.doesNotMatch(source('workspaces/DeviceWorkspace.tsx'),/Planning|missions\//);
assert.doesNotMatch(source('core/missions/PlanningTestContext.tsx'),/dismissed|sessionStorage|launchProfile/);
assert.match(source('App.tsx'),/useState<WorkspaceLaunchProfile>\('direct'\)/);
assert.match(source('App.tsx'),/openView\("spatial-scene", 'planning'\)/);
assert.match(source('App.tsx'),/openView\("spatial-scene", 'direct'\)/);
assert.match(source('workspaces/spatial/SpatialWorkspace.tsx'),/openView\("spatial-scene", launchProfile\)/);
assert.match(source('workspaces/Workspaces.tsx'),/props\.launchProfile === 'planning'/);
assert.match(source('workspaces/missions/MissionPlannerWorkspace.tsx'),/openView\('local-device','planning'\)/);
assert.match(source('workspaces/missions/MissionPlannerWorkspace.tsx'),/if\(await live.select\(project.id\)\)openView/);
});