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}