Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb33a6d45c | ||
|
|
0fb117fe7b | ||
|
|
e2cf3078b6 | ||
|
|
f3640026cf | ||
|
|
bd5873ef34 | ||
|
|
aecae4a986 | ||
|
|
ee7576b7b4 | ||
|
|
e2b0d839eb | ||
|
|
d2ec9e95b1 | ||
|
|
8283f9ba85 | ||
|
|
db3b1d7b67 | ||
|
|
a993eda6b8 |
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -152,6 +152,7 @@ export default function App() {
|
|||||||
contentExpanded: true,
|
contentExpanded: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [navigationRevision, setNavigationRevision] = useState(0);
|
||||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(
|
const [activeRoot, setActiveRoot] = useState<RootId | null>(
|
||||||
polygonDatasetRoute.active ? "data" : null,
|
polygonDatasetRoute.active ? "data" : null,
|
||||||
);
|
);
|
||||||
@@ -159,6 +160,7 @@ export default function App() {
|
|||||||
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
|
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
|
||||||
const [sourceUrl, setSourceUrl] = useState("");
|
const [sourceUrl, setSourceUrl] = useState("");
|
||||||
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
|
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [workspaceHeaderTitleHost, setWorkspaceHeaderTitleHost] = useState<HTMLSpanElement | null>(null);
|
||||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||||
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||||
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
||||||
@@ -457,6 +459,17 @@ export default function App() {
|
|||||||
workspace.openView(viewId);
|
workspace.openView(viewId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Explicit menu navigation always returns to that workspace's entry screen.
|
||||||
|
const openNavigationView = (viewId: string) => {
|
||||||
|
if (!workspaceById(viewId)) return;
|
||||||
|
setNavigationRevision(value => value + 1);
|
||||||
|
sessionOverview.close();
|
||||||
|
setSourceWindowOpen(false);
|
||||||
|
setDisplayWindowOpen(false);
|
||||||
|
setLayerInspectorOpen(false);
|
||||||
|
openView(viewId);
|
||||||
|
};
|
||||||
|
|
||||||
const openSource = () => {
|
const openSource = () => {
|
||||||
if (!sceneWorkspaceActive) return;
|
if (!sceneWorkspaceActive) return;
|
||||||
setSourceDraft(sourceUrl);
|
setSourceDraft(sourceUrl);
|
||||||
@@ -645,11 +658,8 @@ export default function App() {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [layoutSaveNotice]);
|
}, [layoutSaveNotice]);
|
||||||
|
|
||||||
const [fleetCreateRequest, setFleetCreateRequest] = useState(0);
|
|
||||||
const sessionOverview = useSessionOverviewMode(activeDefinition?.kind === "recordings");
|
const sessionOverview = useSessionOverviewMode(activeDefinition?.kind === "recordings");
|
||||||
const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []);
|
|
||||||
const contentActions = useApplicationPanelActions({
|
const contentActions = useApplicationPanelActions({
|
||||||
onAddVehicle,
|
|
||||||
definition: activeDefinition,
|
definition: activeDefinition,
|
||||||
refreshRuntime: runtime.refresh,
|
refreshRuntime: runtime.refresh,
|
||||||
resetConnectionScenario: runtime.resetConnectionScenario,
|
resetConnectionScenario: runtime.resetConnectionScenario,
|
||||||
@@ -729,6 +739,7 @@ export default function App() {
|
|||||||
<SystemNavigationPanel
|
<SystemNavigationPanel
|
||||||
title={currentRoot.title}
|
title={currentRoot.title}
|
||||||
onAdd={computeContourSettings.openCreate}
|
onAdd={computeContourSettings.openCreate}
|
||||||
|
onNavigateRoot={() => openNavigationView("modules")}
|
||||||
onClose={workspace.closeNavigation}
|
onClose={workspace.closeNavigation}
|
||||||
/>
|
/>
|
||||||
) : currentRoot ? (
|
) : currentRoot ? (
|
||||||
@@ -755,7 +766,7 @@ export default function App() {
|
|||||||
icon: <Icon name={item.icon} />,
|
icon: <Icon name={item.icon} />,
|
||||||
}))}
|
}))}
|
||||||
activeId={workspace.activeView ?? undefined}
|
activeId={workspace.activeView ?? undefined}
|
||||||
onItemChange={openView}
|
onItemChange={openNavigationView}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
|
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
|
||||||
@@ -765,7 +776,8 @@ export default function App() {
|
|||||||
{rootWorkspaces.length}{" "}
|
{rootWorkspaces.length}{" "}
|
||||||
{rootWorkspaces.length === 1
|
{rootWorkspaces.length === 1
|
||||||
? "рабочая поверхность"
|
? "рабочая поверхность"
|
||||||
: "рабочих поверхностей"}
|
: rootWorkspaces.length >= 2 && rootWorkspaces.length <= 4
|
||||||
|
? "рабочие поверхности" : "рабочих поверхностей"}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -776,7 +788,9 @@ export default function App() {
|
|||||||
key={activeDefinition.id}
|
key={activeDefinition.id}
|
||||||
eyebrow={activeDefinition.eyebrow}
|
eyebrow={activeDefinition.eyebrow}
|
||||||
title={
|
title={
|
||||||
activeDefinition.kind === "recordings"
|
activeDefinition.kind === "vehicles"
|
||||||
|
? <>{activeDefinition.title}<span className="fleet-header-title" ref={setWorkspaceHeaderTitleHost}/></>
|
||||||
|
: activeDefinition.kind === "recordings"
|
||||||
&& replayPresented
|
&& replayPresented
|
||||||
&& recordedReplayLabel
|
&& recordedReplayLabel
|
||||||
? `${activeDefinition.title}: ${recordedReplayLabel}`
|
? `${activeDefinition.title}: ${recordedReplayLabel}`
|
||||||
@@ -822,16 +836,18 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
) : activeDefinition.kind === "missions" ? (
|
) : activeDefinition.kind === "missions" ? (
|
||||||
<div ref={setWorkspaceHeaderToolsHost} />
|
<div ref={setWorkspaceHeaderToolsHost} />
|
||||||
) : activeDefinition.kind === "vehicles" ? <div ref={setWorkspaceHeaderToolsHost} /> : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
|
) : activeDefinition.kind === "vehicles" ? <div className="fleet-header-tools" ref={setWorkspaceHeaderToolsHost} /> : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
|
||||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||||
) : activeDefinition.kind === "lab-archive" ? (
|
) : activeDefinition.kind === "lab-archive" ? (
|
||||||
laboratoryAnnotation.control
|
laboratoryAnnotation.control
|
||||||
) : activeDefinition.kind === "observatory" ? (
|
) : activeDefinition.kind === "observatory" ? (
|
||||||
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
|
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
|
||||||
|
) : activeDefinition.kind === "contour-health" ? (
|
||||||
|
<div ref={setWorkspaceHeaderToolsHost} />
|
||||||
) : activeDefinition.root === "system" ? (
|
) : activeDefinition.root === "system" ? (
|
||||||
<SystemWorkspaceSelector
|
<SystemWorkspaceSelector
|
||||||
value={activeDefinition.id}
|
value={activeDefinition.id}
|
||||||
onChange={openView}
|
onChange={openNavigationView}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||||
@@ -840,7 +856,7 @@ export default function App() {
|
|||||||
utilityActions={contentActions}
|
utilityActions={contentActions}
|
||||||
onClose={workspace.closeView}
|
onClose={workspace.closeView}
|
||||||
>
|
>
|
||||||
<PlanningCaptureGuard
|
<PlanningCaptureGuard key={`${activeDefinition.id}:${navigationRevision}`}
|
||||||
enabled={launchProfile === 'direct' && (activeDefinition.kind === 'device' || activeDefinition.kind === 'spatial')}
|
enabled={launchProfile === 'direct' && (activeDefinition.kind === 'device' || activeDefinition.kind === 'spatial')}
|
||||||
onResume={() => openView('local-device', 'planning')}
|
onResume={() => openView('local-device', 'planning')}
|
||||||
>
|
>
|
||||||
@@ -860,8 +876,8 @@ export default function App() {
|
|||||||
<WorkspaceRenderer
|
<WorkspaceRenderer
|
||||||
launchProfile={launchProfile}
|
launchProfile={launchProfile}
|
||||||
definition={activeDefinition}
|
definition={activeDefinition}
|
||||||
fleetCreateRequest={fleetCreateRequest}
|
|
||||||
headerToolsHost={workspaceHeaderToolsHost}
|
headerToolsHost={workspaceHeaderToolsHost}
|
||||||
|
headerTitleHost={workspaceHeaderTitleHost}
|
||||||
state={activeRuntimeState}
|
state={activeRuntimeState}
|
||||||
backendStatus={runtime.backendStatus}
|
backendStatus={runtime.backendStatus}
|
||||||
sourceUrl={effectiveSourceUrl}
|
sourceUrl={effectiveSourceUrl}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import {createPortal} from 'react-dom';
|
||||||
|
import {Icon,IconButton} from '@nodedc/ui-react';
|
||||||
|
|
||||||
|
/** A nested workspace returns through the shared panel header. */
|
||||||
|
export function WorkspaceBackButton({label,onClick,host}:{label:string;onClick:()=>void;host?:HTMLElement|null}) {
|
||||||
|
const button=<IconButton label={label} onClick={onClick}><Icon name="chevron-left"/></IconButton>;
|
||||||
|
return host?createPortal(button,host):button;
|
||||||
|
}
|
||||||
@@ -6,40 +6,41 @@ import {bindRoverHoldInput} from '../../core/fleet/roverHoldInput';
|
|||||||
import type {RoverController} from '../../core/fleet/useRoverControl';
|
import type {RoverController} from '../../core/fleet/useRoverControl';
|
||||||
import type {ObservationHeaderTargets} from '../../../../../packages/sensor-ui/src/observation';
|
import type {ObservationHeaderTargets} from '../../../../../packages/sensor-ui/src/observation';
|
||||||
import './rover.css';
|
import './rover.css';
|
||||||
const Scene=lazy(()=>import('./playcanvas/PlayCanvasViewer'));
|
const Scene=lazy(()=>import('./editor/RoverScene'));
|
||||||
const labels:Record<string,string>={observing:'Наблюдение',preparing:'Подготовка управления',ready:'Управление включено',driving:'Команда движения',receiver:'Управление с пульта',stopping:'Остановка',stopped:'Управление выключено',fault:'Управление остановлено'};
|
const labels:Record<string,string>={observing:'Наблюдение',preparing:'Подготовка управления',ready:'Управление включено',driving:'Команда движения',receiver:'Управление с пульта',stopping:'Остановка',stopped:'Управление выключено',fault:'Управление остановлено'};
|
||||||
|
|
||||||
export function RoverView({vehicleID,controller:c,header,active}:{vehicleID:string;controller:RoverController;header:ObservationHeaderTargets;active:boolean}){
|
export function RoverView({vehicleID,controller:c,header,active}:{vehicleID:string;controller:RoverController;header:ObservationHeaderTargets;active:boolean}){
|
||||||
const storage=`missioncore.rover-view.v1:${vehicleID}`;
|
const storage=`missioncore.rover-view.v1:${vehicleID}`;
|
||||||
const [settings,setSettings]=useState(()=>{try{return roverSettings(JSON.parse(localStorage.getItem(storage)??'null'));}catch{return roverSettings(null);}});
|
const [settings,setSettings]=useState(()=>{try{return roverSettings(JSON.parse(localStorage.getItem(storage)??'null'));}catch{return roverSettings(null);}});
|
||||||
const [open,setOpen]=useState(false),[confirmed,setConfirmed]=useState(false),[held,setHeld]=useState<Set<string>>(new Set());
|
const [open,setOpen]=useState(false),[confirmed,setConfirmed]=useState(false),[held,setHeld]=useState<Set<string>>(new Set());
|
||||||
|
const [editing,setEditing]=useState(false);
|
||||||
const [modelState,setModelState]=useState<'loading'|'ready'|'error'>('loading');
|
const [modelState,setModelState]=useState<'loading'|'ready'|'error'>('loading');
|
||||||
const stage=useRef<HTMLDivElement>(null),input=useRef<ReturnType<typeof bindRoverHoldInput>|null>(null);
|
const stage=useRef<HTMLDivElement>(null),input=useRef<ReturnType<typeof bindRoverHoldInput>|null>(null);
|
||||||
const stop=useCallback(()=>{input.current?.dispose();input.current=null;c.setInputGuard(null);setHeld(new Set());c.stop();setConfirmed(false);},[c.stop,c.setInputGuard]);
|
const stop=useCallback(()=>{input.current?.dispose();input.current=null;c.setInputGuard(null);setHeld(new Set());c.stop();setConfirmed(false);},[c.stop,c.setInputGuard]);
|
||||||
useEffect(()=>{try{localStorage.setItem(storage,JSON.stringify(settings));}catch{}},[storage,settings]);
|
useEffect(()=>{try{localStorage.setItem(storage,JSON.stringify(settings));}catch{}},[storage,settings]);
|
||||||
useEffect(()=>{if(!active)stop();},[active,stop]);
|
useEffect(()=>{if(!active)stop();},[active,stop]);
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
if(!c.ready||!active||open||!stage.current)return;
|
if(!c.ready||!active||open||editing||!stage.current)return;
|
||||||
const binding=bindRoverHoldInput(stage.current,settings.mode,{held:setHeld,demand:c.setDemand,pause:c.pauseInput,stop});
|
const binding=bindRoverHoldInput(stage.current,settings.mode,{held:setHeld,demand:c.setDemand,pause:c.pauseInput,stop});
|
||||||
input.current=binding;c.setInputGuard(binding.valid);
|
input.current=binding;c.setInputGuard(binding.valid);
|
||||||
return()=>{c.setInputGuard(null);binding.dispose();if(input.current===binding)input.current=null;};
|
return()=>{c.setInputGuard(null);binding.dispose();if(input.current===binding)input.current=null;};
|
||||||
},[c.ready,active,open,settings.mode,c.setDemand,c.setInputGuard,c.pauseInput,stop]);
|
},[c.ready,active,open,editing,settings.mode,c.setDemand,c.setInputGuard,c.pauseInput,stop]);
|
||||||
const showSettings=()=>{stop();setOpen(true);};
|
const showSettings=()=>{setEditing(false);stop();setOpen(true);};
|
||||||
const preparing=c.pending||(c.armed&&!c.ready);
|
const preparing=c.pending||(c.armed&&!c.ready);
|
||||||
const availability=c.connecting?'Синхронизация с бортом':!c.state.fresh?'Нет свежих данных с борта':!c.state.snapshot.supported?'Управление на борту недоступно':c.state.controlling&&!c.armed?'Управление уже включено':!c.canArm&&!c.armed?'Завершение предыдущего управления':null;
|
const availability=c.connecting?'Синхронизация с бортом':!c.state.fresh?'Нет свежих данных с борта':!c.state.snapshot.supported?'Управление на борту недоступно':c.state.controlling&&!c.armed?'Управление уже включено':!c.canArm&&!c.armed?'Завершение предыдущего управления':null;
|
||||||
const tone=preparing||availability?'warning':c.ready?'success':c.state.snapshot.state==='fault'?'warning':'neutral';
|
const tone=preparing||availability?'warning':c.ready?'success':c.state.snapshot.state==='fault'?'warning':'neutral';
|
||||||
const status=preparing?'Подготовка управления':availability??(labels[c.state.snapshot.state??'']??'Наблюдение');
|
const status=preparing?'Подготовка управления':availability??(labels[c.state.snapshot.state??'']??'Наблюдение');
|
||||||
return <>
|
return <>
|
||||||
{createPortal(<IconButton label="Настройки управления ровером" onClick={showSettings}><Icon name="settings"/></IconButton>,header.actionsTarget)}
|
{createPortal(<><IconButton label={editing?"Завершить редактирование сцены":"Редактировать сцену"} aria-pressed={editing} onClick={()=>{c.pauseInput();setHeld(new Set());setEditing(v=>!v);}}><Icon name="edit"/></IconButton><IconButton label="Настройки управления ровером" onClick={showSettings}><Icon name="settings"/></IconButton></>,header.actionsTarget)}
|
||||||
{createPortal(<StatusBadge variant="indicator" tone={tone} aria-label={status} title={status}/>,header.statusTarget)}
|
{createPortal(<StatusBadge variant="indicator" tone={tone} aria-label={status} title={status}/>,header.statusTarget)}
|
||||||
<div className="rover-view" ref={stage} tabIndex={0} aria-label="3D-вид и управление ровером"
|
<div className="rover-view" ref={stage} tabIndex={0} aria-label="3D-вид и управление ровером"
|
||||||
onBlur={e=>{if(c.armed&&!e.currentTarget.contains(e.relatedTarget as Node|null))c.pauseInput();}}>
|
onBlur={e=>{if(c.armed&&!e.currentTarget.contains(e.relatedTarget as Node|null))c.pauseInput();}}>
|
||||||
{settings.model==='dcd006-v020'?<LoadingRegion className="rover-view__scene" loading={modelState==='loading'} label="Загрузка модели ровера">
|
{settings.model==='dcd006-v020'?<LoadingRegion className="rover-view__scene" loading={modelState==='loading'} label="Загрузка модели ровера">
|
||||||
<Suspense fallback={null}><Scene modelUrl="/rover-scene/dcd006-v020.glb" cameraAutoRotate={false} onModelState={setModelState}/></Suspense>
|
<Suspense fallback={null}><Scene key={vehicleID} vehicleID={vehicleID} editing={editing&&active} onModelState={setModelState}/></Suspense>
|
||||||
{modelState==='error'&&<p className="rover-view__empty">Не удалось загрузить модель ровера.</p>}
|
{modelState==='error'&&<p className="rover-view__empty">Не удалось загрузить модель ровера.</p>}
|
||||||
</LoadingRegion>:<div className="rover-view__empty"><p>Выберите модель аппарата.</p><Button onClick={showSettings}>Настройки вида</Button></div>}
|
</LoadingRegion>:<div className="rover-view__empty"><p>Выберите модель аппарата.</p><Button onClick={showSettings}>Настройки вида</Button></div>}
|
||||||
<div className="rover-view__status"><StatusBadge tone={tone}>{status}</StatusBadge></div>
|
<div className="rover-view__status"><StatusBadge tone={editing?'neutral':tone}>{editing?'Редактирование сцены':status}</StatusBadge></div>
|
||||||
{c.ready&&<div className="rover-view__controls">
|
{c.ready&&!editing&&<div className="rover-view__controls">
|
||||||
<div className={`rover-keys rover-keys--${settings.mode}`} aria-label={settings.mode==='arcade'?'Аркадное управление':'Танковое управление'}>
|
<div className={`rover-keys rover-keys--${settings.mode}`} aria-label={settings.mode==='arcade'?'Аркадное управление':'Танковое управление'}>
|
||||||
{keysFor(settings.mode).map(code=><KeyButton key={code} className={`rover-key-${code}`} label={settings.mode==='arcade'?({KeyW:'Вперёд · W',KeyS:'Назад · S',KeyA:'Налево · A',KeyD:'Направо · D'}[code]??code):({KeyQ:'Левая сторона вперёд · Q',KeyA:'Левая сторона назад · A',KeyE:'Правая сторона вперёд · E',KeyD:'Правая сторона назад · D'}[code]??code)}
|
{keysFor(settings.mode).map(code=><KeyButton key={code} className={`rover-key-${code}`} label={settings.mode==='arcade'?({KeyW:'Вперёд · W',KeyS:'Назад · S',KeyA:'Налево · A',KeyD:'Направо · D'}[code]??code):({KeyQ:'Левая сторона вперёд · Q',KeyA:'Левая сторона назад · A',KeyE:'Правая сторона вперёд · E',KeyD:'Правая сторона назад · D'}[code]??code)}
|
||||||
disabled={!c.ready||!active} pressed={held.has(code)}
|
disabled={!c.ready||!active} pressed={held.has(code)}
|
||||||
@@ -50,9 +51,9 @@ export function RoverView({vehicleID,controller:c,header,active}:{vehicleID:stri
|
|||||||
</div>
|
</div>
|
||||||
<span>{settings.mode==='arcade'?'Аркадный':'Танковый'} · пробел — стоп</span>
|
<span>{settings.mode==='arcade'?'Аркадный':'Танковый'} · пробел — стоп</span>
|
||||||
</div>}
|
</div>}
|
||||||
<div className="rover-view__authority">
|
{!editing&&<div className="rover-view__authority">
|
||||||
{c.armed||c.pending?<Button onClick={stop}>Остановить управление</Button>:<Button onClick={showSettings}>Управлять</Button>}
|
{c.armed||c.pending?<Button onClick={stop}>Остановить управление</Button>:<Button onClick={showSettings}>Управлять</Button>}
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
<Window open={open} onClose={()=>setOpen(false)} title="Управление ровером" size="md"><div className="rover-settings">
|
<Window open={open} onClose={()=>setOpen(false)} title="Управление ровером" size="md"><div className="rover-settings">
|
||||||
<Select label="Тип управления" value={settings.mode} options={[{value:'arcade',label:'Аркадный · W A S D'},{value:'tank',label:'Танковый · Q A / E D'}]} onChange={mode=>setSettings(v=>({...v,mode:mode as RoverMode}))}/>
|
<Select label="Тип управления" value={settings.mode} options={[{value:'arcade',label:'Аркадный · W A S D'},{value:'tank',label:'Танковый · Q A / E D'}]} onChange={mode=>setSettings(v=>({...v,mode:mode as RoverMode}))}/>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {LutField} from './LutField';
|
||||||
|
import {useRef} from 'react';
|
||||||
|
import {Button,ColorField,ControlRow,Inspector,InspectorSelectField,RangeControl,Switch,TextField} from '@nodedc/ui-react';
|
||||||
|
import {environmentFields,fieldValue,fieldDisabled,type SceneDocument,type SceneField} from '../../../core/roverScene/sceneDocument';
|
||||||
|
const labels:Record<string,string>={'Камера':'Камера',Lighting:'Освещение',Rendering:'Рендеринг',Grid:'Сетка',SSAO:'Контактное затенение · SSAO',Bloom:'Свечение · Bloom','Chromatic Aberration':'Хроматическая аберрация',TAA:'Временное сглаживание · TAA',Grading:'Цветокоррекция',LUT:'Цветовая таблица · LUT',Vignette:'Виньетка','Environment Atlas':'Карта окружения','Skybox':'Небо','Directional Light':'Направленный свет','Shadow Catcher':'Тень на поверхности'};
|
||||||
|
export function EnvironmentInspector({document,onChange,onResetCamera,onError}:{document:SceneDocument;onChange:(key:string,value:unknown)=>void;onResetCamera:()=>void;onError:(error:string)=>void}){
|
||||||
|
const groups=[...new Set(environmentFields.map(f=>f.group))];
|
||||||
|
return <Inspector defaultOpen={['Камера','PostProcess/Lighting']} sections={groups.map(group=>({id:group,label:labels[group.split('/').at(-1)!]??group,content:<div className="rover-scene-fields">{environmentFields.filter(f=>f.group===group).map(f=><EnvironmentField key={f.key} field={f} value={fieldValue(document,f.key)} disabled={fieldDisabled(document,f)} onChange={v=>onChange(f.key,v)} onResetCamera={onResetCamera} onError={onError}/>)}</div>}))}/>;
|
||||||
|
}
|
||||||
|
function EnvironmentField({field:f,value,disabled,onChange,onResetCamera,onError}:{field:SceneField;value:unknown;disabled:boolean;onChange:(v:unknown)=>void;onResetCamera:()=>void;onError:(error:string)=>void}){
|
||||||
|
if(f.type==='texture')return <LutField value={value as string|null} onChange={onChange} onError={onError}/>;
|
||||||
|
if(f.type==='action')return <Button onClick={onResetCamera}>Сбросить камеру</Button>;
|
||||||
|
if(f.type==='toggle')return <Switch label={f.label} checked={!!value} disabled={disabled} onChange={onChange}/>;
|
||||||
|
if(f.type==='color')return <ControlRow label={f.label}><ColorField label={f.label} value={String(value)} disabled={disabled} onChange={onChange}/></ControlRow>;
|
||||||
|
if(f.type==='select')return <InspectorSelectField label={f.label} value={String(value)} options={f.options??[]} disabled={disabled} onChange={onChange}/>;
|
||||||
|
if(f.type==='doubleSlider'){const range=value as number[];return <>{['Начало тумана, м','Конец тумана, м'].map((label,i)=><RangeControl key={i} label={label} min={f.min??0} max={f.max??100} step={f.step??1} exactValueBounds={{min:f.min,max:f.max}} value={range[i]} disabled={disabled} onChange={v=>onChange(i?[range[0],v]:[v,range[1]])}/>)}</>;}
|
||||||
|
if(f.type==='number')return <TextField type="number" label={f.label} min={f.min} max={f.max} step={f.step??.01} value={Number(value)} disabled={disabled} onChange={e=>onChange(Number(e.target.value))}/>;
|
||||||
|
return <RangeControl label={f.label} value={Number(value)} min={f.min??0} max={f.max??100} step={f.step??.01} exactValueBounds={{min:f.min,max:f.max}} disabled={disabled} onChange={onChange}/>;
|
||||||
|
}
|
||||||
|
export function SceneFileActions({onImport,onExport,onReset}:{onImport:(file:File)=>void;onExport:()=>void;onReset:()=>void}){
|
||||||
|
const input=useRef<HTMLInputElement>(null);
|
||||||
|
return <div className="rover-scene-actions"><Button size="compact" onClick={onExport}>Экспорт JSON</Button><Button size="compact" onClick={()=>input.current?.click()}>Импорт JSON</Button><Button size="compact" onClick={onReset}>Окружение NodeDC</Button><input ref={input} hidden type="file" accept=".json,application/json" onChange={e=>{const f=e.currentTarget.files?.[0];if(f)onImport(f);e.currentTarget.value='';}}/></div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import {useRef,useState} from 'react';
|
||||||
|
import {Button,Icon,IconButton} from '@nodedc/ui-react';
|
||||||
|
import {textureFromFile} from '../../../core/roverScene/sceneStorage';
|
||||||
|
export function LutField({value,onChange,onError}:{value:string|null|undefined;onChange:(value:string|null)=>void;onError:(error:string)=>void}){
|
||||||
|
const input=useRef<HTMLInputElement>(null),[loading,setLoading]=useState(false);
|
||||||
|
return <div className="rover-scene-fields"><p className="rover-scene-note">PNG 256 × 16 · горизонтальная таблица 16³</p><div className="rover-scene-actions"><Button loading={loading} onClick={()=>input.current?.click()}>{value?'Заменить LUT':'Загрузить LUT'}</Button>{value&&<IconButton label="Удалить LUT" onClick={()=>onChange(null)}><Icon name="trash"/></IconButton>}</div><input hidden ref={input} type="file" accept="image/png" onChange={async e=>{const file=e.currentTarget.files?.[0];e.currentTarget.value='';if(!file)return;setLoading(true);try{const bitmap=await createImageBitmap(file);const valid=bitmap.width===256&&bitmap.height===16;bitmap.close();if(!valid||file.type!=='image/png')throw new Error('Нужна PNG-таблица LUT размером 256 × 16.');onChange((await textureFromFile(file)).data);}catch(error){onError(error instanceof Error?error.message:'Не удалось прочитать LUT.');}finally{setLoading(false);}}}/></div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {useRef,useState} from 'react';
|
||||||
|
import {Button,ColorField,ControlRow,Icon,IconButton,Inspector,RangeControl,Switch,TextField} from '@nodedc/ui-react';
|
||||||
|
import {textureSlots,type TextureSlot,type MaterialSpec} from '../../../core/roverScene/sceneDocument';
|
||||||
|
import {textureFromFile} from '../../../core/roverScene/sceneStorage';
|
||||||
|
const slotLabels:Record<TextureSlot,string>={diffuse:'Базовый цвет',normal:'Нормали',roughness:'Шероховатость',metalness:'Металличность',opacity:'Прозрачность',emissive:'Свечение',ao:'Окклюзия · AO'};
|
||||||
|
export function MaterialInspector({material,onChange,onReset,onError}:{material:MaterialSpec;onChange:(patch:Partial<MaterialSpec>)=>void;onReset:()=>void;onError:(message:string)=>void}){
|
||||||
|
const range=(key:keyof MaterialSpec,label:string,max=1)=><RangeControl label={label} value={Number(material[key])} min={0} max={max} step={.01} exactValueBounds={{min:0,max}} onChange={v=>onChange({[key]:v})}/>;
|
||||||
|
return <><TextField label="Название материала" value={material.name} maxLength={120} onChange={e=>onChange({name:e.target.value})}/><p className="rover-scene-note">Изменения применяются ко всем деталям с этим материалом.</p><Inspector defaultOpen={['surface']} sections={[
|
||||||
|
{id:'surface',label:'Поверхность',content:<div className="rover-scene-fields"><ControlRow label="Базовый цвет"><ColorField label="Базовый цвет" value={material.color} onChange={color=>onChange({color})}/></ControlRow>{range('metalness','Металличность')}{range('roughness','Шероховатость')}{range('reflectivity','Отражение')}{range('opacity','Непрозрачность')}<Switch label="Двусторонняя поверхность" checked={material.doubleSided} onChange={doubleSided=>onChange({doubleSided})}/></div>},
|
||||||
|
{id:'coat',label:'Лак и свечение',content:<div className="rover-scene-fields">{range('clearCoat','Слой лака')}{range('clearCoatRoughness','Шероховатость лака')}<ControlRow label="Цвет свечения"><ColorField label="Цвет свечения" value={material.emissive} onChange={emissive=>onChange({emissive})}/></ControlRow>{range('emissiveIntensity','Сила свечения',10)}</div>},
|
||||||
|
{id:'textures',label:'Текстуры',description:'PNG / JPEG / WebP · до 4096 px',content:<div className="rover-scene-fields">{textureSlots.map(slot=><TextureField key={slot} slot={slot} value={material.textures[slot]} onChange={texture=>onChange({textures:{...material.textures,[slot]:texture}})} onError={onError}/>)}{range('normalStrength','Сила нормалей',2)}<RangeControl label="Повторение текстуры" min={.01} max={20} step={.01} exactValueBounds={{min:.01,max:20}} value={material.tiling} onChange={tiling=>onChange({tiling})}/></div>},
|
||||||
|
]}/><Button onClick={onReset}>Исходный материал</Button></>;
|
||||||
|
}
|
||||||
|
function TextureField({slot,value,onChange,onError}:{slot:TextureSlot;value:MaterialSpec['textures'][TextureSlot];onChange:(value:MaterialSpec['textures'][TextureSlot])=>void;onError:(message:string)=>void}){
|
||||||
|
const input=useRef<HTMLInputElement>(null),[pending,setPending]=useState(false);
|
||||||
|
return <div className="rover-scene-texture"><ControlRow label={slotLabels[slot]} layout="stack"><div className="rover-scene-texture__row">{value&&<img src={value.data} alt=""/>}<Button size="compact" loading={pending} onClick={()=>input.current?.click()}>{value?.name??'Загрузить текстуру'}</Button>{value&&<IconButton label={`Удалить текстуру: ${slotLabels[slot]}`} onClick={()=>onChange(undefined)}><Icon name="trash"/></IconButton>}</div></ControlRow><input hidden ref={input} type="file" accept="image/png,image/jpeg,image/webp" onChange={async e=>{const file=e.currentTarget.files?.[0];e.currentTarget.value='';if(!file)return;setPending(true);try{onChange(await textureFromFile(file));}catch(error){onError(error instanceof Error?error.message:'Не удалось загрузить текстуру.');}finally{setPending(false);}}}/></div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {useCallback,useEffect,useState,type CSSProperties} from 'react';
|
||||||
|
import {Button,GlassSurface,Icon,IconButton,LoadingRegion,StatusBadge,ToastStack,Window} from '@nodedc/ui-react';
|
||||||
|
import {assignMaterial,createMaterial,changeField,defaultScene,textureBytes,type MaterialSpec} from '../../../core/roverScene/sceneDocument';
|
||||||
|
import {useSceneDocument} from '../../../core/roverScene/useSceneDocument';
|
||||||
|
import PlayCanvasViewer from '../playcanvas/PlayCanvasViewer';
|
||||||
|
import {EnvironmentInspector,SceneFileActions} from './EnvironmentInspector';
|
||||||
|
import {MaterialInspector} from './MaterialInspector';
|
||||||
|
import './sceneEditor.css';
|
||||||
|
export default function RoverScene({vehicleID,editing,onModelState}:{vehicleID:string;editing:boolean;onModelState:(state:'loading'|'ready'|'error')=>void}){
|
||||||
|
const scene=useSceneDocument(vehicleID),[palette,setPalette]=useState(false),[environment,setEnvironment]=useState(false),[selected,setSelected]=useState<string|null>(null),[materialOpen,setMaterialOpen]=useState(false),[cameraReset,setCameraReset]=useState(0),[textureState,setTextureState]=useState<'loading'|'ready'|'error'>('ready');
|
||||||
|
const [selectedGroup,setSelectedGroup]=useState<string|null>(null);
|
||||||
|
const pick=useCallback((id:string,group?:string)=>{setSelected(id);if(group)setSelectedGroup(group);setMaterialOpen(true);},[]);
|
||||||
|
const addMaterial=()=>{if(Object.keys(scene.document.materials).length>=128){scene.setError('В сцене может быть не больше 128 материалов.');return;}const id=`custom/${crypto.randomUUID()}`;scene.update(doc=>createMaterial(doc,id));pick(id);};
|
||||||
|
const patchMaterial=(id:string,patch:Partial<MaterialSpec>)=>{const next={...scene.document,materials:{...scene.document.materials,[id]:{...scene.document.materials[id],...patch}}};if(textureBytes(next)>28_000_000){scene.setError('Суммарный объём текстур превышает 20 МБ. Удалите ненужную текстуру.');return;}scene.update(doc=>({...doc,materials:{...doc.materials,[id]:{...doc.materials[id],...patch}}}));};
|
||||||
|
useEffect(()=>{if(textureState==='error')scene.setError('Не удалось применить текстуру к сцене. Проверьте файл и загрузите его заново.');},[textureState,scene.setError]);
|
||||||
|
const saved=scene.saveState==='saved',status=textureState==='loading'?'Загрузка текстур':scene.saveState==='loading'?'Загрузка настроек сцены':scene.saveState==='saving'?'Сохранение сцены':scene.saveState==='error'?'Изменения не сохранены':'Сохранено на этом компьютере';
|
||||||
|
const material=selected?scene.document.materials[selected]:null;
|
||||||
|
return <>
|
||||||
|
<LoadingRegion className="rover-view__scene" style={{bottom:editing&&palette?150:0}} loading={!scene.ready} label="Загрузка настроек сцены">
|
||||||
|
{scene.ready&&<PlayCanvasViewer modelUrl="/rover-scene/dcd006-v020.glb?materials-v2" postFx={scene.document.postFx} cameraAutoRotate={scene.document.cameraAutoRotate} cameraZoomStrength={scene.document.cameraZoomStrength} cameraResetToken={cameraReset} materials={scene.document.materials} assignments={scene.document.assignments} editing={editing} onMaterialPick={pick} onModelState={onModelState} onTextureState={setTextureState}/>}
|
||||||
|
</LoadingRegion>
|
||||||
|
{editing&&<>
|
||||||
|
<div className="rover-scene-tools"><GlassSurface className="rover-scene-tools__surface"><IconButton label="Материалы сцены" aria-pressed={palette} onClick={()=>setPalette(v=>!v)}><Icon name="layers"/></IconButton><IconButton label="Окружение сцены" aria-pressed={environment} onClick={()=>setEnvironment(v=>!v)}><Icon name="globe"/></IconButton></GlassSurface></div>
|
||||||
|
<div className="rover-scene-save"><StatusBadge tone={saved&&textureState!=='loading'?'neutral':'warning'}>{status}</StatusBadge></div>
|
||||||
|
{palette&&<GlassSurface className="rover-material-palette"><div className="rover-material-palette__header"><span>Материалы · выберите в палитре или на модели</span><IconButton label="Скрыть палитру материалов" onClick={()=>setPalette(false)}><Icon name="close"/></IconButton></div><div className="rover-material-palette__items">{Object.entries(scene.document.materials).map(([id,m])=><Button key={id} variant={selected===id?'primary':'secondary'} tone="neutral" aria-pressed={selected===id} title={m.name} onClick={()=>pick(id)}><span className="rover-material-tile"><span aria-hidden="true" className="rover-material-swatch" style={{'--material-color':m.color,'--material-gloss':1-m.roughness} as CSSProperties}/><span>{m.name}</span></span></Button>)}<Button aria-label="Добавить материал" title="Добавить материал" onClick={addMaterial}><span className="rover-material-tile"><Icon name="plus"/><span>Новый материал</span></span></Button></div></GlassSurface>}
|
||||||
|
</>}
|
||||||
|
<Window open={editing&&environment} title="Окружение сцены" placement="end" draggable onClose={()=>setEnvironment(false)} footer={<SceneFileActions onImport={scene.importDocument} onExport={scene.exportDocument} onReset={()=>scene.update(doc=>({...doc,postFx:defaultScene().postFx}))}/>}>
|
||||||
|
<EnvironmentInspector onError={scene.setError} document={scene.document} onChange={(key,value)=>scene.update(doc=>changeField(doc,key,value))} onResetCamera={()=>setCameraReset(v=>v+1)}/>
|
||||||
|
</Window>
|
||||||
|
<Window open={editing&&materialOpen&&!!material} title={material?.name||'Материал'} subtitle="Материал сцены" placement="end" draggable style={{position:'fixed',left:'var(--nodedc-space-4)',top:'6rem',right:'auto'}} onClose={()=>setMaterialOpen(false)}>
|
||||||
|
{material&&selected&&<>
|
||||||
|
{selectedGroup?<div className="rover-scene-fields"><p className="rover-scene-note">Выбранная группа: {defaultScene().materials[selectedGroup]?.name}</p><Button disabled={(scene.document.assignments[selectedGroup]??selectedGroup)===selected} onClick={()=>scene.update(doc=>assignMaterial(doc,selectedGroup,selected))}>Назначить выбранной группе</Button></div>:<p className="rover-scene-note">Нажмите на деталь в сцене, затем выберите материал в палитре, чтобы назначить его группе.</p>}
|
||||||
|
<MaterialInspector key={selected} material={material} onChange={patch=>patchMaterial(selected,patch)} onReset={()=>{const base=defaultScene().materials[selected]??createMaterial(defaultScene(),selected).materials[selected];if(base)patchMaterial(selected,{...base,name:material.name});}} onError={scene.setError}/>
|
||||||
|
</>}
|
||||||
|
</Window>
|
||||||
|
<ToastStack items={scene.error?[{id:'rover-scene-error',tone:'error',title:'Настройки сцены',description:scene.error}]:[]} onDismiss={()=>scene.setError('')}/>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.rover-scene-tools {position:absolute;right:var(--nodedc-space-3);top:calc(var(--observation-overlay-header-space, 0px) + var(--nodedc-space-3));}
|
||||||
|
.rover-scene-tools__surface,.rover-scene-actions {display:flex;align-items:center;gap:var(--nodedc-space-2);flex-wrap:wrap;}
|
||||||
|
.rover-scene-tools__surface {padding:var(--nodedc-space-1);}
|
||||||
|
.rover-scene-save {position:absolute;left:var(--nodedc-space-3);top:calc(var(--observation-overlay-header-space, 0px) + 3rem);pointer-events:none;}
|
||||||
|
.rover-material-palette {position:absolute;bottom:var(--nodedc-space-3);left:var(--nodedc-space-3);right:var(--nodedc-space-3);padding:var(--nodedc-space-2);}
|
||||||
|
.rover-material-palette__header {display:flex;align-items:center;justify-content:space-between;gap:var(--nodedc-space-2);font-size:var(--nodedc-font-size-xs);color:var(--nodedc-text-secondary);}
|
||||||
|
.rover-material-palette__items {display:flex;gap:var(--nodedc-space-2);overflow:auto;}
|
||||||
|
.rover-material-palette__items > * {flex:0 0 auto;}
|
||||||
|
.rover-material-tile {display:grid;justify-items:center;gap:var(--nodedc-space-1);width:100px;white-space:normal;font-size:var(--nodedc-font-size-xs);}
|
||||||
|
/* Domain material thumbnail: a swatch, not a replacement control. */
|
||||||
|
.rover-material-swatch {display:block;width:38px;height:38px;border-radius:50%;background:radial-gradient(ellipse at 35% 25%,#ffffffb0,transparent 45%),radial-gradient(ellipse at 60% 75%,transparent,var(--material-color) 70%),var(--material-color);box-shadow:inset -5px -5px 12px #0008;}
|
||||||
|
.rover-scene-fields {display:flex;flex-direction:column;gap:var(--nodedc-space-3);}
|
||||||
|
.rover-scene-note {font-size:var(--nodedc-font-size-sm);color:var(--nodedc-text-secondary);}
|
||||||
|
.rover-scene-texture__row {display:flex;gap:var(--nodedc-space-2);align-items:center;min-width:0;}
|
||||||
|
.rover-scene-texture__row img {width:36px;height:36px;object-fit:cover;}
|
||||||
|
.rover-scene-texture__row .nodedc-button {min-width:0;overflow:hidden;}
|
||||||
|
@container(max-width:460px){.rover-scene-save{top:calc(var(--observation-overlay-header-space, 0px) + 4.5rem)}.rover-material-tile{width:82px}.rover-material-palette__header>span{max-width:210px}}
|
||||||
@@ -11,6 +11,13 @@
|
|||||||
"No fallback to a different vehicle",
|
"No fallback to a different vehicle",
|
||||||
"Removed unused node-editor type helper; rendering settings unchanged",
|
"Removed unused node-editor type helper; rendering settings unchanged",
|
||||||
"Observe the host pane on resize; preserve responsive canvas CSS sizing",
|
"Observe the host pane on resize; preserve responsive canvas CSS sizing",
|
||||||
"Frame the complete rover using the donor 28-degree FOV and current viewport aspect"
|
"Frame the complete rover using the donor 28-degree FOV and current viewport aspect",
|
||||||
]
|
"Scene editing uses canonical modeless Design Guideline inspectors; all 109 donor camera/post-FX fields retained as a versioned field catalog",
|
||||||
|
"Original NodeDC environment preset restored by owner request; editor reset changes environment only",
|
||||||
|
"Semantic material groups, UV texture editing, GPU surface picking and bounded texture disposal",
|
||||||
|
"Live grid step/diameter and ambient fill added; wireframe application and LUT loading corrected",
|
||||||
|
"Responsive scene framing and palette space without drive authority"
|
||||||
|
],
|
||||||
|
"inspector_source_sha256": "8f1df16bd8c12fb1b8481480690ff4ed0e6b2645cb3fba527888c0f9acb59192",
|
||||||
|
"model_revision": "dcd006-v020-materials-v2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import * as pc from 'playcanvas'
|
|||||||
import { buildSceneTree } from './sceneTree'
|
import { buildSceneTree } from './sceneTree'
|
||||||
import { mergePostFxDefaults, type PostFxSettings } from './playcanvasPostFx'
|
import { mergePostFxDefaults, type PostFxSettings } from './playcanvasPostFx'
|
||||||
|
|
||||||
|
import {useLutTexture} from './useLutTexture'
|
||||||
|
import {useSceneMaterials,type SceneMaterialsProps} from './useSceneMaterials'
|
||||||
|
|
||||||
const DEFAULT_MODEL_URL = ''
|
const DEFAULT_MODEL_URL = ''
|
||||||
const PIXELFORMAT_RGBA8 = (pc as any).PIXELFORMAT_RGBA8 ?? 7
|
const PIXELFORMAT_RGBA8 = (pc as any).PIXELFORMAT_RGBA8 ?? 7
|
||||||
const PIXELFORMAT_111110F = (pc as any).PIXELFORMAT_111110F ?? 18
|
const PIXELFORMAT_111110F = (pc as any).PIXELFORMAT_111110F ?? 18
|
||||||
const PIXELFORMAT_RGBA16F = (pc as any).PIXELFORMAT_RGBA16F ?? 12
|
const PIXELFORMAT_RGBA16F = (pc as any).PIXELFORMAT_RGBA16F ?? 12
|
||||||
const PIXELFORMAT_RGBA32F = (pc as any).PIXELFORMAT_RGBA32F ?? 14
|
const PIXELFORMAT_RGBA32F = (pc as any).PIXELFORMAT_RGBA32F ?? 14
|
||||||
|
|
||||||
export type PlayCanvasViewerProps = {
|
export type PlayCanvasViewerProps = SceneMaterialsProps & {
|
||||||
modelUrl: string | null
|
modelUrl: string | null
|
||||||
postFx?: Partial<PostFxSettings> | null
|
postFx?: Partial<PostFxSettings> | null
|
||||||
viewportSize?: { width: number; height: number }
|
viewportSize?: { width: number; height: number }
|
||||||
@@ -390,16 +393,9 @@ function toColor(value: ColorInput, fallback?: pc.Color): pc.Color {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setWireframeForScene(app: pc.Application, enabled: boolean) {
|
function setWireframeForScene(app: pc.Application, enabled: boolean) {
|
||||||
const renders = app.root.findComponents('render') as any[]
|
const root = app.root.findByName('ModelRoot') as pc.Entity | null
|
||||||
for (const r of renders) {
|
for (const mi of collectMeshInstances(root)) {
|
||||||
const meshInstances = r?.meshInstances ?? []
|
mi.renderStyle = enabled ? pc.RENDERSTYLE_WIREFRAME : pc.RENDERSTYLE_SOLID
|
||||||
for (const mi of meshInstances) {
|
|
||||||
const mat = mi.material as any
|
|
||||||
if (mat && 'wireframe' in mat) {
|
|
||||||
mat.wireframe = enabled
|
|
||||||
mat.update?.()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,7 +801,7 @@ function applyPostFx(
|
|||||||
}
|
}
|
||||||
frame.rendering.renderTargetScale = fx.rendering.renderTargetScale
|
frame.rendering.renderTargetScale = fx.rendering.renderTargetScale
|
||||||
frame.rendering.samples = Number.isFinite(overrides?.samples) ? Number(overrides?.samples) : fx.rendering.samples
|
frame.rendering.samples = Number.isFinite(overrides?.samples) ? Number(overrides?.samples) : fx.rendering.samples
|
||||||
frame.rendering.sceneColorMap = true
|
frame.rendering.sceneColorMap = !!fx.rendering.sceneColorMap
|
||||||
frame.rendering.sceneDepthMap = !!fx.rendering.sceneDepthMap
|
frame.rendering.sceneDepthMap = !!fx.rendering.sceneDepthMap
|
||||||
frame.rendering.toneMapping = fx.rendering.toneMapping
|
frame.rendering.toneMapping = fx.rendering.toneMapping
|
||||||
frame.rendering.sharpness = fx.rendering.sharpness
|
frame.rendering.sharpness = fx.rendering.sharpness
|
||||||
@@ -882,6 +878,7 @@ function applyPostFx(
|
|||||||
|
|
||||||
export default function PlayCanvasViewer({
|
export default function PlayCanvasViewer({
|
||||||
modelUrl,
|
modelUrl,
|
||||||
|
materials, assignments, editing, onMaterialPick, onTextureState,
|
||||||
postFx,
|
postFx,
|
||||||
viewportSize,
|
viewportSize,
|
||||||
onSceneReady,
|
onSceneReady,
|
||||||
@@ -928,7 +925,11 @@ export default function PlayCanvasViewer({
|
|||||||
const [skyboxVersion, setSkyboxVersion] = useState(0)
|
const [skyboxVersion, setSkyboxVersion] = useState(0)
|
||||||
const [modelVersion, setModelVersion] = useState(0)
|
const [modelVersion, setModelVersion] = useState(0)
|
||||||
|
|
||||||
|
useSceneMaterials(appRef,cameraEntityRef,canvasRef,modelVersion,{materials,assignments,editing,onMaterialPick,onTextureState})
|
||||||
|
|
||||||
const fx = useMemo(() => mergePostFxDefaults(postFx), [postFx])
|
const fx = useMemo(() => mergePostFxDefaults(postFx), [postFx])
|
||||||
|
const lutError = useCallback(()=>onTextureState?.('error'),[onTextureState])
|
||||||
|
useLutTexture(appRef,cameraFrameRef,lutTextureRef,fx.lut.textureUrl,appReady,lutError)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cameraStateRef.current = cameraState ?? null
|
cameraStateRef.current = cameraState ?? null
|
||||||
@@ -962,7 +963,7 @@ export default function PlayCanvasViewer({
|
|||||||
if (bounds && orbit) {
|
if (bounds && orbit) {
|
||||||
const vertical = (cameraEntityRef.current?.camera?.fov ?? 28) * Math.PI / 360
|
const vertical = (cameraEntityRef.current?.camera?.fov ?? 28) * Math.PI / 360
|
||||||
const horizontal = Math.atan(Math.tan(vertical) * w / h)
|
const horizontal = Math.atan(Math.tan(vertical) * w / h)
|
||||||
orbit.setDistance(Math.max(1.8, bounds.halfExtents.length() / Math.sin(Math.min(vertical, horizontal)) * 1.1))
|
orbit.setDistance(Math.max(1.8, bounds.halfExtents.length() / Math.sin(Math.min(vertical, horizontal)) * 0.88))
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -1112,6 +1113,8 @@ export default function PlayCanvasViewer({
|
|||||||
`,
|
`,
|
||||||
fragmentGLSL: `
|
fragmentGLSL: `
|
||||||
uniform vec2 uHalfExtents;
|
uniform vec2 uHalfExtents;
|
||||||
|
uniform float uGridStep;
|
||||||
|
uniform float uGridRadius;
|
||||||
uniform vec3 uColorX;
|
uniform vec3 uColorX;
|
||||||
uniform vec3 uColorZ;
|
uniform vec3 uColorZ;
|
||||||
uniform vec3 uColorMain;
|
uniform vec3 uColorMain;
|
||||||
@@ -1182,7 +1185,9 @@ export default function PlayCanvasViewer({
|
|||||||
void main(void) {
|
void main(void) {
|
||||||
vec2 uv = uv0;
|
vec2 uv = uv0;
|
||||||
|
|
||||||
vec2 pos = (uv * 2.0 - 1.0) * uHalfExtents;
|
vec2 worldPos = (uv * 2.0 - 1.0) * uHalfExtents;
|
||||||
|
if (length(worldPos) > uGridRadius) discard;
|
||||||
|
vec2 pos = worldPos / uGridStep;
|
||||||
vec2 ddx = dFdx(pos);
|
vec2 ddx = dFdx(pos);
|
||||||
vec2 ddy = dFdy(pos);
|
vec2 ddy = dFdy(pos);
|
||||||
|
|
||||||
@@ -1262,7 +1267,7 @@ export default function PlayCanvasViewer({
|
|||||||
float fadeEnd = max(0.0, uFadeEnd);
|
float fadeEnd = max(0.0, uFadeEnd);
|
||||||
float fade = 1.0;
|
float fade = 1.0;
|
||||||
if (fadeEnd > fadeStart && fadeEnd > 0.0) {
|
if (fadeEnd > fadeStart && fadeEnd > 0.0) {
|
||||||
float d = length(pos);
|
float d = length(worldPos);
|
||||||
fade = 1.0 - smoothstep(fadeStart, fadeEnd, d);
|
fade = 1.0 - smoothstep(fadeStart, fadeEnd, d);
|
||||||
}
|
}
|
||||||
outAlpha *= fade;
|
outAlpha *= fade;
|
||||||
@@ -1312,6 +1317,8 @@ export default function PlayCanvasViewer({
|
|||||||
gridMaterial.setParameter('uFadeStart', 0)
|
gridMaterial.setParameter('uFadeStart', 0)
|
||||||
gridMaterial.setParameter('uFadeEnd', 0)
|
gridMaterial.setParameter('uFadeEnd', 0)
|
||||||
gridMaterial.setParameter('uResolution', 2)
|
gridMaterial.setParameter('uResolution', 2)
|
||||||
|
gridMaterial.setParameter('uGridStep', fx.grid.step)
|
||||||
|
gridMaterial.setParameter('uGridRadius', fx.grid.diameter / 2)
|
||||||
const gridHalfExtents = new pc.Vec2(500, 500)
|
const gridHalfExtents = new pc.Vec2(500, 500)
|
||||||
gridMaterial.setParameter('uHalfExtents', [gridHalfExtents.x, gridHalfExtents.y])
|
gridMaterial.setParameter('uHalfExtents', [gridHalfExtents.x, gridHalfExtents.y])
|
||||||
gridMaterial.update()
|
gridMaterial.update()
|
||||||
@@ -1528,7 +1535,7 @@ export default function PlayCanvasViewer({
|
|||||||
const cam = cameraEntityRef.current?.camera
|
const cam = cameraEntityRef.current?.camera
|
||||||
const vertical = (cam?.fov ?? 28) * Math.PI / 360
|
const vertical = (cam?.fov ?? 28) * Math.PI / 360
|
||||||
const horizontal = Math.atan(Math.tan(vertical) * (cam?.aspectRatio ?? 1))
|
const horizontal = Math.atan(Math.tan(vertical) * (cam?.aspectRatio ?? 1))
|
||||||
orbit.setDistance(Math.max(1.8, aabb.halfExtents.length() / Math.sin(Math.min(vertical, horizontal)) * 1.1))
|
orbit.setDistance(Math.max(1.8, aabb.halfExtents.length() / Math.sin(Math.min(vertical, horizontal)) * 0.88))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exposeSceneGraph || onSceneGraph) {
|
if (exposeSceneGraph || onSceneGraph) {
|
||||||
@@ -1617,7 +1624,7 @@ export default function PlayCanvasViewer({
|
|||||||
if (!aabb) return
|
if (!aabb) return
|
||||||
orbit.setState({
|
orbit.setState({
|
||||||
pivot: { x: aabb.center.x, y: aabb.center.y, z: aabb.center.z },
|
pivot: { x: aabb.center.x, y: aabb.center.y, z: aabb.center.z },
|
||||||
distance: Math.max(1.8, aabb.halfExtents.length() * 2.5),
|
distance: Math.max(1.8,aabb.halfExtents.length() / Math.sin(Math.min(14*Math.PI/180,Math.atan(Math.tan(14*Math.PI/180)*(cameraEntityRef.current?.camera?.aspectRatio??1))))*.88),
|
||||||
azimuthDeg: 45,
|
azimuthDeg: 45,
|
||||||
elevationDeg: 10,
|
elevationDeg: 10,
|
||||||
})
|
})
|
||||||
@@ -1638,6 +1645,9 @@ export default function PlayCanvasViewer({
|
|||||||
|
|
||||||
const gridVisible = !!fx.grid.enabled || !!fx.grid.dotsEnabled || !!fx.grid.crossEnabled
|
const gridVisible = !!fx.grid.enabled || !!fx.grid.dotsEnabled || !!fx.grid.crossEnabled
|
||||||
grid.enabled = gridVisible
|
grid.enabled = gridVisible
|
||||||
|
grid.setLocalScale(fx.grid.diameter,1,fx.grid.diameter)
|
||||||
|
mat.setParameter('uGridStep',fx.grid.step)
|
||||||
|
mat.setParameter('uGridRadius',fx.grid.diameter/2)
|
||||||
const cX = toColor(fx.grid.colorX)
|
const cX = toColor(fx.grid.colorX)
|
||||||
const cZ = toColor(fx.grid.colorZ)
|
const cZ = toColor(fx.grid.colorZ)
|
||||||
const cMain = toColor(fx.grid.colorMain)
|
const cMain = toColor(fx.grid.colorMain)
|
||||||
@@ -1661,6 +1671,7 @@ export default function PlayCanvasViewer({
|
|||||||
mat.setParameter('uFadeEnd', Math.max(0, Number(fx.grid.fadeEnd) || 0))
|
mat.setParameter('uFadeEnd', Math.max(0, Number(fx.grid.fadeEnd) || 0))
|
||||||
mat.update()
|
mat.update()
|
||||||
}, [
|
}, [
|
||||||
|
fx.grid.step,fx.grid.diameter,
|
||||||
fx.grid.enabled,
|
fx.grid.enabled,
|
||||||
fx.grid.colorX,
|
fx.grid.colorX,
|
||||||
fx.grid.colorZ,
|
fx.grid.colorZ,
|
||||||
@@ -1690,7 +1701,7 @@ export default function PlayCanvasViewer({
|
|||||||
const app = appRef.current
|
const app = appRef.current
|
||||||
if (!app) return
|
if (!app) return
|
||||||
setWireframeForScene(app, fx.rendering.wireframe)
|
setWireframeForScene(app, fx.rendering.wireframe)
|
||||||
}, [fx.rendering.wireframe])
|
}, [fx.rendering.wireframe,modelVersion])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frame: any = cameraFrameRef.current
|
const frame: any = cameraFrameRef.current
|
||||||
@@ -1703,9 +1714,10 @@ export default function PlayCanvasViewer({
|
|||||||
const app = appRef.current
|
const app = appRef.current
|
||||||
if (!app) return
|
if (!app) return
|
||||||
if (fx.lighting) {
|
if (fx.lighting) {
|
||||||
|
app.scene.ambientLight = new pc.Color(fx.lighting.ambientIntensity,fx.lighting.ambientIntensity,fx.lighting.ambientIntensity)
|
||||||
app.scene.exposure = fx.lighting.exposure
|
app.scene.exposure = fx.lighting.exposure
|
||||||
}
|
}
|
||||||
}, [fx.lighting?.exposure])
|
}, [fx.lighting?.exposure,fx.lighting.ambientIntensity])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const app = appRef.current
|
const app = appRef.current
|
||||||
@@ -1924,6 +1936,8 @@ export default function PlayCanvasViewer({
|
|||||||
envAtlasVersion,
|
envAtlasVersion,
|
||||||
skyboxVersion,
|
skyboxVersion,
|
||||||
modelVersion,
|
modelVersion,
|
||||||
|
materials,
|
||||||
|
assignments,
|
||||||
fx.envAtlas.enabled,
|
fx.envAtlas.enabled,
|
||||||
fx.envAtlas.background,
|
fx.envAtlas.background,
|
||||||
fx.envAtlas.reflection,
|
fx.envAtlas.reflection,
|
||||||
|
|||||||
@@ -1,393 +1,2 @@
|
|||||||
|
// Shared serializable contract; donor defaults remain available.
|
||||||
export type PostFxSettings = {
|
export * from '../../../core/roverScene/postFx';
|
||||||
lighting: {
|
|
||||||
exposure: number
|
|
||||||
skyBoxIntensity: number
|
|
||||||
}
|
|
||||||
envAtlas: {
|
|
||||||
enabled: boolean
|
|
||||||
background: boolean
|
|
||||||
reflection: boolean
|
|
||||||
intensity: number
|
|
||||||
reflectionIntensity: number
|
|
||||||
brightness: number
|
|
||||||
contrast: number
|
|
||||||
saturation: number
|
|
||||||
toneMapping: number
|
|
||||||
mip: number
|
|
||||||
rotation: number
|
|
||||||
}
|
|
||||||
skybox: {
|
|
||||||
enabled: boolean
|
|
||||||
background: boolean
|
|
||||||
reflection: boolean
|
|
||||||
intensity: number
|
|
||||||
reflectionIntensity: number
|
|
||||||
mip: number
|
|
||||||
rotation: number
|
|
||||||
colorA: string
|
|
||||||
colorB: string
|
|
||||||
}
|
|
||||||
rendering: {
|
|
||||||
backgroundColor: string
|
|
||||||
wireframe: boolean
|
|
||||||
renderFormat: number
|
|
||||||
renderFormatFallback0: number
|
|
||||||
renderFormatFallback1: number
|
|
||||||
stencil: boolean
|
|
||||||
renderTargetScale: number
|
|
||||||
samples: number
|
|
||||||
sharpness: number
|
|
||||||
toneMapping: number
|
|
||||||
sceneColorMap: boolean
|
|
||||||
sceneDepthMap: boolean
|
|
||||||
fog: 'none' | 'linear' | 'exp' | 'exp2'
|
|
||||||
fogColor: string
|
|
||||||
fogRange: [number, number]
|
|
||||||
fogDensity: number
|
|
||||||
fogStart: number
|
|
||||||
fogEnd: number
|
|
||||||
}
|
|
||||||
grid: {
|
|
||||||
enabled: boolean
|
|
||||||
colorX: string
|
|
||||||
colorZ: string
|
|
||||||
colorMain: string
|
|
||||||
alphaX: number
|
|
||||||
alphaZ: number
|
|
||||||
alphaMain: number
|
|
||||||
dotsEnabled: boolean
|
|
||||||
dotsColor: string
|
|
||||||
dotsAlpha: number
|
|
||||||
dotsDiameter: number
|
|
||||||
crossEnabled: boolean
|
|
||||||
crossColor: string
|
|
||||||
crossAlpha: number
|
|
||||||
crossLength: number
|
|
||||||
crossWidth: number
|
|
||||||
fadeStart: number
|
|
||||||
fadeEnd: number
|
|
||||||
}
|
|
||||||
ssao: {
|
|
||||||
type: 'none' | 'lighting' | 'combine'
|
|
||||||
blurEnabled: boolean
|
|
||||||
intensity: number
|
|
||||||
radius: number
|
|
||||||
samples: number
|
|
||||||
power: number
|
|
||||||
minAngle: number
|
|
||||||
scale: number
|
|
||||||
}
|
|
||||||
bloom: {
|
|
||||||
enabled: boolean
|
|
||||||
intensity: number
|
|
||||||
lastMipLevel: number
|
|
||||||
}
|
|
||||||
chromaticAberration: {
|
|
||||||
enabled: boolean
|
|
||||||
intensity: number
|
|
||||||
}
|
|
||||||
taa: {
|
|
||||||
enabled: boolean
|
|
||||||
jitter: number
|
|
||||||
}
|
|
||||||
grading: {
|
|
||||||
enabled: boolean
|
|
||||||
brightness: number
|
|
||||||
contrast: number
|
|
||||||
saturation: number
|
|
||||||
tint: string
|
|
||||||
}
|
|
||||||
lut: {
|
|
||||||
intensity: number
|
|
||||||
textureUrl?: string | null
|
|
||||||
}
|
|
||||||
vignette: {
|
|
||||||
enabled: boolean
|
|
||||||
intensity: number
|
|
||||||
inner: number
|
|
||||||
outer: number
|
|
||||||
curvature: number
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
directionalLight: {
|
|
||||||
enabled: boolean
|
|
||||||
color: string
|
|
||||||
intensity: number
|
|
||||||
azimuth: number
|
|
||||||
elevation: number
|
|
||||||
castShadows: boolean
|
|
||||||
shadowIntensity: number
|
|
||||||
shadowDistance: number
|
|
||||||
shadowResolution: number
|
|
||||||
shadowBias: number
|
|
||||||
normalOffsetBias: number
|
|
||||||
shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3'
|
|
||||||
vsmBlurSize: number
|
|
||||||
}
|
|
||||||
shadowCatcher: {
|
|
||||||
enabled: boolean
|
|
||||||
size: number
|
|
||||||
yOffset: number
|
|
||||||
lightIntensity: number
|
|
||||||
lightColor: string
|
|
||||||
lightAzimuth: number
|
|
||||||
lightElevation: number
|
|
||||||
shadowIntensity: number
|
|
||||||
shadowDistance: number
|
|
||||||
shadowResolution: number
|
|
||||||
shadowBias: number
|
|
||||||
normalOffsetBias: number
|
|
||||||
shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3'
|
|
||||||
vsmBlurSize: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_POSTFX: PostFxSettings = {
|
|
||||||
lighting: {
|
|
||||||
exposure: 1.21,
|
|
||||||
skyBoxIntensity: 0.86,
|
|
||||||
},
|
|
||||||
envAtlas: {
|
|
||||||
enabled: true,
|
|
||||||
background: false,
|
|
||||||
reflection: true,
|
|
||||||
intensity: 0.76,
|
|
||||||
reflectionIntensity: 0.6,
|
|
||||||
brightness: 1.65,
|
|
||||||
contrast: 1,
|
|
||||||
saturation: 1,
|
|
||||||
toneMapping: 0,
|
|
||||||
mip: 1,
|
|
||||||
rotation: 247,
|
|
||||||
},
|
|
||||||
skybox: {
|
|
||||||
enabled: false,
|
|
||||||
background: false,
|
|
||||||
reflection: true,
|
|
||||||
intensity: 0.62,
|
|
||||||
reflectionIntensity: 1,
|
|
||||||
mip: 0,
|
|
||||||
rotation: 0,
|
|
||||||
colorA: '#c5bfbf',
|
|
||||||
colorB: '#c7bcbc',
|
|
||||||
},
|
|
||||||
rendering: {
|
|
||||||
backgroundColor: '#111113',
|
|
||||||
wireframe: false,
|
|
||||||
renderFormat: 18,
|
|
||||||
renderFormatFallback0: 12,
|
|
||||||
renderFormatFallback1: 14,
|
|
||||||
stencil: false,
|
|
||||||
renderTargetScale: 1,
|
|
||||||
samples: 4,
|
|
||||||
sharpness: 0,
|
|
||||||
toneMapping: 4,
|
|
||||||
sceneColorMap: false,
|
|
||||||
sceneDepthMap: false,
|
|
||||||
fog: 'exp',
|
|
||||||
fogColor: '#dcc2ff',
|
|
||||||
fogRange: [0, 100],
|
|
||||||
fogDensity: 0.008,
|
|
||||||
fogStart: 0,
|
|
||||||
fogEnd: 100,
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
enabled: true,
|
|
||||||
colorX: '#ffffff',
|
|
||||||
colorZ: '#ffffff',
|
|
||||||
colorMain: '#7a3cff',
|
|
||||||
alphaX: 0.18,
|
|
||||||
alphaZ: 0.18,
|
|
||||||
alphaMain: 0.6,
|
|
||||||
dotsEnabled: false,
|
|
||||||
dotsColor: '#ffffff',
|
|
||||||
dotsAlpha: 0.5,
|
|
||||||
dotsDiameter: 0.06,
|
|
||||||
crossEnabled: false,
|
|
||||||
crossColor: '#ffffff',
|
|
||||||
crossAlpha: 0.5,
|
|
||||||
crossLength: 0.5,
|
|
||||||
crossWidth: 0.06,
|
|
||||||
fadeStart: 0,
|
|
||||||
fadeEnd: 0,
|
|
||||||
},
|
|
||||||
ssao: {
|
|
||||||
type: 'none',
|
|
||||||
blurEnabled: true,
|
|
||||||
intensity: 0.5,
|
|
||||||
radius: 30,
|
|
||||||
samples: 12,
|
|
||||||
power: 6,
|
|
||||||
minAngle: 10,
|
|
||||||
scale: 1,
|
|
||||||
},
|
|
||||||
bloom: {
|
|
||||||
enabled: true,
|
|
||||||
intensity: 0.03,
|
|
||||||
lastMipLevel: 4,
|
|
||||||
},
|
|
||||||
chromaticAberration: {
|
|
||||||
enabled: true,
|
|
||||||
intensity: 30,
|
|
||||||
},
|
|
||||||
taa: {
|
|
||||||
enabled: false,
|
|
||||||
jitter: 1,
|
|
||||||
},
|
|
||||||
grading: {
|
|
||||||
enabled: true,
|
|
||||||
brightness: 0.837,
|
|
||||||
contrast: 1.1,
|
|
||||||
saturation: 1.126,
|
|
||||||
tint: '#ffffff',
|
|
||||||
},
|
|
||||||
lut: {
|
|
||||||
intensity: 1,
|
|
||||||
textureUrl: null,
|
|
||||||
},
|
|
||||||
vignette: {
|
|
||||||
enabled: true,
|
|
||||||
intensity: 1,
|
|
||||||
inner: 0.25,
|
|
||||||
outer: 1.52,
|
|
||||||
curvature: 0.78,
|
|
||||||
color: '#000000',
|
|
||||||
},
|
|
||||||
directionalLight: {
|
|
||||||
enabled: true,
|
|
||||||
color: '#ffffff',
|
|
||||||
intensity: 0.4,
|
|
||||||
azimuth: 0,
|
|
||||||
elevation: 0,
|
|
||||||
castShadows: false,
|
|
||||||
shadowIntensity: 0.5,
|
|
||||||
shadowDistance: 16,
|
|
||||||
shadowResolution: 1024,
|
|
||||||
shadowBias: 0,
|
|
||||||
normalOffsetBias: 0,
|
|
||||||
shadowType: 'vsm16',
|
|
||||||
vsmBlurSize: 8,
|
|
||||||
},
|
|
||||||
shadowCatcher: {
|
|
||||||
enabled: true,
|
|
||||||
size: 9,
|
|
||||||
yOffset: 0.001,
|
|
||||||
lightIntensity: 0.3,
|
|
||||||
lightColor: '#ffffff',
|
|
||||||
lightAzimuth: 20,
|
|
||||||
lightElevation: 60,
|
|
||||||
shadowIntensity: 0.29,
|
|
||||||
shadowDistance: 16,
|
|
||||||
shadowResolution: 2048,
|
|
||||||
shadowBias: 0,
|
|
||||||
normalOffsetBias: 0,
|
|
||||||
shadowType: 'vsm16',
|
|
||||||
vsmBlurSize: 8,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function isObject(value: any): value is Record<string, any> {
|
|
||||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergePostFxDefaults(raw?: Partial<PostFxSettings> | null): PostFxSettings {
|
|
||||||
if (!raw) return JSON.parse(JSON.stringify(DEFAULT_POSTFX)) as PostFxSettings
|
|
||||||
|
|
||||||
const out: any = JSON.parse(JSON.stringify(DEFAULT_POSTFX))
|
|
||||||
const merge = (target: any, source: any) => {
|
|
||||||
if (!isObject(source)) return
|
|
||||||
for (const [key, value] of Object.entries(source)) {
|
|
||||||
if (isObject(value)) {
|
|
||||||
if (!isObject(target[key])) target[key] = {}
|
|
||||||
merge(target[key], value)
|
|
||||||
} else {
|
|
||||||
target[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
merge(out, raw)
|
|
||||||
|
|
||||||
const legacy: any = raw as any
|
|
||||||
if (legacy?.hdrLight) {
|
|
||||||
const src = legacy.hdrLight
|
|
||||||
const env = legacy?.envAtlas ?? {}
|
|
||||||
if (env?.enabled === undefined && typeof src.enabled === 'boolean') out.envAtlas.enabled = src.enabled
|
|
||||||
if (env?.background === undefined && typeof src.showSkybox === 'boolean') out.envAtlas.background = src.showSkybox
|
|
||||||
if (env?.intensity === undefined && typeof src.intensity === 'number') out.envAtlas.intensity = src.intensity
|
|
||||||
if (env?.mip === undefined && typeof src.mip === 'number') out.envAtlas.mip = src.mip
|
|
||||||
if (env?.rotation === undefined && typeof src.rotation === 'number') out.envAtlas.rotation = src.rotation
|
|
||||||
}
|
|
||||||
if (legacy?.hdrReflection && (legacy?.envAtlas?.reflection === undefined)) {
|
|
||||||
if (typeof legacy.hdrReflection.enabled === 'boolean') {
|
|
||||||
out.envAtlas.reflection = legacy.hdrReflection.enabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (out.envAtlas && typeof out.envAtlas.reflectionIntensity !== 'number') {
|
|
||||||
out.envAtlas.reflectionIntensity = out.envAtlas.intensity
|
|
||||||
}
|
|
||||||
if (out.envAtlas && typeof out.envAtlas.brightness !== 'number') {
|
|
||||||
out.envAtlas.brightness = 1
|
|
||||||
}
|
|
||||||
if (out.envAtlas && typeof out.envAtlas.contrast !== 'number') {
|
|
||||||
out.envAtlas.contrast = 1
|
|
||||||
}
|
|
||||||
if (out.envAtlas && typeof out.envAtlas.saturation !== 'number') {
|
|
||||||
out.envAtlas.saturation = 1
|
|
||||||
}
|
|
||||||
if (out.envAtlas && typeof out.envAtlas.toneMapping !== 'number') {
|
|
||||||
out.envAtlas.toneMapping = 0
|
|
||||||
}
|
|
||||||
if (out.skybox && typeof out.skybox.reflectionIntensity !== 'number') {
|
|
||||||
out.skybox.reflectionIntensity = out.skybox.intensity
|
|
||||||
}
|
|
||||||
if (out.grid && typeof out.grid.enabled !== 'boolean') {
|
|
||||||
out.grid.enabled = DEFAULT_POSTFX.grid.enabled
|
|
||||||
}
|
|
||||||
if (out.grid && typeof out.grid.colorX !== 'string') {
|
|
||||||
out.grid.colorX = DEFAULT_POSTFX.grid.colorX
|
|
||||||
}
|
|
||||||
if (out.grid && typeof out.grid.colorZ !== 'string') {
|
|
||||||
out.grid.colorZ = DEFAULT_POSTFX.grid.colorZ
|
|
||||||
}
|
|
||||||
if (out.grid && typeof out.grid.colorMain !== 'string') {
|
|
||||||
out.grid.colorMain = DEFAULT_POSTFX.grid.colorMain
|
|
||||||
}
|
|
||||||
if (out.grid && typeof out.grid.alphaX !== 'number') {
|
|
||||||
out.grid.alphaX = DEFAULT_POSTFX.grid.alphaX
|
|
||||||
}
|
|
||||||
if (out.grid && typeof out.grid.alphaZ !== 'number') {
|
|
||||||
out.grid.alphaZ = DEFAULT_POSTFX.grid.alphaZ
|
|
||||||
}
|
|
||||||
if (out.grid && typeof out.grid.alphaMain !== 'number') {
|
|
||||||
out.grid.alphaMain = DEFAULT_POSTFX.grid.alphaMain
|
|
||||||
}
|
|
||||||
if (out.grid && typeof out.grid.dotsEnabled !== 'boolean') {
|
|
||||||
out.grid.dotsEnabled = DEFAULT_POSTFX.grid.dotsEnabled
|
|
||||||
}
|
|
||||||
if (out.grid && typeof out.grid.dotsColor !== 'string') {
|
|
||||||
out.grid.dotsColor = DEFAULT_POSTFX.grid.dotsColor
|
|
||||||
}
|
|
||||||
if (out.grid && typeof out.grid.dotsAlpha !== 'number') {
|
|
||||||
out.grid.dotsAlpha = DEFAULT_POSTFX.grid.dotsAlpha
|
|
||||||
}
|
|
||||||
if (out.grid && typeof out.grid.dotsDiameter !== 'number') {
|
|
||||||
out.grid.dotsDiameter = DEFAULT_POSTFX.grid.dotsDiameter
|
|
||||||
}
|
|
||||||
if (out.grid && typeof out.grid.crossEnabled !== 'boolean') {
|
|
||||||
out.grid.crossEnabled = DEFAULT_POSTFX.grid.crossEnabled
|
|
||||||
}
|
|
||||||
if (out.grid && typeof out.grid.crossColor !== 'string') {
|
|
||||||
out.grid.crossColor = DEFAULT_POSTFX.grid.crossColor
|
|
||||||
}
|
|
||||||
if (out.grid && typeof out.grid.crossAlpha !== 'number') {
|
|
||||||
out.grid.crossAlpha = DEFAULT_POSTFX.grid.crossAlpha
|
|
||||||
}
|
|
||||||
if (out.grid && typeof out.grid.crossLength !== 'number') {
|
|
||||||
out.grid.crossLength = DEFAULT_POSTFX.grid.crossLength
|
|
||||||
}
|
|
||||||
if (out.grid && typeof out.grid.crossWidth !== 'number') {
|
|
||||||
out.grid.crossWidth = DEFAULT_POSTFX.grid.crossWidth
|
|
||||||
}
|
|
||||||
return out as PostFxSettings
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {useEffect,type RefObject} from 'react';
|
||||||
|
import * as pc from 'playcanvas';
|
||||||
|
export function useLutTexture(appRef:RefObject<pc.Application|null>,frameRef:RefObject<any>,textureRef:RefObject<pc.Texture|null>,url:string|null|undefined,ready:boolean,onError?:()=>void){
|
||||||
|
useEffect(()=>{
|
||||||
|
const app=appRef.current;let cancelled=false;
|
||||||
|
const clear=()=>{if(frameRef.current?.colorLUT){frameRef.current.colorLUT.texture=null;frameRef.current.update?.();}textureRef.current?.destroy();textureRef.current=null;};
|
||||||
|
clear();if(!app||!ready||!url)return;
|
||||||
|
const img=new Image();img.onload=()=>{
|
||||||
|
if(cancelled)return;
|
||||||
|
if(img.width!==256||img.height!==16){onError?.();return;}
|
||||||
|
const texture=new pc.Texture(app.graphicsDevice,{name:'Rover color LUT',format:pc.PIXELFORMAT_SRGBA8,mipmaps:false,minFilter:pc.FILTER_LINEAR,magFilter:pc.FILTER_LINEAR,addressU:pc.ADDRESS_CLAMP_TO_EDGE,addressV:pc.ADDRESS_CLAMP_TO_EDGE});
|
||||||
|
texture.setSource(img);textureRef.current=texture;
|
||||||
|
if(frameRef.current?.colorLUT){frameRef.current.colorLUT.texture=texture;frameRef.current.update?.();}
|
||||||
|
};img.onerror=()=>{if(!cancelled)onError?.();};img.src=url;
|
||||||
|
return()=>{cancelled=true;clear();};
|
||||||
|
},[appRef,frameRef,textureRef,url,ready,onError]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import {useEffect,useRef,type RefObject} from 'react';
|
||||||
|
import * as pc from 'playcanvas';
|
||||||
|
import type {MaterialSpec,TextureSlot} from '../../../core/roverScene/sceneDocument';
|
||||||
|
export type SceneMaterialsProps={materials?:Record<string,MaterialSpec>;assignments?:Record<string,string>;editing?:boolean;onMaterialPick?:(id:string,group:string)=>void;onTextureState?:(state:'loading'|'ready'|'error')=>void};
|
||||||
|
const maps:Record<TextureSlot,string>={diffuse:'diffuseMap',normal:'normalMap',metalness:'metalnessMap',roughness:'glossMap',opacity:'opacityMap',emissive:'emissiveMap',ao:'aoMap'};
|
||||||
|
export function useSceneMaterials(appRef:RefObject<pc.Application|null>,cameraRef:RefObject<pc.Entity|null>,canvasRef:RefObject<HTMLCanvasElement|null>,modelVersion:number,{materials,assignments,editing,onMaterialPick,onTextureState}:SceneMaterialsProps){
|
||||||
|
const latest=useRef({materials,onMaterialPick,onTextureState});latest.current={materials,onMaterialPick,onTextureState};
|
||||||
|
const textures=useRef(new Map<string,{texture?:pc.Texture;promise:Promise<pc.Texture>}>());
|
||||||
|
const generation=useRef(0);
|
||||||
|
const originalGroups=useRef(new WeakMap<pc.MeshInstance,{id:string;material:pc.StandardMaterial}>());
|
||||||
|
const customMaterials=useRef(new Map<string,pc.StandardMaterial>());
|
||||||
|
useEffect(()=>{
|
||||||
|
const app=appRef.current;if(!app||!modelVersion||!materials)return;
|
||||||
|
const current=++generation.current;let pending=0,failed=false;
|
||||||
|
const map=new Map<string,pc.StandardMaterial>();
|
||||||
|
for(const component of (app.root.findByName('ModelRoot') as pc.Entity|null)?.findComponents('render')??[])for(const mi of (component as pc.RenderComponent).meshInstances){
|
||||||
|
if(!(mi.material instanceof pc.StandardMaterial))continue;
|
||||||
|
if(!originalGroups.current.has(mi))originalGroups.current.set(mi,{id:mi.material.name,material:mi.material});
|
||||||
|
const source=originalGroups.current.get(mi)!;map.set(source.id,source.material);
|
||||||
|
}
|
||||||
|
for(const id of Object.keys(materials))if(id.startsWith('custom/')){
|
||||||
|
if(!customMaterials.current.has(id)){const m=new pc.StandardMaterial();m.name=id;customMaterials.current.set(id,m);}
|
||||||
|
map.set(id,customMaterials.current.get(id)!);
|
||||||
|
}
|
||||||
|
for(const component of (app.root.findByName('ModelRoot') as pc.Entity|null)?.findComponents('render')??[])for(const mi of (component as pc.RenderComponent).meshInstances){
|
||||||
|
const source=originalGroups.current.get(mi);if(source)mi.material=map.get(assignments?.[source.id]??source.id)??source.material;
|
||||||
|
}
|
||||||
|
for(const [id,m] of customMaterials.current)if(!Object.hasOwn(materials,id)){m.destroy();customMaterials.current.delete(id);}
|
||||||
|
const applyTexture=(m:pc.StandardMaterial,slot:TextureSlot,texture:pc.Texture|null,spec:MaterialSpec)=>{
|
||||||
|
(m as any)[maps[slot]]=texture;
|
||||||
|
const prefix=maps[slot];(m as any)[prefix+'Tiling']=new pc.Vec2(spec.tiling,spec.tiling);
|
||||||
|
if(['metalness','roughness','opacity','ao'].includes(slot))(m as any)[prefix+'Channel']='r';
|
||||||
|
m.update();
|
||||||
|
};
|
||||||
|
const used=new Set<string>();
|
||||||
|
for(const [id,spec] of Object.entries(materials)){
|
||||||
|
const m=map.get(id);if(!m)continue;
|
||||||
|
m.diffuse.fromString(spec.color);m.useMetalness=true;m.metalness=spec.metalness;
|
||||||
|
m.glossInvert=true;m.gloss=spec.roughness;m.opacity=spec.opacity;
|
||||||
|
// Specular factor is independent from the donor's global IBL multiplier.
|
||||||
|
m.specularityFactor=spec.reflectivity;m.clearCoat=spec.clearCoat;
|
||||||
|
m.clearCoatGlossInvert=true;m.clearCoatGloss=spec.clearCoatRoughness;
|
||||||
|
m.emissive.fromString(spec.emissive);m.emissiveIntensity=spec.emissiveIntensity;
|
||||||
|
m.bumpiness=spec.normalStrength;m.cull=spec.doubleSided?pc.CULLFACE_NONE:pc.CULLFACE_BACK;
|
||||||
|
m.blendType=spec.opacity<1||spec.textures.opacity?pc.BLEND_NORMAL:pc.BLEND_NONE;
|
||||||
|
m.depthWrite=m.blendType===pc.BLEND_NONE;
|
||||||
|
for(const slot of Object.keys(maps) as TextureSlot[]){
|
||||||
|
const data=spec.textures[slot]?.data;if(!data){applyTexture(m,slot,null,spec);continue;}
|
||||||
|
const srgb=slot==='diffuse'||slot==='emissive';const key=`${srgb}:${data}`;used.add(key);
|
||||||
|
let item=textures.current.get(key);
|
||||||
|
if(!item){
|
||||||
|
const promise=new Promise<pc.Texture>((resolve,reject)=>{const img=new Image();img.onload=()=>{if(generation.current<0){reject(new Error('Scene disposed'));return;}const tex=new pc.Texture(app.graphicsDevice,{name:spec.textures[slot]?.name,format:srgb?pc.PIXELFORMAT_SRGBA8:pc.PIXELFORMAT_RGBA8,mipmaps:true,addressU:pc.ADDRESS_REPEAT,addressV:pc.ADDRESS_REPEAT});tex.setSource(img);resolve(tex);};img.onerror=()=>reject(new Error('Texture decode failed'));img.src=data;});
|
||||||
|
item={promise};textures.current.set(key,item);const cached=item;
|
||||||
|
promise.then(tex=>{cached.texture=tex;if(textures.current.get(key)!==cached){tex.destroy();}}).catch(()=>{if(textures.current.get(key)===cached)textures.current.delete(key);});
|
||||||
|
}
|
||||||
|
if(item.texture)applyTexture(m,slot,item.texture,spec);
|
||||||
|
else{
|
||||||
|
pending++;item.promise.then(tex=>{if(generation.current===current)applyTexture(m,slot,tex,spec);}).catch(()=>{failed=true;}).finally(()=>{pending--;if(generation.current===current&&!pending)latest.current.onTextureState?.(failed?'error':'ready');});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.update();
|
||||||
|
}
|
||||||
|
for(const [key,item] of textures.current)if(!used.has(key)){item.texture?.destroy();textures.current.delete(key);}
|
||||||
|
onTextureState?.(pending?'loading':'ready');
|
||||||
|
return()=>{generation.current++;};
|
||||||
|
},[materials,assignments,modelVersion,appRef,onTextureState]);
|
||||||
|
useEffect(()=>()=>{generation.current=-1;for(const item of textures.current.values())item.texture?.destroy();textures.current.clear();for(const m of customMaterials.current.values())m.destroy();customMaterials.current.clear();},[]);
|
||||||
|
useEffect(()=>{
|
||||||
|
const app=appRef.current,canvas=canvasRef.current,camera=cameraRef.current;
|
||||||
|
if(!editing||!app||!canvas||!camera?.camera||!modelVersion)return;
|
||||||
|
const picker=new pc.Picker(app,1,1);let start:{x:number;y:number}|null=null;
|
||||||
|
const down=(e:PointerEvent)=>{start=e.button===0?{x:e.clientX,y:e.clientY}:null;};
|
||||||
|
const up=(e:PointerEvent)=>{
|
||||||
|
const p=start;start=null;if(!p||Math.hypot(p.x-e.clientX,p.y-e.clientY)>4)return;
|
||||||
|
const rect=canvas.getBoundingClientRect();picker.resize(Math.max(1,Math.floor(rect.width)),Math.max(1,Math.floor(rect.height)));
|
||||||
|
picker.prepare(camera.camera!,app.scene);
|
||||||
|
const hit=picker.getSelection(e.clientX-rect.left,e.clientY-rect.top).find(mi=>!!latest.current.materials?.[mi.material.name]);
|
||||||
|
if(hit instanceof pc.MeshInstance){const source=originalGroups.current.get(hit);if(source)latest.current.onMaterialPick?.(hit.material.name,source.id);}
|
||||||
|
};
|
||||||
|
canvas.addEventListener('pointerdown',down);canvas.addEventListener('pointerup',up);
|
||||||
|
return()=>{canvas.removeEventListener('pointerdown',down);canvas.removeEventListener('pointerup',up);picker.destroy();};
|
||||||
|
},[editing,modelVersion,appRef,cameraRef,canvasRef]);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
.rover-view:focus-visible { outline:2px solid var(--nodedc-text-secondary); outline-offset:-2px; }
|
.rover-view:focus-visible { outline:2px solid var(--nodedc-text-secondary); outline-offset:-2px; }
|
||||||
.rover-view .rover-view__scene { position:absolute; inset:0; }
|
.rover-view .rover-view__scene { position:absolute; inset:0; }
|
||||||
.rover-view__empty { display:grid; place-content:center; height:100%; text-align:center; font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-muted); }
|
.rover-view__empty { display:grid; place-content:center; height:100%; text-align:center; font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-muted); }
|
||||||
.rover-view__status { position:absolute; top:var(--nodedc-space-3); left:var(--nodedc-space-3); }
|
.rover-view__status { position:absolute; top:calc(var(--observation-overlay-header-space, 0px) + var(--nodedc-space-3)); left:var(--nodedc-space-3); }
|
||||||
.rover-view__controls { position:absolute; right:var(--nodedc-space-3); bottom:var(--nodedc-space-3); display:flex; flex-direction:column; gap:var(--nodedc-space-2); align-items:center; }
|
.rover-view__controls { position:absolute; right:var(--nodedc-space-3); bottom:var(--nodedc-space-3); display:flex; flex-direction:column; gap:var(--nodedc-space-2); align-items:center; }
|
||||||
.rover-view__controls > span { font-size:var(--nodedc-font-size-xs); color:var(--nodedc-text-secondary); }
|
.rover-view__controls > span { font-size:var(--nodedc-font-size-xs); color:var(--nodedc-text-secondary); }
|
||||||
.rover-keys { display:grid; gap:var(--nodedc-space-1); grid-template-columns:repeat(3,46px); }
|
.rover-keys { display:grid; gap:var(--nodedc-space-1); grid-template-columns:repeat(3,46px); }
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
.rover-key-KeyQ { grid-column:1; }.rover-key-KeyE { grid-column:3; }
|
.rover-key-KeyQ { grid-column:1; }.rover-key-KeyE { grid-column:3; }
|
||||||
.rover-view__authority { position:absolute; left:var(--nodedc-space-3); bottom:var(--nodedc-space-3); }
|
.rover-view__authority { position:absolute; left:var(--nodedc-space-3); bottom:var(--nodedc-space-3); }
|
||||||
@container (max-width: 380px) {
|
@container (max-width: 380px) {
|
||||||
.rover-view__authority { top:48px; bottom:auto; }
|
.rover-view:has(.rover-view__controls) .rover-view__authority { top:calc(var(--observation-overlay-header-space, 0px) + 48px); bottom:auto; }
|
||||||
}
|
}
|
||||||
.rover-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-3); }
|
.rover-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-3); }
|
||||||
.rover-settings p { font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-secondary); }
|
.rover-settings p { font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-secondary); }
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import { useComputeContours } from "../../core/system/ComputeContourContext";
|
|||||||
interface SystemNavigationPanelProps {
|
interface SystemNavigationPanelProps {
|
||||||
title: string;
|
title: string;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
|
onNavigateRoot: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SystemNavigationPanel({
|
export function SystemNavigationPanel({
|
||||||
title,
|
title,
|
||||||
onAdd,
|
onAdd,
|
||||||
|
onNavigateRoot,
|
||||||
onClose,
|
onClose,
|
||||||
}: SystemNavigationPanelProps) {
|
}: SystemNavigationPanelProps) {
|
||||||
const { contours, selectedContour, selectContour } = useComputeContours();
|
const { contours, selectedContour, selectContour } = useComputeContours();
|
||||||
@@ -40,7 +42,7 @@ export function SystemNavigationPanel({
|
|||||||
description: contour.expected_node_id,
|
description: contour.expected_node_id,
|
||||||
icon: <Icon name="apps" />,
|
icon: <Icon name="apps" />,
|
||||||
active: selectedContour?.contour_id === contour.contour_id,
|
active: selectedContour?.contour_id === contour.contour_id,
|
||||||
onSelect: () => selectContour(contour.contour_id),
|
onSelect: () => { selectContour(contour.contour_id); onNavigateRoot(); },
|
||||||
}))}
|
}))}
|
||||||
items={[]}
|
items={[]}
|
||||||
onItemChange={() => undefined}
|
onItemChange={() => undefined}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { WorkspaceDefinition } from "../productModel";
|
|||||||
|
|
||||||
interface ApplicationPanelActionsOptions {
|
interface ApplicationPanelActionsOptions {
|
||||||
definition: WorkspaceDefinition | null;
|
definition: WorkspaceDefinition | null;
|
||||||
onAddVehicle?: () => void;
|
|
||||||
refreshRuntime: () => void;
|
refreshRuntime: () => void;
|
||||||
resetConnectionScenario?: () => Promise<boolean>;
|
resetConnectionScenario?: () => Promise<boolean>;
|
||||||
connectionScenarioResetting: boolean;
|
connectionScenarioResetting: boolean;
|
||||||
@@ -45,7 +44,6 @@ export function deviceRuntimeUtilityAction({
|
|||||||
|
|
||||||
export function useApplicationPanelActions({
|
export function useApplicationPanelActions({
|
||||||
definition,
|
definition,
|
||||||
onAddVehicle,
|
|
||||||
refreshRuntime,
|
refreshRuntime,
|
||||||
resetConnectionScenario,
|
resetConnectionScenario,
|
||||||
connectionScenarioResetting,
|
connectionScenarioResetting,
|
||||||
@@ -56,7 +54,6 @@ export function useApplicationPanelActions({
|
|||||||
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
|
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const actions: ApplicationPanelUtilityAction[] = [];
|
const actions: ApplicationPanelUtilityAction[] = [];
|
||||||
if (definition?.kind === "vehicles" && onAddVehicle) actions.push({ label: "Добавить аппарат", icon: "plus", onClick: onAddVehicle });
|
|
||||||
if (definition?.kind === "device") {
|
if (definition?.kind === "device") {
|
||||||
actions.push(deviceRuntimeUtilityAction({
|
actions.push(deviceRuntimeUtilityAction({
|
||||||
refreshRuntime,
|
refreshRuntime,
|
||||||
@@ -81,7 +78,6 @@ export function useApplicationPanelActions({
|
|||||||
return actions;
|
return actions;
|
||||||
}, [
|
}, [
|
||||||
definition,
|
definition,
|
||||||
onAddVehicle,
|
|
||||||
refreshRuntime,
|
refreshRuntime,
|
||||||
resetConnectionScenario,
|
resetConnectionScenario,
|
||||||
connectionScenarioResetting,
|
connectionScenarioResetting,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
export interface ObservationLayout {
|
export interface ObservationLayout {
|
||||||
version:1; order:string[]; hidden:string[]; layers:Record<string,string>;
|
version:1; order:string[]; hidden:string[]; layers:Record<string,string>;
|
||||||
splits:Record<string,number>; arrangement:'auto'|'columns'|'rows';
|
catalogVisible?:string[]; splits:Record<string,number>; arrangement:'auto'|'columns'|'rows';
|
||||||
}
|
}
|
||||||
export const emptyObservationLayout=():ObservationLayout=>({version:1,order:[],hidden:[],layers:{},splits:{},arrangement:'auto'});
|
export const emptyObservationLayout=():ObservationLayout=>({version:1,order:[],hidden:[],layers:{},splits:{},arrangement:'auto'});
|
||||||
const record=(value:unknown):Record<string,unknown>=>value!==null&&typeof value==='object'&&!Array.isArray(value)?value as Record<string,unknown>:{};
|
const record=(value:unknown):Record<string,unknown>=>value!==null&&typeof value==='object'&&!Array.isArray(value)?value as Record<string,unknown>:{};
|
||||||
export function decodeObservationLayout(value:unknown):ObservationLayout {
|
export function decodeObservationLayout(value:unknown):ObservationLayout {
|
||||||
const source=record(value),result=emptyObservationLayout();if(source.version!==1)return result;
|
const source=record(value),result=emptyObservationLayout();if(source.version!==1)return result;
|
||||||
const ids=(value:unknown)=>Array.isArray(value)?[...new Set(value.filter((v):v is string=>typeof v==='string'&&v.length<512))].slice(0,128):[];
|
const ids=(value:unknown)=>Array.isArray(value)?[...new Set(value.filter((v):v is string=>typeof v==='string'&&v.length<512))].slice(0,128):[];
|
||||||
|
if(source.catalogVisible!==undefined)result.catalogVisible=ids(source.catalogVisible);
|
||||||
result.order=ids(source.order);result.hidden=ids(source.hidden);
|
result.order=ids(source.order);result.hidden=ids(source.hidden);
|
||||||
result.layers=Object.fromEntries(Object.entries(record(source.layers)).filter(([k,v])=>k.length<512&&typeof v==='string'&&v.length<128).slice(0,128)) as Record<string,string>;
|
result.layers=Object.fromEntries(Object.entries(record(source.layers)).filter(([k,v])=>k.length<512&&typeof v==='string'&&v.length<128).slice(0,128)) as Record<string,string>;
|
||||||
result.splits=Object.fromEntries(Object.entries(record(source.splits)).filter(([k,v])=>k.length<4096&&typeof v==='number'&&Number.isFinite(v)).slice(0,256).map(([k,v])=>[k,Math.min(80,Math.max(20,v as number))]));
|
result.splits=Object.fromEntries(Object.entries(record(source.splits)).filter(([k,v])=>k.length<4096&&typeof v==='number'&&Number.isFinite(v)).slice(0,256).map(([k,v])=>[k,Math.min(80,Math.max(20,v as number))]));
|
||||||
|
|||||||
@@ -57,5 +57,8 @@ export function useFleet() {
|
|||||||
events.onerror = unavailable;
|
events.onerror = unavailable;
|
||||||
return () => { active = false; events.close(); if (fallback) clearInterval(fallback); };
|
return () => { active = false; events.close(); if (fallback) clearInterval(fallback); };
|
||||||
}, []);
|
}, []);
|
||||||
return { items, error, refresh };
|
const acceptVehicle = useCallback((vehicle: Vehicle) => {
|
||||||
|
setItems(current => current?.map(item => item.id === vehicle.id ? vehicle : item) ?? null);
|
||||||
|
}, []);
|
||||||
|
return { items, error, refresh, acceptVehicle };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,5 +37,5 @@ export function useSessionOverviewMode(enabled: boolean) {
|
|||||||
window.addEventListener("keydown", close, true);
|
window.addEventListener("keydown", close, true);
|
||||||
return () => window.removeEventListener("keydown", close, true);
|
return () => window.removeEventListener("keydown", close, true);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
return { open: enabled && open, toggle: useCallback(() => setOpen(v => !v), []) };
|
return { open: enabled && open, close: useCallback(() => setOpen(false), []), toggle: useCallback(() => setOpen(v => !v), []) };
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
|||||||
|
|
||||||
|
export type PostFxSettings = {
|
||||||
|
lighting: {
|
||||||
|
ambientIntensity: number
|
||||||
|
exposure: number
|
||||||
|
skyBoxIntensity: number
|
||||||
|
}
|
||||||
|
envAtlas: {
|
||||||
|
enabled: boolean
|
||||||
|
background: boolean
|
||||||
|
reflection: boolean
|
||||||
|
intensity: number
|
||||||
|
reflectionIntensity: number
|
||||||
|
brightness: number
|
||||||
|
contrast: number
|
||||||
|
saturation: number
|
||||||
|
toneMapping: number
|
||||||
|
mip: number
|
||||||
|
rotation: number
|
||||||
|
}
|
||||||
|
skybox: {
|
||||||
|
enabled: boolean
|
||||||
|
background: boolean
|
||||||
|
reflection: boolean
|
||||||
|
intensity: number
|
||||||
|
reflectionIntensity: number
|
||||||
|
mip: number
|
||||||
|
rotation: number
|
||||||
|
colorA: string
|
||||||
|
colorB: string
|
||||||
|
}
|
||||||
|
rendering: {
|
||||||
|
backgroundColor: string
|
||||||
|
wireframe: boolean
|
||||||
|
renderFormat: number
|
||||||
|
renderFormatFallback0: number
|
||||||
|
renderFormatFallback1: number
|
||||||
|
stencil: boolean
|
||||||
|
renderTargetScale: number
|
||||||
|
samples: number
|
||||||
|
sharpness: number
|
||||||
|
toneMapping: number
|
||||||
|
sceneColorMap: boolean
|
||||||
|
sceneDepthMap: boolean
|
||||||
|
fog: 'none' | 'linear' | 'exp' | 'exp2'
|
||||||
|
fogColor: string
|
||||||
|
fogRange: [number, number]
|
||||||
|
fogDensity: number
|
||||||
|
fogStart: number
|
||||||
|
fogEnd: number
|
||||||
|
}
|
||||||
|
grid: {
|
||||||
|
step: number
|
||||||
|
diameter: number
|
||||||
|
enabled: boolean
|
||||||
|
colorX: string
|
||||||
|
colorZ: string
|
||||||
|
colorMain: string
|
||||||
|
alphaX: number
|
||||||
|
alphaZ: number
|
||||||
|
alphaMain: number
|
||||||
|
dotsEnabled: boolean
|
||||||
|
dotsColor: string
|
||||||
|
dotsAlpha: number
|
||||||
|
dotsDiameter: number
|
||||||
|
crossEnabled: boolean
|
||||||
|
crossColor: string
|
||||||
|
crossAlpha: number
|
||||||
|
crossLength: number
|
||||||
|
crossWidth: number
|
||||||
|
fadeStart: number
|
||||||
|
fadeEnd: number
|
||||||
|
}
|
||||||
|
ssao: {
|
||||||
|
type: 'none' | 'lighting' | 'combine'
|
||||||
|
blurEnabled: boolean
|
||||||
|
intensity: number
|
||||||
|
radius: number
|
||||||
|
samples: number
|
||||||
|
power: number
|
||||||
|
minAngle: number
|
||||||
|
scale: number
|
||||||
|
}
|
||||||
|
bloom: {
|
||||||
|
enabled: boolean
|
||||||
|
intensity: number
|
||||||
|
lastMipLevel: number
|
||||||
|
}
|
||||||
|
chromaticAberration: {
|
||||||
|
enabled: boolean
|
||||||
|
intensity: number
|
||||||
|
}
|
||||||
|
taa: {
|
||||||
|
enabled: boolean
|
||||||
|
jitter: number
|
||||||
|
}
|
||||||
|
grading: {
|
||||||
|
enabled: boolean
|
||||||
|
brightness: number
|
||||||
|
contrast: number
|
||||||
|
saturation: number
|
||||||
|
tint: string
|
||||||
|
}
|
||||||
|
lut: {
|
||||||
|
intensity: number
|
||||||
|
textureUrl?: string | null
|
||||||
|
}
|
||||||
|
vignette: {
|
||||||
|
enabled: boolean
|
||||||
|
intensity: number
|
||||||
|
inner: number
|
||||||
|
outer: number
|
||||||
|
curvature: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
directionalLight: {
|
||||||
|
enabled: boolean
|
||||||
|
color: string
|
||||||
|
intensity: number
|
||||||
|
azimuth: number
|
||||||
|
elevation: number
|
||||||
|
castShadows: boolean
|
||||||
|
shadowIntensity: number
|
||||||
|
shadowDistance: number
|
||||||
|
shadowResolution: number
|
||||||
|
shadowBias: number
|
||||||
|
normalOffsetBias: number
|
||||||
|
shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3'
|
||||||
|
vsmBlurSize: number
|
||||||
|
}
|
||||||
|
shadowCatcher: {
|
||||||
|
enabled: boolean
|
||||||
|
size: number
|
||||||
|
yOffset: number
|
||||||
|
lightIntensity: number
|
||||||
|
lightColor: string
|
||||||
|
lightAzimuth: number
|
||||||
|
lightElevation: number
|
||||||
|
shadowIntensity: number
|
||||||
|
shadowDistance: number
|
||||||
|
shadowResolution: number
|
||||||
|
shadowBias: number
|
||||||
|
normalOffsetBias: number
|
||||||
|
shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3'
|
||||||
|
vsmBlurSize: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_POSTFX: PostFxSettings = {
|
||||||
|
lighting: {
|
||||||
|
ambientIntensity: 0,
|
||||||
|
exposure: 1.21,
|
||||||
|
skyBoxIntensity: 0.86,
|
||||||
|
},
|
||||||
|
envAtlas: {
|
||||||
|
enabled: true,
|
||||||
|
background: false,
|
||||||
|
reflection: true,
|
||||||
|
intensity: 0.76,
|
||||||
|
reflectionIntensity: 0.6,
|
||||||
|
brightness: 1.65,
|
||||||
|
contrast: 1,
|
||||||
|
saturation: 1,
|
||||||
|
toneMapping: 0,
|
||||||
|
mip: 1,
|
||||||
|
rotation: 247,
|
||||||
|
},
|
||||||
|
skybox: {
|
||||||
|
enabled: false,
|
||||||
|
background: false,
|
||||||
|
reflection: true,
|
||||||
|
intensity: 0.62,
|
||||||
|
reflectionIntensity: 1,
|
||||||
|
mip: 0,
|
||||||
|
rotation: 0,
|
||||||
|
colorA: '#c5bfbf',
|
||||||
|
colorB: '#c7bcbc',
|
||||||
|
},
|
||||||
|
rendering: {
|
||||||
|
backgroundColor: '#111113',
|
||||||
|
wireframe: false,
|
||||||
|
renderFormat: 18,
|
||||||
|
renderFormatFallback0: 12,
|
||||||
|
renderFormatFallback1: 14,
|
||||||
|
stencil: false,
|
||||||
|
renderTargetScale: 1,
|
||||||
|
samples: 4,
|
||||||
|
sharpness: 0,
|
||||||
|
toneMapping: 4,
|
||||||
|
sceneColorMap: false,
|
||||||
|
sceneDepthMap: false,
|
||||||
|
fog: 'exp',
|
||||||
|
fogColor: '#dcc2ff',
|
||||||
|
fogRange: [0, 100],
|
||||||
|
fogDensity: 0.008,
|
||||||
|
fogStart: 0,
|
||||||
|
fogEnd: 100,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
step: 1,
|
||||||
|
diameter: 1000,
|
||||||
|
enabled: true,
|
||||||
|
colorX: '#ffffff',
|
||||||
|
colorZ: '#ffffff',
|
||||||
|
colorMain: '#7a3cff',
|
||||||
|
alphaX: 0.18,
|
||||||
|
alphaZ: 0.18,
|
||||||
|
alphaMain: 0.6,
|
||||||
|
dotsEnabled: false,
|
||||||
|
dotsColor: '#ffffff',
|
||||||
|
dotsAlpha: 0.5,
|
||||||
|
dotsDiameter: 0.06,
|
||||||
|
crossEnabled: false,
|
||||||
|
crossColor: '#ffffff',
|
||||||
|
crossAlpha: 0.5,
|
||||||
|
crossLength: 0.5,
|
||||||
|
crossWidth: 0.06,
|
||||||
|
fadeStart: 0,
|
||||||
|
fadeEnd: 0,
|
||||||
|
},
|
||||||
|
ssao: {
|
||||||
|
type: 'none',
|
||||||
|
blurEnabled: true,
|
||||||
|
intensity: 0.5,
|
||||||
|
radius: 30,
|
||||||
|
samples: 12,
|
||||||
|
power: 6,
|
||||||
|
minAngle: 10,
|
||||||
|
scale: 1,
|
||||||
|
},
|
||||||
|
bloom: {
|
||||||
|
enabled: true,
|
||||||
|
intensity: 0.03,
|
||||||
|
lastMipLevel: 4,
|
||||||
|
},
|
||||||
|
chromaticAberration: {
|
||||||
|
enabled: true,
|
||||||
|
intensity: 30,
|
||||||
|
},
|
||||||
|
taa: {
|
||||||
|
enabled: false,
|
||||||
|
jitter: 1,
|
||||||
|
},
|
||||||
|
grading: {
|
||||||
|
enabled: true,
|
||||||
|
brightness: 0.837,
|
||||||
|
contrast: 1.1,
|
||||||
|
saturation: 1.126,
|
||||||
|
tint: '#ffffff',
|
||||||
|
},
|
||||||
|
lut: {
|
||||||
|
intensity: 1,
|
||||||
|
textureUrl: null,
|
||||||
|
},
|
||||||
|
vignette: {
|
||||||
|
enabled: true,
|
||||||
|
intensity: 1,
|
||||||
|
inner: 0.25,
|
||||||
|
outer: 1.52,
|
||||||
|
curvature: 0.78,
|
||||||
|
color: '#000000',
|
||||||
|
},
|
||||||
|
directionalLight: {
|
||||||
|
enabled: true,
|
||||||
|
color: '#ffffff',
|
||||||
|
intensity: 0.4,
|
||||||
|
azimuth: 0,
|
||||||
|
elevation: 0,
|
||||||
|
castShadows: false,
|
||||||
|
shadowIntensity: 0.5,
|
||||||
|
shadowDistance: 16,
|
||||||
|
shadowResolution: 1024,
|
||||||
|
shadowBias: 0,
|
||||||
|
normalOffsetBias: 0,
|
||||||
|
shadowType: 'vsm16',
|
||||||
|
vsmBlurSize: 8,
|
||||||
|
},
|
||||||
|
shadowCatcher: {
|
||||||
|
enabled: true,
|
||||||
|
size: 9,
|
||||||
|
yOffset: 0.001,
|
||||||
|
lightIntensity: 0.3,
|
||||||
|
lightColor: '#ffffff',
|
||||||
|
lightAzimuth: 20,
|
||||||
|
lightElevation: 60,
|
||||||
|
shadowIntensity: 0.29,
|
||||||
|
shadowDistance: 16,
|
||||||
|
shadowResolution: 2048,
|
||||||
|
shadowBias: 0,
|
||||||
|
normalOffsetBias: 0,
|
||||||
|
shadowType: 'vsm16',
|
||||||
|
vsmBlurSize: 8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: any): value is Record<string, any> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergePostFxDefaults(raw?: Partial<PostFxSettings> | null): PostFxSettings {
|
||||||
|
if (!raw) return JSON.parse(JSON.stringify(DEFAULT_POSTFX)) as PostFxSettings
|
||||||
|
|
||||||
|
const out: any = JSON.parse(JSON.stringify(DEFAULT_POSTFX))
|
||||||
|
const merge = (target: any, source: any) => {
|
||||||
|
if (!isObject(source)) return
|
||||||
|
for (const [key, value] of Object.entries(source)) {
|
||||||
|
if (isObject(value)) {
|
||||||
|
if (!isObject(target[key])) target[key] = {}
|
||||||
|
merge(target[key], value)
|
||||||
|
} else {
|
||||||
|
target[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merge(out, raw)
|
||||||
|
|
||||||
|
const legacy: any = raw as any
|
||||||
|
if (legacy?.hdrLight) {
|
||||||
|
const src = legacy.hdrLight
|
||||||
|
const env = legacy?.envAtlas ?? {}
|
||||||
|
if (env?.enabled === undefined && typeof src.enabled === 'boolean') out.envAtlas.enabled = src.enabled
|
||||||
|
if (env?.background === undefined && typeof src.showSkybox === 'boolean') out.envAtlas.background = src.showSkybox
|
||||||
|
if (env?.intensity === undefined && typeof src.intensity === 'number') out.envAtlas.intensity = src.intensity
|
||||||
|
if (env?.mip === undefined && typeof src.mip === 'number') out.envAtlas.mip = src.mip
|
||||||
|
if (env?.rotation === undefined && typeof src.rotation === 'number') out.envAtlas.rotation = src.rotation
|
||||||
|
}
|
||||||
|
if (legacy?.hdrReflection && (legacy?.envAtlas?.reflection === undefined)) {
|
||||||
|
if (typeof legacy.hdrReflection.enabled === 'boolean') {
|
||||||
|
out.envAtlas.reflection = legacy.hdrReflection.enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (out.envAtlas && typeof out.envAtlas.reflectionIntensity !== 'number') {
|
||||||
|
out.envAtlas.reflectionIntensity = out.envAtlas.intensity
|
||||||
|
}
|
||||||
|
if (out.envAtlas && typeof out.envAtlas.brightness !== 'number') {
|
||||||
|
out.envAtlas.brightness = 1
|
||||||
|
}
|
||||||
|
if (out.envAtlas && typeof out.envAtlas.contrast !== 'number') {
|
||||||
|
out.envAtlas.contrast = 1
|
||||||
|
}
|
||||||
|
if (out.envAtlas && typeof out.envAtlas.saturation !== 'number') {
|
||||||
|
out.envAtlas.saturation = 1
|
||||||
|
}
|
||||||
|
if (out.envAtlas && typeof out.envAtlas.toneMapping !== 'number') {
|
||||||
|
out.envAtlas.toneMapping = 0
|
||||||
|
}
|
||||||
|
if (out.skybox && typeof out.skybox.reflectionIntensity !== 'number') {
|
||||||
|
out.skybox.reflectionIntensity = out.skybox.intensity
|
||||||
|
}
|
||||||
|
if (out.grid && typeof out.grid.enabled !== 'boolean') {
|
||||||
|
out.grid.enabled = DEFAULT_POSTFX.grid.enabled
|
||||||
|
}
|
||||||
|
if (out.grid && typeof out.grid.colorX !== 'string') {
|
||||||
|
out.grid.colorX = DEFAULT_POSTFX.grid.colorX
|
||||||
|
}
|
||||||
|
if (out.grid && typeof out.grid.colorZ !== 'string') {
|
||||||
|
out.grid.colorZ = DEFAULT_POSTFX.grid.colorZ
|
||||||
|
}
|
||||||
|
if (out.grid && typeof out.grid.colorMain !== 'string') {
|
||||||
|
out.grid.colorMain = DEFAULT_POSTFX.grid.colorMain
|
||||||
|
}
|
||||||
|
if (out.grid && typeof out.grid.alphaX !== 'number') {
|
||||||
|
out.grid.alphaX = DEFAULT_POSTFX.grid.alphaX
|
||||||
|
}
|
||||||
|
if (out.grid && typeof out.grid.alphaZ !== 'number') {
|
||||||
|
out.grid.alphaZ = DEFAULT_POSTFX.grid.alphaZ
|
||||||
|
}
|
||||||
|
if (out.grid && typeof out.grid.alphaMain !== 'number') {
|
||||||
|
out.grid.alphaMain = DEFAULT_POSTFX.grid.alphaMain
|
||||||
|
}
|
||||||
|
if (out.grid && typeof out.grid.dotsEnabled !== 'boolean') {
|
||||||
|
out.grid.dotsEnabled = DEFAULT_POSTFX.grid.dotsEnabled
|
||||||
|
}
|
||||||
|
if (out.grid && typeof out.grid.dotsColor !== 'string') {
|
||||||
|
out.grid.dotsColor = DEFAULT_POSTFX.grid.dotsColor
|
||||||
|
}
|
||||||
|
if (out.grid && typeof out.grid.dotsAlpha !== 'number') {
|
||||||
|
out.grid.dotsAlpha = DEFAULT_POSTFX.grid.dotsAlpha
|
||||||
|
}
|
||||||
|
if (out.grid && typeof out.grid.dotsDiameter !== 'number') {
|
||||||
|
out.grid.dotsDiameter = DEFAULT_POSTFX.grid.dotsDiameter
|
||||||
|
}
|
||||||
|
if (out.grid && typeof out.grid.crossEnabled !== 'boolean') {
|
||||||
|
out.grid.crossEnabled = DEFAULT_POSTFX.grid.crossEnabled
|
||||||
|
}
|
||||||
|
if (out.grid && typeof out.grid.crossColor !== 'string') {
|
||||||
|
out.grid.crossColor = DEFAULT_POSTFX.grid.crossColor
|
||||||
|
}
|
||||||
|
if (out.grid && typeof out.grid.crossAlpha !== 'number') {
|
||||||
|
out.grid.crossAlpha = DEFAULT_POSTFX.grid.crossAlpha
|
||||||
|
}
|
||||||
|
if (out.grid && typeof out.grid.crossLength !== 'number') {
|
||||||
|
out.grid.crossLength = DEFAULT_POSTFX.grid.crossLength
|
||||||
|
}
|
||||||
|
if (out.grid && typeof out.grid.crossWidth !== 'number') {
|
||||||
|
out.grid.crossWidth = DEFAULT_POSTFX.grid.crossWidth
|
||||||
|
}
|
||||||
|
return out as PostFxSettings
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import {mergePostFxDefaults,type PostFxSettings} from './postFx';
|
||||||
|
import donorFields from './environmentFields.json';
|
||||||
|
export type SceneField={key:string;type:string;label:string;group:string;default?:unknown;min?:number;max?:number;step?:number;options?:{label:string;value:string}[];disabledIf?:string};
|
||||||
|
export const environmentFields:SceneField[]=[...donorFields,
|
||||||
|
{key:'postFx.lut.textureUrl',type:'texture',label:'Таблица LUT',group:'PostProcess/LUT'},
|
||||||
|
{key:'postFx.lighting.ambientIntensity',type:'sliderNumber',label:'Заполняющий свет',group:'PostProcess/Lighting',min:0,max:1,step:.01},
|
||||||
|
{key:'postFx.grid.step',type:'sliderNumber',label:'Шаг основной сетки, м',group:'PostProcess/Grid',min:.01,max:10,step:.01},
|
||||||
|
{key:'postFx.grid.diameter',type:'sliderNumber',label:'Диаметр сетки, м',group:'PostProcess/Grid',min:1,max:1000,step:1},
|
||||||
|
];
|
||||||
|
export const textureSlots=['diffuse','normal','metalness','roughness','opacity','emissive','ao'] as const;
|
||||||
|
export type TextureSlot=typeof textureSlots[number];
|
||||||
|
export type MaterialSpec={name:string;color:string;metalness:number;roughness:number;opacity:number;reflectivity:number;clearCoat:number;clearCoatRoughness:number;emissive:string;emissiveIntensity:number;normalStrength:number;doubleSided:boolean;tiling:number;textures:Partial<Record<TextureSlot,{name:string;data:string}>>};
|
||||||
|
export type SceneDocument={schema:'missioncore.rover-scene/v1';model:'dcd006-v020-materials-v2';environmentVersion:2;postFx:PostFxSettings;cameraZoomStrength:number;cameraAutoRotate:boolean;materials:Record<string,MaterialSpec>;assignments:Record<string,string>};
|
||||||
|
const material=(name:string,color:string,metalness:number,roughness:number):MaterialSpec=>({name,color,metalness,roughness,opacity:1,reflectivity:1,clearCoat:0,clearCoatRoughness:.3,emissive:'#000000',emissiveIntensity:0,normalStrength:1,doubleSided:false,tiling:1,textures:{}});
|
||||||
|
export function defaultScene():SceneDocument{
|
||||||
|
const postFx=mergePostFxDefaults();
|
||||||
|
return {schema:'missioncore.rover-scene/v1',model:'dcd006-v020-materials-v2',environmentVersion:2,postFx,cameraZoomStrength:.04,cameraAutoRotate:false,assignments:{},materials:{
|
||||||
|
'rover/body':material('Корпус и подвеска','#2c2f31',.12,.53),
|
||||||
|
'rover/rubber':material('Резина гусениц и катков','#202328',0,.72),
|
||||||
|
'rover/track-steel':material('Нержавеющие проставки','#c4c8cd',1,.17),
|
||||||
|
'rover/box':material('Бортовой ящик','#34373a',.08,.4),
|
||||||
|
'rover/molle':material('MOLLE панель','#52575b',.3,.44),
|
||||||
|
'rover/motor':material('Алюминий моторов','#95999c',.68,.38),
|
||||||
|
'rover/hardware':material('Чёрный крепёж','#24272b',.45,.38),
|
||||||
|
'rover/polymer':material('Пластик и кожухи','#24292d',0,.44),
|
||||||
|
'rover/drive':material('Ведущие звёзды','#81652f',0,.46),
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
export function createMaterial(doc:SceneDocument,id:string):SceneDocument{
|
||||||
|
if(!/^custom\/[a-zA-Z0-9-]{1,80}$/.test(id)||Object.hasOwn(doc.materials,id)||Object.keys(doc.materials).length>=128)return doc;
|
||||||
|
const used=new Set(Object.values(doc.materials).map(m=>m.name));let n=1;while(used.has(`Материал ${n}`))n++;
|
||||||
|
return {...doc,materials:{...doc.materials,[id]:material(`Материал ${n}`,'#808080',0,.5)}};
|
||||||
|
}
|
||||||
|
export function assignMaterial(doc:SceneDocument,group:string,id:string):SceneDocument{
|
||||||
|
if(!Object.hasOwn(defaultScene().materials,group)||!Object.hasOwn(doc.materials,id))return doc;
|
||||||
|
return {...doc,assignments:{...doc.assignments,[group]:id}};
|
||||||
|
}
|
||||||
|
export function fieldValue(doc:SceneDocument,key:string):unknown{return key.split('.').reduce<unknown>((v,k)=>v&&typeof v==='object'?(v as Record<string,unknown>)[k]:undefined,doc);}
|
||||||
|
export function fieldDisabled(doc:SceneDocument,f:SceneField):boolean{
|
||||||
|
const rule=f.disabledIf;if(!rule)return false;
|
||||||
|
if(rule.startsWith('!'))return !fieldValue(doc,rule.slice(1));
|
||||||
|
const atlas=doc.postFx.envAtlas.enabled,sky=doc.postFx.skybox.enabled;
|
||||||
|
return ({ssaoDisabled:doc.postFx.ssao.type==='none',fogDisabled:doc.postFx.rendering.fog==='none',envAtlasBlocked:sky,skyboxBlocked:atlas,envAtlasFieldsDisabled:sky||!atlas,skyboxFieldsDisabled:atlas||!sky,gridDotsDisabled:!doc.postFx.grid.dotsEnabled,gridCrossDisabled:!doc.postFx.grid.crossEnabled} as Record<string,boolean>)[rule]??false;
|
||||||
|
}
|
||||||
|
export function changeField(doc:SceneDocument,key:string,value:unknown):SceneDocument{
|
||||||
|
const f=environmentFields.find(x=>x.key===key);if(!f||f.type==='action')return doc;
|
||||||
|
if(f.type==='texture'&&value!==null&&(typeof value!=='string'||value.length>1_000_000||!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(value)))return doc;
|
||||||
|
const old=fieldValue(doc,key);
|
||||||
|
if(typeof old==='number'){
|
||||||
|
value=Number(value);if(!Number.isFinite(value))return doc;
|
||||||
|
if(f.min!==undefined)value=Math.max(f.min,value as number);if(f.max!==undefined)value=Math.min(f.max,value as number);
|
||||||
|
}else if(typeof old==='boolean'){if(typeof value!=='boolean')return doc;}
|
||||||
|
else if(f.type==='color'){if(typeof value!=='string'||!/^#[0-9a-f]{6}$/i.test(value))return doc;}
|
||||||
|
if(f.options&&!f.options.some(o=>o.value===String(value)))return doc;
|
||||||
|
if(f.type==='doubleSlider'){
|
||||||
|
if(!Array.isArray(value)||value.length!==2||!value.every(Number.isFinite))return doc;
|
||||||
|
value=[Math.max(0,Math.min(100,value[0])),Math.max(0,Math.min(100,value[1]))].sort((a,b)=>a-b);
|
||||||
|
}
|
||||||
|
const result={...doc,postFx:structuredClone(doc.postFx)};const parts=key.split('.');const tail=parts.pop()!;
|
||||||
|
const target=parts.reduce((v,k)=>v[k],result as any);target[tail]=value;
|
||||||
|
if(key==='postFx.rendering.fogRange'){[result.postFx.rendering.fogStart,result.postFx.rendering.fogEnd]=value as [number,number];}
|
||||||
|
if(key==='postFx.envAtlas.enabled'&&value)result.postFx.skybox.enabled=false;
|
||||||
|
if(key==='postFx.skybox.enabled'&&value)result.postFx.envAtlas.enabled=false;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
export const materialRanges:Record<string,[number,number]>={metalness:[0,1],roughness:[0,1],opacity:[0,1],reflectivity:[0,1],clearCoat:[0,1],clearCoatRoughness:[0,1],emissiveIntensity:[0,10],normalStrength:[0,2],tiling:[.01,20]};
|
||||||
|
export function textureBytes(doc:SceneDocument):number{return Object.values(doc.materials).reduce((sum,m)=>sum+Object.values(m.textures).reduce((n,t)=>n+(t?.data.length??0),0),0);}
|
||||||
|
export function parseScene(raw:unknown):SceneDocument{
|
||||||
|
const r=raw as SceneDocument;if(!r||r.schema!=='missioncore.rover-scene/v1'||r.model!=='dcd006-v020-materials-v2')throw new Error('Файл сцены не подходит к этой модели.');
|
||||||
|
let doc=defaultScene();
|
||||||
|
for(const f of environmentFields){const v=fieldValue(r,f.key);if(v!==undefined)doc=changeField(doc,f.key,v);}
|
||||||
|
if(Object.keys(r.materials??{}).length>128)throw new Error('В сцене может быть не больше 128 материалов.');
|
||||||
|
for(const id of Object.keys(r.materials??{}))if(id.startsWith('custom/'))doc=createMaterial(doc,id);
|
||||||
|
for(const [id,base] of Object.entries(doc.materials)){
|
||||||
|
const m=r.materials?.[id];if(!m)continue;
|
||||||
|
if(typeof m.name==='string')base.name=m.name.trim().slice(0,120)||base.name;
|
||||||
|
for(const key of ['color','emissive'] as const)if(/^#[0-9a-f]{6}$/i.test(m[key]))base[key]=m[key];
|
||||||
|
for(const [key,[lo,hi]] of Object.entries(materialRanges)){const v=m[key as keyof MaterialSpec];if(typeof v==='number'&&Number.isFinite(v))(base as any)[key]=Math.max(lo,Math.min(hi,v));}
|
||||||
|
if(typeof m.doubleSided==='boolean')base.doubleSided=m.doubleSided;
|
||||||
|
for(const slot of textureSlots){const t=m.textures?.[slot];if(!t)continue;if(typeof t.name!=='string'||typeof t.data!=='string'||t.data.length>12_000_000||!/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(t.data))throw new Error('Некорректная текстура в файле сцены.');base.textures[slot]={name:t.name.slice(0,200),data:t.data};}
|
||||||
|
}
|
||||||
|
for(const [group,id] of Object.entries(r.assignments??{}))if(typeof id==='string')doc=assignMaterial(doc,group,id);
|
||||||
|
if(textureBytes(doc)>28_000_000)throw new Error('Суммарный объём текстур превышает 20 МБ.');
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {defaultScene,parseScene,type SceneDocument} from './sceneDocument';
|
||||||
|
|
||||||
|
// Fingerprint of the first editor's withdrawn neutral preset. Match the entire
|
||||||
|
// environment: independently edited environments must never be reset on load.
|
||||||
|
function withdrawnEnvironment(){
|
||||||
|
const fx=defaultScene().postFx;
|
||||||
|
Object.assign(fx.lighting,{exposure:1.1,skyBoxIntensity:1,ambientIntensity:.22});
|
||||||
|
Object.assign(fx.envAtlas,{reflectionIntensity:1,brightness:1.2});
|
||||||
|
Object.assign(fx.rendering,{backgroundColor:'#202227',fog:'none',toneMapping:4});
|
||||||
|
Object.assign(fx.grid,{colorMain:'#747982',colorX:'#93969c',colorZ:'#93969c',alphaMain:.25,alphaX:.35,alphaZ:.35,fadeStart:2,fadeEnd:5,step:.5,diameter:12});
|
||||||
|
fx.chromaticAberration.enabled=false;fx.vignette.enabled=false;fx.grading.enabled=false;fx.bloom.enabled=false;
|
||||||
|
Object.assign(fx.directionalLight,{intensity:1.8,azimuth:135,elevation:45});
|
||||||
|
Object.assign(fx.shadowCatcher,{size:4,lightIntensity:.5,shadowIntensity:.25});
|
||||||
|
return fx;
|
||||||
|
}
|
||||||
|
export function loadStoredScene(raw:unknown):{document:SceneDocument;updated:boolean;restoredEnvironment:boolean}{
|
||||||
|
const doc=parseScene(raw);
|
||||||
|
const updated=(raw as Partial<SceneDocument>).environmentVersion!==2;
|
||||||
|
const restoredEnvironment=updated&&JSON.stringify(doc.postFx)===JSON.stringify(withdrawnEnvironment());
|
||||||
|
return {document:restoredEnvironment?{...doc,postFx:defaultScene().postFx}:doc,updated,restoredEnvironment};
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type {SceneDocument} from './sceneDocument';
|
||||||
|
import {loadStoredScene} from './sceneMigration';
|
||||||
|
const key=(vehicle:string)=>`${vehicle}:dcd006-v020-materials-v2`;
|
||||||
|
function database():Promise<IDBDatabase>{return new Promise((resolve,reject)=>{const r=indexedDB.open('missioncore-rover-scenes',1);r.onupgradeneeded=()=>r.result.createObjectStore('scenes');r.onerror=()=>reject(r.error);r.onblocked=()=>reject(new Error('Хранилище сцены занято другим окном.'));r.onsuccess=()=>resolve(r.result);});}
|
||||||
|
export async function readScene(vehicle:string):Promise<SceneDocument|null>{
|
||||||
|
const db=await database();try{
|
||||||
|
const raw=await new Promise<unknown>((resolve,reject)=>{const r=db.transaction('scenes').objectStore('scenes').get(key(vehicle));r.onerror=()=>reject(r.error);r.onsuccess=()=>resolve(r.result);});
|
||||||
|
if(!raw)return null;
|
||||||
|
const result=loadStoredScene(raw);
|
||||||
|
if(result.updated)await new Promise<void>((resolve,reject)=>{
|
||||||
|
const tx=db.transaction('scenes','readwrite'),store=tx.objectStore('scenes');
|
||||||
|
if(result.restoredEnvironment)store.put(raw,`${key(vehicle)}:before-environment-v2`);
|
||||||
|
store.put(result.document,key(vehicle));tx.oncomplete=()=>resolve();tx.onabort=()=>reject(tx.error);tx.onerror=()=>reject(tx.error);
|
||||||
|
});
|
||||||
|
return result.document;
|
||||||
|
}finally{db.close();}
|
||||||
|
}
|
||||||
|
export async function saveScene(vehicle:string,document:SceneDocument):Promise<void>{const db=await database();try{await new Promise<void>((resolve,reject)=>{const tx=db.transaction('scenes','readwrite');tx.objectStore('scenes').put(document,key(vehicle));tx.oncomplete=()=>resolve();tx.onabort=()=>reject(tx.error);tx.onerror=()=>reject(tx.error);});}finally{db.close();}}
|
||||||
|
export async function textureFromFile(file:File):Promise<{name:string;data:string}>{
|
||||||
|
if(!['image/png','image/jpeg','image/webp'].includes(file.type)||file.size>8_000_000)throw new Error('Выберите PNG, JPEG или WebP до 8 МБ.');
|
||||||
|
const bitmap=await createImageBitmap(file);try{if(bitmap.width>4096||bitmap.height>4096)throw new Error('Максимальный размер текстуры — 4096 × 4096.');}finally{bitmap.close();}
|
||||||
|
return new Promise((resolve,reject)=>{const r=new FileReader();r.onerror=()=>reject(r.error);r.onload=()=>resolve({name:file.name,data:String(r.result)});r.readAsDataURL(file);});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {useCallback,useEffect,useRef,useState} from 'react';
|
||||||
|
import {defaultScene,parseScene,type SceneDocument} from './sceneDocument';
|
||||||
|
import {readScene,saveScene} from './sceneStorage';
|
||||||
|
let writes=Promise.resolve();
|
||||||
|
const enqueue=(vehicle:string,doc:SceneDocument)=>{const next=writes.catch(()=>{}).then(()=>saveScene(vehicle,doc));writes=next;return next;};
|
||||||
|
export function useSceneDocument(vehicle:string){
|
||||||
|
const [document,setDocument]=useState(defaultScene),[ready,setReady]=useState(false),[revision,setRevision]=useState(0);
|
||||||
|
const [saveState,setSaveState]=useState<'loading'|'saved'|'saving'|'error'>('loading'),[error,setError]=useState('');
|
||||||
|
const latest=useRef({document,revision,vehicle});latest.current={document,revision,vehicle};
|
||||||
|
const savedRevision=useRef(0);
|
||||||
|
useEffect(()=>{let cancelled=false;readScene(vehicle).then(value=>{if(!cancelled){setDocument(value??defaultScene());setSaveState('saved');}}).catch(()=>{if(!cancelled){setError('Не удалось прочитать сохранённую сцену. Изменения можно экспортировать в JSON.');setSaveState('error');}}).finally(()=>{if(!cancelled)setReady(true);});return()=>{cancelled=true;};},[vehicle]);
|
||||||
|
const update=useCallback((fn:(doc:SceneDocument)=>SceneDocument)=>{setDocument(d=>fn(d));setRevision(v=>v+1);setSaveState('saving');},[]);
|
||||||
|
useEffect(()=>{
|
||||||
|
if(!ready||revision===0)return;
|
||||||
|
let cancelled=false;const timer=setTimeout(()=>{enqueue(vehicle,document).then(()=>{savedRevision.current=revision;if(!cancelled)setSaveState('saved');}).catch(()=>{if(!cancelled){setSaveState('error');setError('Не удалось сохранить сцену на этом компьютере. Экспортируйте JSON, чтобы сохранить изменения.');}});},400);
|
||||||
|
return()=>{cancelled=true;clearTimeout(timer);};
|
||||||
|
},[ready,revision,vehicle,document]);
|
||||||
|
useEffect(()=>()=>{const v=latest.current;if(v.revision>savedRevision.current)void enqueue(v.vehicle,v.document).catch(()=>{});},[]);
|
||||||
|
const importDocument=async(file:File)=>{try{if(file.size>32_000_000)throw new Error('Файл сцены больше 32 МБ.');const next=parseScene(JSON.parse(await file.text()));update(()=>next);}catch(e){setError(e instanceof Error?e.message:'Не удалось загрузить сцену.');}};
|
||||||
|
const exportDocument=()=>{const blob=new Blob([JSON.stringify(document,null,2)],{type:'application/json'});const url=URL.createObjectURL(blob);const a=globalThis.document.createElement('a');a.href=url;a.download='rover-scene.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
|
||||||
|
return {document,ready,saveState,error,setError,update,importDocument,exportDocument};
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {fetchComputeContours,type ComputeContour} from './computeContours';
|
||||||
|
import {fetchWorkerTelemetry,type WorkerTelemetry} from './workerTelemetry';
|
||||||
|
import type {Vehicle} from '../fleet/useFleet';
|
||||||
|
import {workerConnectionStatus} from './workerConnectionStatus';
|
||||||
|
|
||||||
|
export interface CoreHealth {
|
||||||
|
ok:boolean; service:string; version:string;
|
||||||
|
plugin_runtimes:{ready:number;total:number};
|
||||||
|
components?:{recording_cache?:{status?:string}};
|
||||||
|
}
|
||||||
|
export interface ContourSnapshot {
|
||||||
|
core:CoreHealth|null; latency:number|null;
|
||||||
|
workers:{contour:ComputeContour;telemetry:WorkerTelemetry|null}[]|null;
|
||||||
|
vehicles:Vehicle[]|null; errors:string[]; observedAt:number;
|
||||||
|
}
|
||||||
|
async function json(url:string,signal:AbortSignal):Promise<unknown>{
|
||||||
|
const response=await fetch(url,{signal,cache:'no-store',headers:{Accept:'application/json'}});
|
||||||
|
if(!response.ok)throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
export async function readContourSnapshot(signal:AbortSignal):Promise<ContourSnapshot>{
|
||||||
|
const bounded=AbortSignal.any([signal,AbortSignal.timeout(8000)]);
|
||||||
|
const errors:string[]=[];
|
||||||
|
const [coreResult,workersResult,fleetResult]=await Promise.allSettled([
|
||||||
|
(async()=>{
|
||||||
|
const start=performance.now();
|
||||||
|
const data=await json('/api/health',bounded) as CoreHealth;
|
||||||
|
if(!data||typeof data.ok!=='boolean'||data.service!=='mission-core-control-plane'||typeof data.version!=='string'||!Number.isFinite(data.plugin_runtimes?.ready)||!Number.isFinite(data.plugin_runtimes?.total))throw new Error('Invalid health');
|
||||||
|
return {data,latency:performance.now()-start};
|
||||||
|
})(),
|
||||||
|
(async()=>{
|
||||||
|
const contours=await fetchComputeContours(bounded);
|
||||||
|
return Promise.all(contours.map(async contour=>{
|
||||||
|
try{return {contour,telemetry:await fetchWorkerTelemetry(contour.contour_id,bounded)};}
|
||||||
|
catch{errors.push(`Не удалось проверить вычислитель «${contour.display_name}».`);return {contour,telemetry:null};}
|
||||||
|
}));
|
||||||
|
})(),
|
||||||
|
(async()=>{
|
||||||
|
const data=await json('/api/v1/fleet',bounded) as {items:Vehicle[]};
|
||||||
|
if(!data||!Array.isArray(data.items)||data.items.some(item=>!item||typeof item.id!=='string'||typeof item.name!=='string'||!['online','offline'].includes(item.connectivity)))throw new Error('Invalid fleet');
|
||||||
|
return data.items;
|
||||||
|
})(),
|
||||||
|
]);
|
||||||
|
if(coreResult.status==='rejected')errors.push('Не удалось проверить Mission Core.');
|
||||||
|
if(workersResult.status==='rejected')errors.push('Не удалось получить список вычислителей.');
|
||||||
|
if(fleetResult.status==='rejected')errors.push('Не удалось получить бортовые компьютеры.');
|
||||||
|
return {
|
||||||
|
core:coreResult.status==='fulfilled'?coreResult.value.data:null,
|
||||||
|
latency:coreResult.status==='fulfilled'?coreResult.value.latency:null,
|
||||||
|
workers:workersResult.status==='fulfilled'?workersResult.value:null,
|
||||||
|
vehicles:fleetResult.status==='fulfilled'?fleetResult.value:null,
|
||||||
|
errors,observedAt:Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function workerAvailable(telemetry:WorkerTelemetry|null):boolean{
|
||||||
|
return Boolean(telemetry?.connection.reachable&&telemetry.connection.identity_matches&&telemetry.node);
|
||||||
|
}
|
||||||
|
export function boardAvailable(vehicle:Vehicle):boolean{
|
||||||
|
return vehicle.enrollment==='paired'&&vehicle.connectivity==='online';
|
||||||
|
}
|
||||||
|
export function contourSummary(snapshot:ContourSnapshot|null){
|
||||||
|
if(!snapshot)return {tone:'warning' as const,label:'Проверка состояния',online:null,total:null};
|
||||||
|
const total=snapshot.workers!==null&&snapshot.vehicles!==null?1+snapshot.workers.length+snapshot.vehicles.length:null;
|
||||||
|
const online=Number(snapshot.core!==null)+(snapshot.workers??[]).filter(item=>workerAvailable(item.telemetry)).length+(snapshot.vehicles??[]).filter(boardAvailable).length;
|
||||||
|
if(snapshot.errors.length)return {tone:'warning' as const,label:'Проверка неполная',online,total};
|
||||||
|
if(!snapshot.core)return {tone:'danger' as const,label:'Mission Core не в сети',online,total};
|
||||||
|
if(!snapshot.core.ok)return {tone:'warning' as const,label:'Mission Core работает с ограничениями',online,total};
|
||||||
|
if(snapshot.workers?.some(item=>workerConnectionStatus(item.telemetry).state==='unknown'))return {tone:'warning' as const,label:'Состояние части узлов не подтверждено',online,total};
|
||||||
|
return {tone:online===total?'success' as const:'warning' as const,label:online===total?'Все узлы в сети':'Часть узлов не в сети',online,total};
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type {WorkerTelemetry} from './workerTelemetry';
|
||||||
|
|
||||||
|
// Receiver failure is not evidence that the remote host has shut down.
|
||||||
|
export function workerConnectionStatus(telemetry: WorkerTelemetry | null) {
|
||||||
|
const connection = telemetry?.connection;
|
||||||
|
if (!connection) return {state:'unknown', tone:'warning', label:'Не в сети', detail:'Состояние вычислителя пока не подтверждено.'} as const;
|
||||||
|
if (connection.reachable && !connection.identity_matches) return {state:'unknown', tone:'warning', label:'Не в сети', detail:'Ответивший узел не совпадает с выбранным вычислителем.'} as const;
|
||||||
|
if (connection.reachable && connection.identity_matches && telemetry.node) return {state:'online', tone:'success', label:'В сети', detail:null} as const;
|
||||||
|
if (['telemetry-receiver-unavailable','telemetry-agent-unavailable','invalid-telemetry-response'].includes(connection.error_code ?? '')) {
|
||||||
|
return {state:'unknown', tone:'warning', label:'Не в сети', detail:'Mission Core не получает данные через службу телеметрии. Состояние самого вычислителя не подтверждено.'} as const;
|
||||||
|
}
|
||||||
|
return {state:'unknown', tone:'warning', label:'Не в сети', detail:'Последние данные не подтверждают текущее состояние вычислителя.'} as const;
|
||||||
|
}
|
||||||
@@ -186,7 +186,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
|||||||
label: "Состояние контура",
|
label: "Состояние контура",
|
||||||
title: "Состояние контура",
|
title: "Состояние контура",
|
||||||
eyebrow: "ПАРК / СОСТОЯНИЕ",
|
eyebrow: "ПАРК / СОСТОЯНИЕ",
|
||||||
description: "Узлы, процессы, сеть и подключённые устройства по данным живого контура.",
|
description: "Связь с Mission Core, вычислителями и бортовыми компьютерами аппаратов.",
|
||||||
icon: "shield",
|
icon: "shield",
|
||||||
kind: "contour-health",
|
kind: "contour-health",
|
||||||
groups: [],
|
groups: [],
|
||||||
@@ -213,50 +213,6 @@ export const workspaces: WorkspaceDefinition[] = [
|
|||||||
kind: "device",
|
kind: "device",
|
||||||
groups: [],
|
groups: [],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "payload",
|
|
||||||
root: "fleet",
|
|
||||||
label: "Сенсоры и каналы",
|
|
||||||
title: "Сенсоры и каналы",
|
|
||||||
eyebrow: "ПАРК / ПОЛЕЗНАЯ НАГРУЗКА",
|
|
||||||
description: "Полезная нагрузка аппарата и каналы данных, которые она публикует.",
|
|
||||||
icon: "grid",
|
|
||||||
kind: "catalog",
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
title: "Пространственные сенсоры",
|
|
||||||
description: "Единый контракт для разных источников.",
|
|
||||||
capabilities: [
|
|
||||||
active("Облако точек", "Доказанный декодированный поток точек текущего устройства."),
|
|
||||||
active("Поза и траектория", "Доказанный поток позы и накопление пути."),
|
|
||||||
contract("Камеры", "Изображение, видео, глубина, внутренние и внешние параметры."),
|
|
||||||
contract("Навигация", "IMU, GNSS/RTK, одометрия и система координат."),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "configurations",
|
|
||||||
root: "fleet",
|
|
||||||
label: "Компоновки",
|
|
||||||
title: "Компоновки борта",
|
|
||||||
eyebrow: "ПАРК / КОМПОНОВКИ",
|
|
||||||
description: "Версионируемые сочетания сенсоров, вычислителей, транспорта и питания.",
|
|
||||||
icon: "settings",
|
|
||||||
kind: "catalog",
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
title: "Шаблон компоновки",
|
|
||||||
description: "Повторяемая конфигурация для одинаковых аппаратов.",
|
|
||||||
capabilities: [
|
|
||||||
contract("Состав модулей", "Сенсоры, адаптеры, обработчики и зависимости."),
|
|
||||||
contract("Калибровки", "Системы координат, преобразования, внутренние и внешние параметры."),
|
|
||||||
contract("Профиль сети", "Локальная сеть, ячеистая сеть, LTE и серверный маршрут."),
|
|
||||||
later("Развёртывание на борт", "Пакет конфигурации и контролируемое обновление."),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "spatial-scene",
|
id: "spatial-scene",
|
||||||
root: "polygon",
|
root: "polygon",
|
||||||
|
|||||||
@@ -2173,6 +2173,7 @@
|
|||||||
|
|
||||||
.contour-health-summary {
|
.contour-health-summary {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2203,7 +2204,7 @@
|
|||||||
|
|
||||||
.contour-health-kpis {
|
.contour-health-kpis {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2235,7 +2236,7 @@
|
|||||||
|
|
||||||
.contour-node-grid {
|
.contour-node-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2251,6 +2252,7 @@
|
|||||||
|
|
||||||
.contour-node header {
|
.contour-node header {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2262,7 +2264,7 @@
|
|||||||
|
|
||||||
.contour-node dl {
|
.contour-node dl {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
background: rgb(255 255 255 / 0.022);
|
background: rgb(255 255 255 / 0.022);
|
||||||
@@ -2277,11 +2279,10 @@
|
|||||||
|
|
||||||
.contour-node dd {
|
.contour-node dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow-wrap: anywhere;
|
||||||
color: var(--nodedc-text-secondary);
|
color: var(--nodedc-text-secondary);
|
||||||
font-size: 0.62rem;
|
font-size: var(--nodedc-font-size-sm);
|
||||||
text-overflow: ellipsis;
|
white-space: normal;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.contour-process-list {
|
.contour-process-list {
|
||||||
|
|||||||
@@ -1,41 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import {useEffect,useState} from 'react';
|
||||||
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
|
import {createPortal} from 'react-dom';
|
||||||
|
import {workerConnectionStatus} from '../core/system/workerConnectionStatus';
|
||||||
import type { MissionRuntimeState } from "../core/runtime/contracts";
|
import {IconButton,Icon,StatusBadge} from '@nodedc/ui-react';
|
||||||
import {
|
import type {MissionRuntimeState} from '../core/runtime/contracts';
|
||||||
fetchPolygonWorkerStatus,
|
import {boardAvailable,contourSummary,readContourSnapshot,type ContourSnapshot} from '../core/system/contourHealth';
|
||||||
type PolygonWorkerStatus,
|
import {startSequentialPolling} from '../core/system/sequentialPolling';
|
||||||
} from "../core/polygon/liveWorker";
|
|
||||||
|
|
||||||
interface ControlPlaneHealth {
|
|
||||||
ok: boolean;
|
|
||||||
status: string;
|
|
||||||
service: string;
|
|
||||||
version: string;
|
|
||||||
plugin_runtimes: {
|
|
||||||
ready: number;
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PluginRuntimeHealth {
|
|
||||||
runtime_instance_id: string;
|
|
||||||
plugin_id: string;
|
|
||||||
plugin_version: string;
|
|
||||||
status: string;
|
|
||||||
observed_at: string;
|
|
||||||
detail_code: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContourSnapshot {
|
|
||||||
controlPlane: ControlPlaneHealth | null;
|
|
||||||
pluginRuntimes: PluginRuntimeHealth[];
|
|
||||||
simulationWorker: PolygonWorkerStatus | null;
|
|
||||||
controlPlaneLatencyMs: number | null;
|
|
||||||
simulationGatewayLatencyMs: number | null;
|
|
||||||
observedAt: Date;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function contourRuntimeAuthorityPresentation(
|
export function contourRuntimeAuthorityPresentation(
|
||||||
state: MissionRuntimeState | null,
|
state: MissionRuntimeState | null,
|
||||||
@@ -56,310 +25,64 @@ export function contourRuntimeAuthorityPresentation(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function validControlPlaneHealth(value: unknown): value is ControlPlaneHealth {
|
function observed(value:number|string|null|undefined){
|
||||||
if (!value || typeof value !== "object") return false;
|
if(value===null||value===undefined)return '—';
|
||||||
const item = value as Partial<ControlPlaneHealth>;
|
const date=new Date(value);
|
||||||
return typeof item.ok === "boolean"
|
return Number.isFinite(date.getTime())?date.toLocaleString('ru-RU'):'—';
|
||||||
&& typeof item.status === "string"
|
|
||||||
&& typeof item.service === "string"
|
|
||||||
&& typeof item.version === "string"
|
|
||||||
&& Boolean(item.plugin_runtimes)
|
|
||||||
&& typeof item.plugin_runtimes?.ready === "number"
|
|
||||||
&& typeof item.plugin_runtimes?.total === "number";
|
|
||||||
}
|
}
|
||||||
|
export function ContourHealthWorkspace({state,headerToolsHost}:{state:MissionRuntimeState|null;headerToolsHost?:HTMLElement|null}){
|
||||||
function validPluginRuntime(value: unknown): value is PluginRuntimeHealth {
|
const [snapshot,setSnapshot]=useState<ContourSnapshot|null>(null);
|
||||||
if (!value || typeof value !== "object") return false;
|
const [loading,setLoading]=useState(true),[generation,setGeneration]=useState(0);
|
||||||
const item = value as Partial<PluginRuntimeHealth>;
|
useEffect(()=>{
|
||||||
return typeof item.runtime_instance_id === "string"
|
const controller=new AbortController();
|
||||||
&& typeof item.plugin_id === "string"
|
const stop=startSequentialPolling(async()=>{
|
||||||
&& typeof item.plugin_version === "string"
|
setLoading(true);
|
||||||
&& typeof item.status === "string"
|
try{const next=await readContourSnapshot(controller.signal);if(!controller.signal.aborted)setSnapshot(next);}
|
||||||
&& typeof item.observed_at === "string";
|
finally{if(!controller.signal.aborted)setLoading(false);}
|
||||||
}
|
},5000);
|
||||||
|
return ()=>{stop();controller.abort();};
|
||||||
async function timedJson(
|
},[generation]);
|
||||||
url: string,
|
const summary=contourSummary(snapshot);
|
||||||
signal: AbortSignal,
|
const core=snapshot?.core;
|
||||||
): Promise<{ payload: unknown; latencyMs: number }> {
|
const {controlledDevice,deviceControlConnectivity}=contourRuntimeAuthorityPresentation(state);
|
||||||
const started = performance.now();
|
const refresh=<IconButton label={loading?"Проверка состояния":"Обновить состояние контура"} disabled={loading} onClick={()=>setGeneration(value=>value+1)}><Icon name="refresh" size={16}/></IconButton>;
|
||||||
const response = await fetch(url, {
|
return <div className="contour-health-dashboard">
|
||||||
method: "GET",
|
{headerToolsHost?createPortal(refresh,headerToolsHost):refresh}
|
||||||
headers: { Accept: "application/json" },
|
<section className="contour-health-summary">
|
||||||
signal,
|
<div><h2>Узлы и связь</h2><p>Mission Core, зарегистрированные вычислители и бортовые компьютеры аппаратов.</p></div>
|
||||||
});
|
<div className="contour-health-summary__status"><StatusBadge tone={loading?'warning':summary.tone}>{loading?'Проверка состояния':summary.label}</StatusBadge></div>
|
||||||
const latencyMs = performance.now() - started;
|
</section>
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
<section className="contour-health-kpis" aria-label="Сводка состояния контура">
|
||||||
return { payload: await response.json(), latencyMs };
|
<div><span>Узлы в сети</span><strong>{summary.online??'—'} / {summary.total??'—'}</strong><small>Core, вычислители и бортовые ПК</small></div>
|
||||||
}
|
<div><span>Вычислители</span><strong>{snapshot?.workers?.length??'—'}</strong><small>из настроек системы</small></div>
|
||||||
|
<div><span>Бортовые компьютеры</span><strong>{snapshot?.vehicles?.length??'—'}</strong><small>из реестра аппаратов</small></div>
|
||||||
function formatLatency(value: number | null): string {
|
<div><span>Последняя проверка</span><strong>{observed(snapshot?.observedAt)}</strong><small>обновление каждые 5 секунд</small></div>
|
||||||
if (value === null) return "—";
|
</section>
|
||||||
return `${Math.max(1, Math.round(value))} мс`;
|
<section className="contour-node-grid" aria-label="Узлы контура">
|
||||||
}
|
<article className="contour-node" data-state={core?(core.ok?'online':'degraded'):'unknown'}>
|
||||||
|
<header><div><span className="section-eyebrow">КОМПЬЮТЕР ОПЕРАТОРА</span><h3>Mission Core</h3></div><StatusBadge tone={core?.ok?'success':'warning'}>{core?'В сети':snapshot?'Не в сети':'Проверка состояния'}</StatusBadge></header>
|
||||||
function formatObservedAt(value: Date | string | null): string {
|
<dl><div><dt>Версия</dt><dd>{core?.version??'—'}</dd></div><div><dt>Время ответа</dt><dd>{snapshot?.latency==null?'—':`${Math.max(1,Math.round(snapshot.latency))} мс`}</dd></div><div><dt>Обработчики устройств готовы</dt><dd>{core?`${core.plugin_runtimes.ready} / ${core.plugin_runtimes.total}`:'—'}</dd></div></dl>
|
||||||
if (!value) return "—";
|
{!core?.ok&&core?.components?.recording_cache?.status==='capacity-pressure'&&<small>Недостаточно свободного места для новых записей.</small>}
|
||||||
const date = value instanceof Date ? value : new Date(value);
|
</article>
|
||||||
if (!Number.isFinite(date.getTime())) return "—";
|
{snapshot?.workers?.map(({contour,telemetry})=>{
|
||||||
return new Intl.DateTimeFormat("ru-RU", {
|
const status=workerConnectionStatus(telemetry);
|
||||||
hour: "2-digit",
|
return <article key={contour.contour_id} className="contour-node" data-state={status.state}>
|
||||||
minute: "2-digit",
|
<header><div><span className="section-eyebrow">ВЫЧИСЛИТЕЛЬ</span><h3>{contour.display_name}</h3></div><StatusBadge tone={status.tone}>{status.label}</StatusBadge></header>
|
||||||
second: "2-digit",
|
<dl><div><dt>Узел</dt><dd>{contour.expected_node_id}</dd></div><div><dt>Телеметрия</dt><dd>{contour.telemetry_mode==='agent-mqtt'?'Агент':'Диагностика SSH'}</dd></div><div><dt>Проверено</dt><dd>{observed(telemetry?.connection.observed_at_utc)}</dd></div><div><dt>Последнее измерение</dt><dd>{observed(telemetry?.node?.observed_at_utc)}</dd></div><div><dt>Операционная система</dt><dd>{telemetry?.node?.os.caption??'—'}</dd></div></dl>
|
||||||
}).format(date);
|
{status.detail&&<small>{status.detail}</small>}
|
||||||
}
|
</article>;
|
||||||
|
})}
|
||||||
export function ContourHealthWorkspace({
|
{snapshot?.vehicles?.map(vehicle=>{
|
||||||
state,
|
const online=boardAvailable(vehicle);
|
||||||
}: {
|
const status=vehicle.enrollment==='revoked'?'Привязка отозвана':vehicle.enrollment==='pending'?'Подключение':vehicle.enrollment==='failed'?'Не подключён':online?'В сети':'Не в сети';
|
||||||
state: MissionRuntimeState | null;
|
return <article key={vehicle.id} className="contour-node" data-state={online?'online':'offline'}>
|
||||||
}) {
|
<header><div><span className="section-eyebrow">БОРТОВОЙ КОМПЬЮТЕР</span><h3>{vehicle.name}</h3></div><StatusBadge tone={online?'success':'neutral'}>{status}</StatusBadge></header>
|
||||||
const [snapshot, setSnapshot] = useState<ContourSnapshot | null>(null);
|
<dl><div><dt>Имя БК</dt><dd>{vehicle.host?.hostname??'—'}</dd></div><div><dt>Операционная система</dt><dd>{vehicle.host?.os??'—'}</dd></div><div><dt>Последняя связь</dt><dd>{observed(vehicle.last_seen===null?null:vehicle.last_seen*1000)}</dd></div></dl>
|
||||||
const [loading, setLoading] = useState(false);
|
{!online&&vehicle.host&&<small>Сведения о БК сохранены с последнего подключения.</small>}
|
||||||
const [generation, setGeneration] = useState(0);
|
</article>;
|
||||||
|
})}
|
||||||
const refresh = useCallback(() => setGeneration((value) => value + 1), []);
|
</section>
|
||||||
|
{controlledDevice&&<section className="contour-connected-device"><span className="section-eyebrow">ПОДКЛЮЧЁННОЕ УСТРОЙСТВО ОПЕРАТОРА</span><strong>{controlledDevice.displayName}</strong><small>{deviceControlConnectivity==='degraded'?'Поток данных нарушен':'Управляющая связь подтверждена'}</small></section>}
|
||||||
useEffect(() => {
|
{snapshot?.errors.map(error=><p key={error} className="contour-health-error" role="status">{error}</p>)}
|
||||||
const controller = new AbortController();
|
</div>;
|
||||||
setLoading(true);
|
|
||||||
void Promise.allSettled([
|
|
||||||
timedJson("/api/health", controller.signal),
|
|
||||||
timedJson("/api/v1/device-plugin-runtimes", controller.signal),
|
|
||||||
(async () => {
|
|
||||||
const started = performance.now();
|
|
||||||
const worker = await fetchPolygonWorkerStatus({ signal: controller.signal });
|
|
||||||
return { worker, latencyMs: performance.now() - started };
|
|
||||||
})(),
|
|
||||||
]).then(([healthResult, runtimesResult, workerResult]) => {
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
const healthPayload = healthResult.status === "fulfilled"
|
|
||||||
? healthResult.value.payload
|
|
||||||
: null;
|
|
||||||
const runtimesPayload = runtimesResult.status === "fulfilled"
|
|
||||||
? runtimesResult.value.payload
|
|
||||||
: null;
|
|
||||||
const runtimeItems = runtimesPayload
|
|
||||||
&& typeof runtimesPayload === "object"
|
|
||||||
&& Array.isArray((runtimesPayload as { items?: unknown }).items)
|
|
||||||
? (runtimesPayload as { items: unknown[] }).items.filter(validPluginRuntime)
|
|
||||||
: [];
|
|
||||||
const failures = [healthResult, runtimesResult, workerResult]
|
|
||||||
.filter((result) => result.status === "rejected").length;
|
|
||||||
setSnapshot({
|
|
||||||
controlPlane: validControlPlaneHealth(healthPayload) ? healthPayload : null,
|
|
||||||
pluginRuntimes: runtimeItems,
|
|
||||||
simulationWorker: workerResult.status === "fulfilled"
|
|
||||||
? workerResult.value.worker
|
|
||||||
: null,
|
|
||||||
controlPlaneLatencyMs: healthResult.status === "fulfilled"
|
|
||||||
? healthResult.value.latencyMs
|
|
||||||
: null,
|
|
||||||
simulationGatewayLatencyMs: workerResult.status === "fulfilled"
|
|
||||||
? workerResult.value.latencyMs
|
|
||||||
: null,
|
|
||||||
observedAt: new Date(),
|
|
||||||
error: failures === 0
|
|
||||||
? null
|
|
||||||
: `Не ответили ${failures} из 3 диагностических контрактов.`,
|
|
||||||
});
|
|
||||||
}).finally(() => {
|
|
||||||
if (!controller.signal.aborted) setLoading(false);
|
|
||||||
});
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [generation]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = window.setInterval(refresh, 5_000);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
aiActive,
|
|
||||||
deviceControlConnectivity,
|
|
||||||
controlledDevice,
|
|
||||||
} = contourRuntimeAuthorityPresentation(state);
|
|
||||||
const simulationWorker = snapshot?.simulationWorker ?? null;
|
|
||||||
const controlPlaneReady = Boolean(snapshot?.controlPlane?.ok);
|
|
||||||
const runtimeReady = snapshot?.pluginRuntimes.filter(
|
|
||||||
(runtime) => runtime.status === "ready",
|
|
||||||
).length ?? 0;
|
|
||||||
const processCount = (snapshot?.pluginRuntimes.length ?? 0) + 2;
|
|
||||||
const readyProcessCount = runtimeReady
|
|
||||||
+ (controlPlaneReady ? 1 : 0)
|
|
||||||
+ (simulationWorker?.available ? 1 : 0);
|
|
||||||
const nodeStatus = useMemo(() => {
|
|
||||||
if (!snapshot) return "Проверяем";
|
|
||||||
if (controlPlaneReady && snapshot.error === null) return "Контур отвечает";
|
|
||||||
if (controlPlaneReady) return "Частично доступен";
|
|
||||||
return "Нет связи";
|
|
||||||
}, [controlPlaneReady, snapshot]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="contour-health-dashboard">
|
|
||||||
<section className="contour-health-summary">
|
|
||||||
<div>
|
|
||||||
<span className="section-eyebrow">ЖИВОЙ ДИАГНОСТИЧЕСКИЙ СРЕЗ</span>
|
|
||||||
<h2>Локальный вычислительный контур</h2>
|
|
||||||
<p>
|
|
||||||
Статусы читаются из Control Plane, plugin runtime и шлюза Simulation Worker.
|
|
||||||
Пустые значения не подменяются демонстрационными числами.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="contour-health-summary__status">
|
|
||||||
<StatusBadge tone={controlPlaneReady ? "success" : "danger"}>
|
|
||||||
{nodeStatus}
|
|
||||||
</StatusBadge>
|
|
||||||
<Button
|
|
||||||
size="compact"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={refresh}
|
|
||||||
>
|
|
||||||
<Icon name="refresh" size={14} />
|
|
||||||
{loading ? "Проверяем" : "Обновить"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="contour-health-kpis" aria-label="Сводка состояния контура">
|
|
||||||
<div>
|
|
||||||
<span>Узлы</span>
|
|
||||||
<strong>{snapshot ? 2 : "—"}</strong>
|
|
||||||
<small>локальный + внешний worker</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Процессы готовы</span>
|
|
||||||
<strong>{snapshot ? `${readyProcessCount} / ${processCount}` : "—"}</strong>
|
|
||||||
<small>по живым health-контрактам</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Локальный API</span>
|
|
||||||
<strong>{formatLatency(snapshot?.controlPlaneLatencyMs ?? null)}</strong>
|
|
||||||
<small>браузер → Control Plane</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Последняя проверка</span>
|
|
||||||
<strong>{formatObservedAt(snapshot?.observedAt ?? null)}</strong>
|
|
||||||
<small>автообновление каждые 5 секунд</small>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="contour-node-grid" aria-label="Вычислительные узлы">
|
|
||||||
<article className="contour-node" data-state={controlPlaneReady ? "online" : "offline"}>
|
|
||||||
<header>
|
|
||||||
<div>
|
|
||||||
<span className="section-eyebrow">УЗЕЛ 01 · ЛОКАЛЬНЫЙ</span>
|
|
||||||
<h3>Mission Core Control Plane</h3>
|
|
||||||
</div>
|
|
||||||
<StatusBadge tone={controlPlaneReady ? "success" : "danger"}>
|
|
||||||
{controlPlaneReady ? "Доступен" : "Недоступен"}
|
|
||||||
</StatusBadge>
|
|
||||||
</header>
|
|
||||||
<dl>
|
|
||||||
<div><dt>Версия</dt><dd>{snapshot?.controlPlane?.version ?? "—"}</dd></div>
|
|
||||||
<div><dt>API latency</dt><dd>{formatLatency(snapshot?.controlPlaneLatencyMs ?? null)}</dd></div>
|
|
||||||
<div><dt>Plugin runtimes</dt><dd>{snapshot ? `${runtimeReady} / ${snapshot.pluginRuntimes.length}` : "—"}</dd></div>
|
|
||||||
<div><dt>Активный режим</dt><dd>{state?.sourceMode ?? "idle"}</dd></div>
|
|
||||||
</dl>
|
|
||||||
<div className="contour-process-list">
|
|
||||||
{snapshot?.pluginRuntimes.map((runtime) => (
|
|
||||||
<div key={runtime.runtime_instance_id}>
|
|
||||||
<i data-state={runtime.status === "ready" ? "online" : "offline"} />
|
|
||||||
<span>
|
|
||||||
<strong>Device runtime · {runtime.plugin_id}</strong>
|
|
||||||
<small>v{runtime.plugin_version} · {formatObservedAt(runtime.observed_at)}</small>
|
|
||||||
</span>
|
|
||||||
<em>{runtime.status}</em>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div>
|
|
||||||
<i data-state={aiActive ? "online" : "idle"} />
|
|
||||||
<span>
|
|
||||||
<strong>AI perception</strong>
|
|
||||||
<small>
|
|
||||||
{aiActive
|
|
||||||
? `${Math.round(state?.metrics?.aiFrameRateHz ?? 0)} кадр/с`
|
|
||||||
: "Нет активного задания"}
|
|
||||||
</small>
|
|
||||||
</span>
|
|
||||||
<em>{aiActive ? "active" : "idle"}</em>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article
|
|
||||||
className="contour-node"
|
|
||||||
data-state={simulationWorker?.available ? "online" : "offline"}
|
|
||||||
>
|
|
||||||
<header>
|
|
||||||
<div>
|
|
||||||
<span className="section-eyebrow">УЗЕЛ 02 · ВНЕШНИЙ</span>
|
|
||||||
<h3>{simulationWorker?.workerId ?? "Simulation Worker"}</h3>
|
|
||||||
</div>
|
|
||||||
<StatusBadge tone={simulationWorker?.available ? "success" : "neutral"}>
|
|
||||||
{simulationWorker?.available ? "Доступен" : "Offline"}
|
|
||||||
</StatusBadge>
|
|
||||||
</header>
|
|
||||||
<dl>
|
|
||||||
<div><dt>Gateway latency</dt><dd>{formatLatency(snapshot?.simulationGatewayLatencyMs ?? null)}</dd></div>
|
|
||||||
<div><dt>Сеть worker</dt><dd>{simulationWorker?.isolation.network ?? "unavailable"}</dd></div>
|
|
||||||
<div><dt>Политика данных</dt><dd>{simulationWorker?.isolation.artifactPolicy ?? "d-only"}</dd></div>
|
|
||||||
<div><dt>Активный прогон</dt><dd>{simulationWorker?.activeRunId ?? "нет"}</dd></div>
|
|
||||||
</dl>
|
|
||||||
<div className="contour-process-list">
|
|
||||||
<div>
|
|
||||||
<i data-state={simulationWorker?.available ? "online" : "offline"} />
|
|
||||||
<span>
|
|
||||||
<strong>Simulation orchestration</strong>
|
|
||||||
<small>
|
|
||||||
{simulationWorker?.runState
|
|
||||||
? `Прогон: ${simulationWorker.runState}`
|
|
||||||
: "Нет активного прогона"}
|
|
||||||
</small>
|
|
||||||
</span>
|
|
||||||
<em>{simulationWorker?.available ? "ready" : "offline"}</em>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="contour-network-strip" aria-label="Состояние сети">
|
|
||||||
<div>
|
|
||||||
<span className="section-eyebrow">СЕТЬ</span>
|
|
||||||
<strong>Браузер</strong>
|
|
||||||
<small>127.0.0.1:8000</small>
|
|
||||||
</div>
|
|
||||||
<Icon name="chevron-right" />
|
|
||||||
<div>
|
|
||||||
<i data-state={controlPlaneReady ? "online" : "offline"} />
|
|
||||||
<strong>Control Plane</strong>
|
|
||||||
<small>{formatLatency(snapshot?.controlPlaneLatencyMs ?? null)}</small>
|
|
||||||
</div>
|
|
||||||
<Icon name="chevron-right" />
|
|
||||||
<div>
|
|
||||||
<i data-state={simulationWorker?.available ? "online" : "offline"} />
|
|
||||||
<strong>Worker gateway</strong>
|
|
||||||
<small>{formatLatency(snapshot?.simulationGatewayLatencyMs ?? null)}</small>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{controlledDevice ? (
|
|
||||||
<section className="contour-connected-device">
|
|
||||||
<span className="section-eyebrow">ПОДТВЕРЖДЁННАЯ УПРАВЛЯЮЩАЯ СЕССИЯ</span>
|
|
||||||
<strong>{controlledDevice.displayName}</strong>
|
|
||||||
<small>
|
|
||||||
{controlledDevice.endpointLabel ?? controlledDevice.modelId}
|
|
||||||
{deviceControlConnectivity === "degraded"
|
|
||||||
? " · управление подтверждено, поток данных нарушен"
|
|
||||||
: " · управление подтверждено"}
|
|
||||||
</small>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{snapshot?.error ? (
|
|
||||||
<p className="contour-health-error" role="status">{snapshot.error}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,12 +431,13 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
|||||||
case "missions":
|
case "missions":
|
||||||
return <MissionPlannerWorkspace openView={props.navigation.openView} headerToolsHost={props.headerToolsHost} />;
|
return <MissionPlannerWorkspace openView={props.navigation.openView} headerToolsHost={props.headerToolsHost} />;
|
||||||
case "vehicles":
|
case "vehicles":
|
||||||
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} headerToolsHost={props.headerToolsHost} />;
|
return <VehiclesWorkspace headerToolsHost={props.headerToolsHost} headerTitleHost={props.headerTitleHost} />;
|
||||||
case "catalog":
|
case "catalog":
|
||||||
return <CatalogWorkspace {...props} />;
|
return <CatalogWorkspace {...props} />;
|
||||||
case "contour-health":
|
case "contour-health":
|
||||||
return (
|
return (
|
||||||
<ContourHealthWorkspace
|
<ContourHealthWorkspace
|
||||||
|
headerToolsHost={props.headerToolsHost}
|
||||||
state={props.state}
|
state={props.state}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ export interface LaboratoryViewAction {
|
|||||||
|
|
||||||
export interface WorkspaceRendererProps {
|
export interface WorkspaceRendererProps {
|
||||||
launchProfile?: WorkspaceLaunchProfile;
|
launchProfile?: WorkspaceLaunchProfile;
|
||||||
fleetCreateRequest?: number;
|
|
||||||
headerToolsHost?: HTMLElement | null;
|
headerToolsHost?: HTMLElement | null;
|
||||||
|
headerTitleHost?: HTMLElement | null;
|
||||||
definition: WorkspaceDefinition;
|
definition: WorkspaceDefinition;
|
||||||
state: MissionRuntimeState | null;
|
state: MissionRuntimeState | null;
|
||||||
backendStatus: BackendStatus;
|
backendStatus: BackendStatus;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {Button,Icon,IconButton,ResourceRow,StatusBadge,Window} from '@nodedc/ui-react';
|
||||||
|
import type {Vehicle} from '../../core/fleet/useFleet';
|
||||||
|
|
||||||
|
export const boardStatus=(item:Vehicle,available=true)=>item.enrollment==='pending'?'Подтверждаем привязку':item.enrollment==='revoked'?'Не привязан':item.enrollment==='failed'?'Привязка не завершена':item.connectivity==='online'?(available?'В сети':'Не в сети'):'Не в сети';
|
||||||
|
export function BoardComputerRow({vehicle,enabled,showingSavedDevices,onSettings,onMonitor,onAdd,open,onToggle}:{vehicle:Vehicle;enabled:boolean;showingSavedDevices:boolean;onSettings:()=>void;onMonitor:()=>void;onAdd?:()=>void;open:boolean;onToggle:()=>void}){
|
||||||
|
const label=boardStatus(vehicle,enabled);
|
||||||
|
return <ResourceRow icon={<Icon name="apps"/>} title="Бортовой компьютер" description={vehicle.host?.hostname||'Mission Core Node'} metadata={`${label}${showingSavedDevices?' (показаны последние сохранённые устройства)':''}`} statusPlacement="leading"
|
||||||
|
status={<StatusBadge variant="indicator" tone={enabled?'success':vehicle.enrollment==='pending'?'warning':'neutral'} aria-label={label} title={label}/>}
|
||||||
|
actions={<>{onAdd&&<IconButton label="Добавить устройство к БК" onClick={onAdd}><Icon name="plus"/></IconButton>}<IconButton label="Настройки бортового компьютера" onClick={onSettings}><Icon name="settings"/></IconButton><IconButton label="Мониторинг системы БК" onClick={onMonitor}><Icon name="activity"/></IconButton><IconButton label={open?'Свернуть устройства БК':'Показать устройства БК'} aria-expanded={open} onClick={onToggle}><Icon name={open?'chevron-down':'chevron-right'}/></IconButton></>}/>;
|
||||||
|
}
|
||||||
|
export function BoardComputerSettings({vehicle,enabled,onClose,onRevoke}:{vehicle:Vehicle;enabled:boolean;onClose:()=>void;onRevoke:()=>void}){
|
||||||
|
return <Window open title="Бортовой компьютер" subtitle={vehicle.host?.hostname||'Mission Core Node'} onClose={onClose}>
|
||||||
|
<div className="fleet-form"><StatusBadge tone={enabled?'success':vehicle.enrollment==='pending'?'warning':'neutral'}>{boardStatus(vehicle,enabled)}</StatusBadge>
|
||||||
|
{vehicle.notice&&<p role="status">{vehicle.notice}</p>}
|
||||||
|
<dl className="fleet-facts"><div><dt>Идентификатор БК</dt><dd>{vehicle.node_id}</dd></div><div><dt>Последняя связь</dt><dd>{vehicle.last_seen?new Date(vehicle.last_seen*1000).toLocaleString('ru-RU'):'Соединение ещё не получено'}</dd></div>
|
||||||
|
{vehicle.host&&<><div><dt>Имя БК в системе</dt><dd>{vehicle.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{vehicle.host.os}</dd></div><div><dt>Архитектура</dt><dd>{vehicle.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{vehicle.host.cpus}</dd></div><div><dt>Память</dt><dd>{vehicle.host.memory_kib?`${(vehicle.host.memory_kib/1048576).toFixed(1)} ГиБ`:'Нет сведений'}</dd></div></>}
|
||||||
|
</dl>
|
||||||
|
{vehicle.enrollment!=='revoked'&&<Button onClick={onRevoke}>Отозвать привязку БК</Button>}
|
||||||
|
</div>
|
||||||
|
</Window>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import {WorkspaceBackButton} from '../../components/WorkspaceBackButton';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {ActivityIndicator,Button,Icon,ResourceList,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack} from '@nodedc/ui-react';
|
import {ActivityIndicator,Button,ResourceList,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack} from '@nodedc/ui-react';
|
||||||
import {useBoardMonitor,monitorSeries,monitorValue} from '../../core/fleet/boardMonitor';
|
import {useBoardMonitor,monitorSeries,monitorValue} from '../../core/fleet/boardMonitor';
|
||||||
import {TelemetrySeries} from '../../components/system/TelemetrySeries';
|
import {TelemetrySeries} from '../../components/system/TelemetrySeries';
|
||||||
import '../../styles/system-telemetry.css';
|
import '../../styles/system-telemetry.css';
|
||||||
@@ -7,7 +8,7 @@ import './board-monitor.css';
|
|||||||
|
|
||||||
const eventLabels:Record<string,string>={'ui-error':'Ошибка интерфейса','ui-rejection':'Ошибка асинхронной операции','ui-render-error':'Ошибка представления','system-oom':'Нехватка памяти','gpu-reset':'Сброс GPU','disk-error':'Ошибка диска','usb-disconnected':'Отключение USB','usb-error':'Ошибка USB','service-failed':'Сбой службы','ui-process-exit':'Завершение процесса окна','ui-load-failed':'Ошибка загрузки окна'};
|
const eventLabels:Record<string,string>={'ui-error':'Ошибка интерфейса','ui-rejection':'Ошибка асинхронной операции','ui-render-error':'Ошибка представления','system-oom':'Нехватка памяти','gpu-reset':'Сброс GPU','disk-error':'Ошибка диска','usb-disconnected':'Отключение USB','usb-error':'Ошибка USB','service-failed':'Сбой службы','ui-process-exit':'Завершение процесса окна','ui-load-failed':'Ошибка загрузки окна'};
|
||||||
const time=(value:number)=>new Date(value*1000).toLocaleString('ru-RU');
|
const time=(value:number)=>new Date(value*1000).toLocaleString('ru-RU');
|
||||||
export function BoardMonitorView({vehicle,name,back}:{vehicle:string;name:string;back:()=>void}) {
|
export function BoardMonitorView({vehicle,name,back,headerToolsHost}:{vehicle:string;name:string;back:()=>void;headerToolsHost?:HTMLElement|null}) {
|
||||||
const [metric,setMetric]=useState('cpu.usage'),[group,setGroup]=useState('CPU'),[window,setWindow]=useState(900),[end,setEnd]=useState<number|null>(null),[mode,setMode]=useState<'min'|'max'|'mean'>('max');
|
const [metric,setMetric]=useState('cpu.usage'),[group,setGroup]=useState('CPU'),[window,setWindow]=useState(900),[end,setEnd]=useState<number|null>(null),[mode,setMode]=useState<'min'|'max'|'mean'>('max');
|
||||||
const {value,error,dismissError}=useBoardMonitor(vehicle,metric,window,end);
|
const {value,error,dismissError}=useBoardMonitor(vehicle,metric,window,end);
|
||||||
const definitions=value?.definitions??{},definition=definitions[metric];
|
const definitions=value?.definitions??{},definition=definitions[metric];
|
||||||
@@ -15,7 +16,7 @@ export function BoardMonitorView({vehicle,name,back}:{vehicle:string;name:string
|
|||||||
const metrics=Object.entries(definitions).filter(([,item])=>item.group===group);
|
const metrics=Object.entries(definitions).filter(([,item])=>item.group===group);
|
||||||
const latest=value?.latest;
|
const latest=value?.latest;
|
||||||
return <div className="board-monitor">
|
return <div className="board-monitor">
|
||||||
<div><Button onClick={back}><Icon name="chevron-left"/>К аппарату</Button></div>
|
<WorkspaceBackButton label="К аппарату" onClick={back} host={headerToolsHost}/>
|
||||||
<SettingsCard title="Мониторинг системы БК" description={name} actions={<StatusBadge tone={value?.fresh&&!error?'success':'neutral'}>{value?.fresh&&!error?'Данные поступают':'Нет свежих данных'}</StatusBadge>}>
|
<SettingsCard title="Мониторинг системы БК" description={name} actions={<StatusBadge tone={value?.fresh&&!error?'success':'neutral'}>{value?.fresh&&!error?'Данные поступают':'Нет свежих данных'}</StatusBadge>}>
|
||||||
{latest&&<p>Измерено на БК: {time(latest.at)}. {value?.storage==='ready'?'Локальная история записывается.':'Запись локальной истории недоступна.'} {value?.backlog?'История догружается в Mission Core.':''}</p>}
|
{latest&&<p>Измерено на БК: {time(latest.at)}. {value?.storage==='ready'?'Локальная история записывается.':'Запись локальной истории недоступна.'} {value?.backlog?'История догружается в Mission Core.':''}</p>}
|
||||||
{value?.database_bytes!=null&&<p>Архив на БК: {monitorValue(value.database_bytes,'bytes')} из {monitorValue(value.database_budget_bytes,'bytes')}. {value.archive_since?`Копия в Mission Core с ${time(value.archive_since)}.`:''}</p>}
|
{value?.database_bytes!=null&&<p>Архив на БК: {monitorValue(value.database_bytes,'bytes')} из {monitorValue(value.database_budget_bytes,'bytes')}. {value.archive_since?`Копия в Mission Core с ${time(value.archive_since)}.`:''}</p>}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {useId,useState,type FormEvent} from 'react';
|
||||||
|
import {Button,Select,SettingsCard,TextAreaField,TextField,StatusBadge} from '@nodedc/ui-react';
|
||||||
|
import {fleetRequest,type FleetPreview,type Vehicle} from '../../core/fleet/useFleet';
|
||||||
|
import type {SensorEnrollmentProps} from '../../../../../packages/sensor-ui/src/extensions';
|
||||||
|
|
||||||
|
export const platforms=[{value:'ugv',label:'Наземный (UGV)'},{value:'uav',label:'Воздушный (UAV)'},{value:'stationary',label:'Стационарный'},{value:'other',label:'Другой'}];
|
||||||
|
export const platformLabel=(value:string)=>platforms.find(item=>item.value===value)?.label??value;
|
||||||
|
|
||||||
|
/** The invitation admits a computer; an existing apparatus keeps its identity. */
|
||||||
|
export function NodeInvitationForm({vehicle,onClose,onComplete,renderWindow}:{vehicle?:Vehicle;onClose:()=>void;onComplete:(vehicle:Vehicle)=>void;renderWindow:SensorEnrollmentProps['renderWindow']}){
|
||||||
|
const form=useId();
|
||||||
|
const [revision]=useState(vehicle?.revision);
|
||||||
|
const [code,setCode]=useState(''),[name,setName]=useState(''),[platform,setPlatform]=useState('ugv');
|
||||||
|
const [preview,setPreview]=useState<FleetPreview|null>(null),[pending,setPending]=useState(false),[error,setError]=useState('');
|
||||||
|
async function inspect(event:FormEvent){
|
||||||
|
event.preventDefault();if(pending)return;setPending(true);setError('');
|
||||||
|
try{const value=await fleetRequest<FleetPreview>('/preview','POST',{code:code.trim()});setPreview(value);setName(current=>current||value.name);setCode('');}
|
||||||
|
catch(e){setError(e instanceof Error?e.message:'Не удалось проверить приглашение.');}
|
||||||
|
finally{setPending(false);}
|
||||||
|
}
|
||||||
|
async function attach(){
|
||||||
|
if(!preview||pending)return;setPending(true);setError('');
|
||||||
|
try{
|
||||||
|
const result=vehicle
|
||||||
|
?await fleetRequest<Vehicle>(`/${encodeURIComponent(vehicle.id)}/computer`,'POST',{preview_id:preview.preview_id,expected_revision:revision})
|
||||||
|
:await fleetRequest<Vehicle>('','POST',{preview_id:preview.preview_id,name:name.trim(),platform});
|
||||||
|
onComplete(result);
|
||||||
|
}catch(e){setError(e instanceof Error?e.message:'Не удалось подключить БК.');}
|
||||||
|
finally{setPending(false);}
|
||||||
|
}
|
||||||
|
const replacing=!!vehicle&&vehicle.enrollment!=='revoked';
|
||||||
|
return renderWindow({busy:pending,actions:<><Button disabled={pending} onClick={onClose}>Отмена</Button>{preview?<Button loading={pending} disabled={pending||(!vehicle&&!name.trim())} onClick={()=>void attach()}>{vehicle?(replacing?'Заменить БК':'Подключить БК'):'Добавить аппарат'}</Button>:<Button loading={pending} type="submit" form={form} disabled={pending||!code.trim()}>Проверить БК</Button>}</>,content:
|
||||||
|
<form id={form} className="fleet-form" onSubmit={inspect} aria-busy={pending}>
|
||||||
|
{!vehicle&&<><Select label="Класс аппарата" value={platform} options={platforms} onChange={setPlatform} disabled={pending}/><TextField label="Название аппарата" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={pending} autoComplete="off"/></>}
|
||||||
|
{vehicle&&<SettingsCard title={vehicle.name} description={replacing?'Новый БК заменит текущий. Аппарат, его название и раскладки сохранятся; прежняя привязка будет отозвана.':'Подключите БК к этому аппарату по приглашению Mission Core Node.'}/>}
|
||||||
|
{preview?<SettingsCard title={preview.name} description="Идентичность БК проверена по приглашению"><dl className="fleet-facts"><div><dt>Идентификатор БК</dt><dd>{preview.node_id}</dd></div><div><dt>Система</dt><dd>{preview.host.os} · {preview.host.architecture}</dd></div><div><dt>Адрес БК</dt><dd>{preview.endpoint}</dd></div></dl><Button disabled={pending} onClick={()=>setPreview(null)}>Другое приглашение</Button></SettingsCard>:<TextAreaField label="Код приглашения из Node" value={code} rows={5} maxLength={4096} spellCheck={false} autoComplete="off" disabled={pending} onChange={e=>setCode(e.target.value)}/>}
|
||||||
|
{pending&&<StatusBadge tone="warning">{preview?'Подключение БК':'Проверка приглашения БК'}</StatusBadge>}
|
||||||
|
{error&&<p role="alert">{error}</p>}
|
||||||
|
</form>});
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {useEffect,useSyncExternalStore} from 'react';
|
||||||
|
import {Button,Icon,IconButton,Inspector,LoadingRegion} from '@nodedc/ui-react';
|
||||||
|
import type {BoardLayoutStore} from '../../../../../packages/sensor-ui/src/boardLayout';
|
||||||
|
import type {SensorBoardContent} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||||
|
import type {Vehicle} from '../../core/fleet/useFleet';
|
||||||
|
import {VehicleIdentitySettings} from './VehicleIdentitySettings';
|
||||||
|
import {BoardComputerRow} from './BoardComputer';
|
||||||
|
|
||||||
|
export function VehicleEquipment({vehicle,enabled,layout,content,onAddComputer,onSettings,onMonitor,onUpdated}:{vehicle:Vehicle;enabled:boolean;layout:BoardLayoutStore;content:SensorBoardContent;onAddComputer:()=>void;onSettings:()=>void;onMonitor:()=>void;onUpdated:(vehicle:Vehicle)=>void}){
|
||||||
|
const state=useSyncExternalStore(layout.subscribe,layout.getSnapshot);
|
||||||
|
useEffect(()=>{void layout.load();},[layout]);
|
||||||
|
const computerOpen=state.value.open_sections.includes('computer');
|
||||||
|
// The former computer-section preference now controls its nested device branch.
|
||||||
|
const changeSections=(ids:string[])=>layout.change([...ids,...(computerOpen?['computer']:[])]);
|
||||||
|
const toggleComputer=()=>layout.change(computerOpen?state.value.open_sections.filter(id=>id!=='computer'):[...state.value.open_sections,'computer']);
|
||||||
|
return <div className="sensor-content">
|
||||||
|
{state.error&&<div role="alert"><p>{state.error}</p><Button onClick={()=>void layout.load()}>Повторить</Button></div>}
|
||||||
|
<LoadingRegion loading={!state.ready&&!state.error} label="Загрузка раскладки аппарата">
|
||||||
|
<Inspector variant="panel" openSections={state.value.open_sections.filter(id=>id!=='computer')} onOpenSectionsChange={changeSections} sections={[
|
||||||
|
{id:'settings',label:'Настройки аппарата',icon:<Icon name="sliders"/>,disabled:!state.ready,content:<div className="sensor-content"><VehicleIdentitySettings key={vehicle.id} vehicle={vehicle} onUpdated={onUpdated}/>{content.settings}</div>},
|
||||||
|
{id:'devices',label:'Оборудование',icon:<Icon name="apps"/>,disabled:!state.ready,content:<div className="sensor-content">
|
||||||
|
<div className="sensor-actions sensor-inventory-toolbar"><span className="sensor-note">Бортовой компьютер и подключённые к нему устройства</span><div className="sensor-actions"><IconButton label="Подключить бортовой компьютер" onClick={onAddComputer}><Icon name="plus"/></IconButton><IconButton label="Обновить оборудование" onClick={()=>void content.refresh()}><Icon name="refresh"/></IconButton></div></div>
|
||||||
|
<div><BoardComputerRow vehicle={vehicle} enabled={enabled} showingSavedDevices={content.showingSavedDevices} open={computerOpen} onToggle={toggleComputer} onSettings={onSettings} onMonitor={onMonitor} onAdd={content.canAddDevice?content.addDevice:undefined}/>
|
||||||
|
{computerOpen&&<div className="fleet-computer-devices">{content.devices}</div>}
|
||||||
|
</div>
|
||||||
|
</div>},
|
||||||
|
]}/>
|
||||||
|
</LoadingRegion>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {useEffect,useState} from 'react';
|
||||||
|
import {Button,SettingsCard,TextField} from '@nodedc/ui-react';
|
||||||
|
import {fleetRequest,type Vehicle} from '../../core/fleet/useFleet';
|
||||||
|
|
||||||
|
export function VehicleIdentitySettings({vehicle,onUpdated}:{vehicle:Vehicle;onUpdated:(vehicle:Vehicle)=>void}){
|
||||||
|
const [base,setBase]=useState(vehicle),[name,setName]=useState(vehicle.name);
|
||||||
|
const [saving,setSaving]=useState(false),[message,setMessage]=useState(''),[error,setError]=useState('');
|
||||||
|
const dirty=name!==base.name;
|
||||||
|
useEffect(()=>{if(!dirty&&!saving){setBase(vehicle);setName(vehicle.name);}},[vehicle,dirty,saving]);
|
||||||
|
const reset=()=>{setBase(vehicle);setName(vehicle.name);setError('');setMessage('');};
|
||||||
|
const save=async()=>{
|
||||||
|
if(saving||!name.trim()||!dirty)return;
|
||||||
|
setSaving(true);setError('');setMessage('');
|
||||||
|
try{
|
||||||
|
const updated=await fleetRequest<Vehicle>(`/${encodeURIComponent(vehicle.id)}`,'PATCH',{name:name.trim(),expected_revision:base.revision});
|
||||||
|
onUpdated(updated);setBase(updated);setName(updated.name);setMessage('Название сохранено');
|
||||||
|
}catch(reason){setError(reason instanceof Error?reason.message:'Не удалось сохранить название.');}
|
||||||
|
finally{setSaving(false);}
|
||||||
|
};
|
||||||
|
return <SettingsCard title="Основные параметры">
|
||||||
|
<form className="fleet-form" onSubmit={event=>{event.preventDefault();void save();}}>
|
||||||
|
<TextField label="Название аппарата" value={name} maxLength={80} autoComplete="off" disabled={saving} onChange={event=>{setName(event.target.value);setMessage('');}}/>
|
||||||
|
<div className="sensor-actions"><Button type="submit" disabled={!dirty||!name.trim()||saving}>{saving?'Сохранение…':'Сохранить'}</Button>{dirty&&<Button type="button" variant="secondary" disabled={saving} onClick={reset}>Отмена</Button>}</div>
|
||||||
|
{error&&<p role="alert">{error}</p>}{message&&<p role="status">{message}</p>}
|
||||||
|
</form>
|
||||||
|
</SettingsCard>;
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import {useMemo,type ReactNode} from 'react';
|
import {useMemo} from 'react';
|
||||||
import {boardLayout} from '../../core/fleet/boardLayout';
|
import {boardLayout} from '../../core/fleet/boardLayout';
|
||||||
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
|
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
|
||||||
import {createIsolatedRerunHost} from '../../components/rerun/isolatedRerunHost';
|
import {createIsolatedRerunHost} from '../../components/rerun/isolatedRerunHost';
|
||||||
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||||
import {createFleetSensorTransport} from '../../core/fleet/sensorTransport';
|
import {createFleetSensorTransport} from '../../core/fleet/sensorTransport';
|
||||||
export function VehicleSensors({vehicleID,enabled,computer,description}:{vehicleID:string;enabled:boolean;computer:ReactNode;description:string}){
|
import type {Vehicle} from '../../core/fleet/useFleet';
|
||||||
|
import {VehicleEquipment} from './VehicleEquipment';
|
||||||
|
export function VehicleSensors({vehicle,enabled,onAddComputer,onSettings,onMonitor,onUpdated}:{vehicle:Vehicle;enabled:boolean;onAddComputer:()=>void;onSettings:()=>void;onMonitor:()=>void;onUpdated:(vehicle:Vehicle)=>void}){
|
||||||
|
const vehicleID=vehicle.id;
|
||||||
const {registry}=useDevicePluginHost();
|
const {registry}=useDevicePluginHost();
|
||||||
const sensorContributions=registry.sensorContributions;
|
const sensorContributions=registry.sensorContributions;
|
||||||
const layout=useMemo(()=>boardLayout(vehicleID),[vehicleID]);
|
const layout=useMemo(()=>boardLayout(vehicleID),[vehicleID]);
|
||||||
const transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
const transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
||||||
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={vehicleID} transport={transport} enabled={enabled} board={{layout,computer,description}}/>;
|
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={`${vehicleID}:${vehicle.node_id}`} transport={transport} enabled={enabled} readOfflineInventory renderBoard={content=><VehicleEquipment vehicle={vehicle} enabled={enabled} layout={layout} content={content} onAddComputer={onAddComputer} onSettings={onSettings} onMonitor={onMonitor} onUpdated={onUpdated}/>}/>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +1,41 @@
|
|||||||
|
import {useState} from 'react';
|
||||||
|
import {createPortal} from 'react-dom';
|
||||||
|
import {LoadingRegion,ConfirmationModal,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||||
|
import {WorkspaceBackButton} from '../../components/WorkspaceBackButton';
|
||||||
|
import {fleetRequest,useFleet,type Vehicle} from '../../core/fleet/useFleet';
|
||||||
import {BoardObservationCenter} from './observation/BoardObservationCenter';
|
import {BoardObservationCenter} from './observation/BoardObservationCenter';
|
||||||
import {BoardMonitorView} from './BoardMonitorView';
|
import {BoardMonitorView} from './BoardMonitorView';
|
||||||
import { useEffect, useRef, useState } from "react";
|
import {VehicleSensors} from './VehicleSensors';
|
||||||
import { LoadingRegion, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
|
import {BoardComputerSettings,boardStatus} from './BoardComputer';
|
||||||
import { fleetRequest, useFleet, type FleetPreview, type Vehicle } from "../../core/fleet/useFleet";
|
import {NodeInvitationForm,platformLabel} from './NodeInvitationForm';
|
||||||
import "./fleet.css";
|
import './fleet.css';
|
||||||
import { VehicleSensors } from "./VehicleSensors";
|
|
||||||
|
|
||||||
const platforms = [{ value: "ugv", label: "Наземный (UGV)" }, { value: "uav", label: "Воздушный (UAV)" }, { value: "stationary", label: "Стационарный" }, { value: "other", label: "Другой" }];
|
export function VehiclesWorkspace({headerToolsHost,headerTitleHost}:{headerToolsHost?:HTMLElement|null;headerTitleHost?:HTMLElement|null}){
|
||||||
const platformLabel = (value: string) => platforms.find(item => item.value === value)?.label ?? value;
|
const fleet=useFleet();
|
||||||
function statusLabel(item: Vehicle) { return item.enrollment === "pending" ? "Подтверждаем привязку" : item.enrollment === "revoked" ? "Доверие отозвано" : item.enrollment === "failed" ? "Привязка не завершена" : item.connectivity === "online" ? "В сети" : "Нет связи"; }
|
const [selected,setSelected]=useState<string|null>(null);
|
||||||
|
const [adding,setAdding]=useState(false),[monitorOpen,setMonitorOpen]=useState(false),[observationOpen,setObservationOpen]=useState(false),[settingsOpen,setSettingsOpen]=useState(false);
|
||||||
export function VehiclesWorkspace({ createRequest = 0, headerToolsHost }: { createRequest?: number; headerToolsHost?: HTMLElement|null }) {
|
const [revoking,setRevoking]=useState<Vehicle|null>(null),[error,setError]=useState('');
|
||||||
const fleet = useFleet();
|
const detail=fleet.items?.find(item=>item.id===selected);
|
||||||
const [adding, setAdding] = useState(false);
|
const enabled=!!detail&&!fleet.error&&detail.enrollment==='paired'&&detail.connectivity==='online';
|
||||||
const [code, setCode] = useState("");
|
const refresh=()=>{void fleet.refresh().catch(()=>setError('Не удалось обновить аппарат. Повторите получение сведений.'));};
|
||||||
const [name, setName] = useState("");
|
const complete=(item:Vehicle)=>{setSelected(item.id);setAdding(false);refresh();};
|
||||||
const [platform, setPlatform] = useState("ugv");
|
const addButton=<IconButton label="Добавить аппарат" onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>;
|
||||||
const [preview, setPreview] = useState<FleetPreview | null>(null);
|
const headerAdd=!detail&&(headerToolsHost?createPortal(addButton,headerToolsHost):addButton);
|
||||||
const [pending, setPending] = useState(false);
|
const titleSuffix=detail?` — ${detail.name} / ${platformLabel(detail.platform)}`:'';
|
||||||
const [error, setError] = useState("");
|
const observationButton=<IconButton label="Центр наблюдения и управления" onClick={()=>setObservationOpen(true)}><Icon name="eye"/></IconButton>;
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const headerObservation=detail&&!observationOpen&&!monitorOpen&&(headerToolsHost?createPortal(observationButton,headerToolsHost):observationButton);
|
||||||
const [monitorOpen,setMonitorOpen]=useState(false);
|
const content=detail&&observationOpen?<BoardObservationCenter key={detail.id} vehicleID={detail.id} name={detail.name} enabled={enabled} back={()=>{setObservationOpen(false);setSelected(null);}} configure={()=>setObservationOpen(false)} headerToolsHost={headerToolsHost}/>
|
||||||
const [observationOpen,setObservationOpen]=useState(false);
|
:detail&&monitorOpen?<BoardMonitorView vehicle={detail.id} name={detail.name} back={()=>setMonitorOpen(false)} headerToolsHost={headerToolsHost}/>
|
||||||
const [revoking, setRevoking] = useState<Vehicle | null>(null);
|
:<div className="fleet-workspace">
|
||||||
const lastCreateRequest = useRef(createRequest);
|
{detail?<>
|
||||||
useEffect(() => { if (createRequest !== lastCreateRequest.current) { lastCreateRequest.current = createRequest; setAdding(true); setError(""); } }, [createRequest]);
|
<WorkspaceBackButton label="К списку аппаратов" onClick={()=>setSelected(null)} host={headerToolsHost}/>
|
||||||
function close() { if (pending) return; setAdding(false); setPreview(null); setCode(""); setName(""); setError(""); }
|
<VehicleSensors onUpdated={fleet.acceptVehicle} vehicle={detail} enabled={enabled} onAddComputer={()=>setAdding(true)} onSettings={()=>setSettingsOpen(true)} onMonitor={()=>setMonitorOpen(true)}/>
|
||||||
async function inspect(event: React.FormEvent) {
|
</>:!fleet.items?<LoadingRegion loading={!fleet.error} label="Получение аппаратов"/>:fleet.items.length===0?<SettingsCard title="Аппаратов пока нет" description="Создайте аппарат и подключите его бортовой компьютер по приглашению Mission Core Node."/>:<ResourceList aria-label="Аппараты">{fleet.items.map(item=><li key={item.id}><ResourceRow icon={<Icon name="apps"/>} title={item.name} description={platformLabel(item.platform)} status={<StatusBadge tone={!fleet.error&&item.enrollment==='paired'&&item.connectivity==='online'?'success':'neutral'}>{fleet.error?'Нет свежих данных':`БК: ${boardStatus(item)}`}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={()=>setSelected(item.id)}><Icon name="sliders"/></IconButton><IconButton label={`Центр наблюдения и управления: ${item.name}`} onClick={()=>{setSelected(item.id);setObservationOpen(true);}}><Icon name="eye"/></IconButton></>}/></li>)}</ResourceList>}
|
||||||
event.preventDefault(); if (pending) return;
|
|
||||||
setPending(true); setError("");
|
|
||||||
try { const value = await fleetRequest<FleetPreview>("/preview", "POST", { code: code.trim() }); setPreview(value); setName(current => current || value.name); setCode(""); }
|
|
||||||
catch (error) { setError(error instanceof Error ? error.message : "Не удалось проверить приглашение."); }
|
|
||||||
finally { setPending(false); }
|
|
||||||
}
|
|
||||||
async function add() {
|
|
||||||
if (!preview || pending) return;
|
|
||||||
setPending(true); setError("");
|
|
||||||
try {
|
|
||||||
const item = await fleetRequest<Vehicle>("", "POST", { preview_id: preview.preview_id, name: name.trim(), platform });
|
|
||||||
await fleet.refresh(); setSelected(item.id); setAdding(false); setPreview(null); setCode(""); setName("");
|
|
||||||
} catch (error) { setError(error instanceof Error ? error.message : "Не удалось добавить аппарат."); void fleet.refresh().catch(() => undefined); }
|
|
||||||
finally { setPending(false); }
|
|
||||||
}
|
|
||||||
const detail = fleet.items?.find(item => item.id === selected);
|
|
||||||
if(detail&&observationOpen)return <BoardObservationCenter key={detail.id} vehicleID={detail.id} name={detail.name} enabled={!fleet.error&&detail.enrollment==="paired"&&detail.connectivity==="online"} back={()=>{setObservationOpen(false);setSelected(null);}} configure={()=>setObservationOpen(false)} headerToolsHost={headerToolsHost}/>;
|
|
||||||
if(detail&&monitorOpen)return <BoardMonitorView vehicle={detail.id} name={detail.name} back={()=>setMonitorOpen(false)}/>;
|
|
||||||
return <div className="fleet-workspace">
|
|
||||||
{fleet.error && <p role="alert">{fleet.error}</p>}
|
|
||||||
{!adding && error && <p role="alert">{error}</p>}
|
|
||||||
{detail ? <>
|
|
||||||
<div><Button onClick={() => {setSelected(null);}}>К списку аппаратов</Button></div>
|
|
||||||
<VehicleSensors vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} description={detail.name} computer={<SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
|
|
||||||
{detail.notice && <p role="status">{detail.notice}</p>}
|
|
||||||
<dl className="fleet-facts"><div><dt>Бортовой компьютер</dt><dd>{detail.node_id}</dd></div>
|
|
||||||
<div><dt>Последняя связь</dt><dd>{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}</dd></div>
|
|
||||||
{detail.host && <><div><dt>Имя БК в системе</dt><dd>{detail.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{detail.host.os}</dd></div><div><dt>Архитектура</dt><dd>{detail.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{detail.host.cpus}</dd></div><div><dt>Память</dt><dd>{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}</dd></div></>}
|
|
||||||
</dl>
|
|
||||||
<div className="fleet-board-actions"><Button onClick={()=>setMonitorOpen(true)}><Icon name="activity"/>Мониторинг системы БК</Button>
|
|
||||||
<Button onClick={()=>setObservationOpen(true)}><Icon name="eye"/>Центр наблюдения и управления</Button>
|
|
||||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}</div>
|
|
||||||
</SettingsCard>}/>
|
|
||||||
</> : !fleet.items ? <LoadingRegion loading label="Получение аппаратов" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="sliders" /></IconButton><IconButton label={`Центр наблюдения и управления: ${item.name}`} onClick={() => {setSelected(item.id);setObservationOpen(true);}}><Icon name="eye" /></IconButton></>} /></li>)}</ResourceList>}
|
|
||||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавление…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверка БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
|
||||||
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
|
|
||||||
<Select label="Способ подключения" value="node" options={[{ value: "node", label: "С бортовым компьютером" }]} onChange={() => undefined} disabled={pending} />
|
|
||||||
<Select label="Класс аппарата" value={platform} options={platforms} onChange={setPlatform} disabled={pending} />
|
|
||||||
<TextField label="Название аппарата" value={name} maxLength={80} onChange={event => setName(event.target.value)} disabled={pending} autoComplete="off" />
|
|
||||||
{preview ? <SettingsCard title={preview.name} description="Идентичность БК проверена по приглашению"><dl className="fleet-facts"><div><dt>Идентификатор БК</dt><dd>{preview.node_id}</dd></div><div><dt>Система</dt><dd>{preview.host.os} · {preview.host.architecture}</dd></div><div><dt>Адрес БК</dt><dd>{preview.endpoint}</dd></div></dl><Button disabled={pending} onClick={() => setPreview(null)}>Другое приглашение</Button></SettingsCard> : <TextAreaField label="Код приглашения из Node" value={code} rows={5} maxLength={4096} spellCheck={false} autoComplete="off" disabled={pending} onChange={event => setCode(event.target.value)} />}
|
|
||||||
{error && <p role="alert">{error}</p>}
|
|
||||||
</form>
|
|
||||||
</Window>
|
|
||||||
<ConfirmationModal open={revoking !== null} title="Отозвать привязку БК?" description={`Аппарат «${revoking?.name ?? ""}» останется в реестре, а его БК потеряет доступ к Core. БК получит отзыв при следующем соединении.`} confirmLabel="Отозвать" cancelLabel="Отмена" danger onClose={() => setRevoking(null)} onConfirm={async () => { if (!revoking) return; try { await fleetRequest(`/${encodeURIComponent(revoking.id)}`, "DELETE"); await fleet.refresh(); setRevoking(null); } catch (error) { setError(error instanceof Error ? error.message : "Не удалось отозвать привязку."); throw error; } }} />
|
|
||||||
</div>;
|
</div>;
|
||||||
|
return <>{headerTitleHost&&createPortal(titleSuffix,headerTitleHost)}{headerAdd}{headerObservation}{content}
|
||||||
|
{adding&&<NodeInvitationForm key={detail?.id??'new'} vehicle={detail} onClose={()=>setAdding(false)} onComplete={complete} renderWindow={({content,actions,busy})=><Window open title={detail?'Подключить бортовой компьютер':'Добавить аппарат'} subtitle={detail?detail.name:'Создать аппарат и подключить БК по приглашению Node'} size="md" closeOnBackdrop={false} closeOnEscape={!busy} onClose={()=>{if(!busy)setAdding(false);}} footer={<WindowFooterActions>{actions}</WindowFooterActions>}>{content}</Window>}/>}
|
||||||
|
{settingsOpen&&detail&&<BoardComputerSettings vehicle={detail} enabled={enabled} onClose={()=>setSettingsOpen(false)} onRevoke={()=>{setSettingsOpen(false);setRevoking(detail);}}/>}
|
||||||
|
<ConfirmationModal open={revoking!==null} title="Отозвать привязку БК?" description={`Аппарат «${revoking?.name??''}» останется в реестре. БК потеряет доступ к Core и получит отзыв при следующем соединении.`} confirmLabel="Отозвать" cancelLabel="Отмена" danger onClose={()=>setRevoking(null)} onConfirm={async()=>{if(!revoking)return;try{await fleetRequest(`/${encodeURIComponent(revoking.id)}`,'DELETE');await fleet.refresh();setRevoking(null);}catch(e){setError(e instanceof Error?e.message:'Не удалось отозвать привязку.');throw e;}}}/>
|
||||||
|
<ToastStack items={error||fleet.error?[{id:'fleet-error',title:error||fleet.error,tone:'error',durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||||
|
</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
.fleet-workspace, .fleet-form { display: flex; flex-direction: column; gap: var(--nodedc-space-4); }
|
.fleet-workspace, .fleet-form { display: flex; flex-direction: column; gap: var(--nodedc-space-4); }
|
||||||
.fleet-workspace { padding: var(--nodedc-space-4); }
|
.fleet-workspace { padding: var(--nodedc-space-4); }
|
||||||
|
.fleet-header-tools { display:flex; align-items:center; gap:var(--nodedc-space-2); }
|
||||||
|
.nodedc-application-panel__titles h1 > .fleet-header-title { font:inherit; color:inherit; letter-spacing:inherit; text-transform:none; }
|
||||||
.fleet-facts { display: grid; gap: var(--nodedc-space-3); }
|
.fleet-facts { display: grid; gap: var(--nodedc-space-3); }
|
||||||
.fleet-facts > div { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr); gap: var(--nodedc-space-3); }
|
.fleet-facts > div { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr); gap: var(--nodedc-space-3); }
|
||||||
.fleet-facts dd { margin: 0; overflow-wrap: anywhere; }
|
.fleet-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
@@ -10,4 +12,6 @@
|
|||||||
|
|
||||||
.fleet-board-actions { display:flex; gap:var(--nodedc-space-3); }
|
.fleet-board-actions { display:flex; gap:var(--nodedc-space-3); }
|
||||||
.fleet-board-actions > * { flex:1; }
|
.fleet-board-actions > * { flex:1; }
|
||||||
|
.fleet-computer-devices { padding-block-start:var(--nodedc-space-4); padding-inline-start:var(--nodedc-space-4); display:grid; gap:var(--nodedc-space-4); }
|
||||||
|
.fleet-form p { font-size:var(--nodedc-font-size-sm); line-height:1.5; color:var(--nodedc-text-secondary); }
|
||||||
@media(max-width:600px) { .fleet-board-actions { flex-wrap:wrap; } }
|
@media(max-width:600px) { .fleet-board-actions { flex-wrap:wrap; } }
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {decodeObservationLayout,emptyObservationLayout,moveObservation,orderedOb
|
|||||||
import {createIsolatedRerunHost} from '../../../components/rerun/isolatedRerunHost';
|
import {createIsolatedRerunHost} from '../../../components/rerun/isolatedRerunHost';
|
||||||
import {CameraObservation} from '../../../../../../packages/sensor-ui/src/CameraObservation';
|
import {CameraObservation} from '../../../../../../packages/sensor-ui/src/CameraObservation';
|
||||||
import {cameraObservationViews,observationKey,type SensorObservationView,type ObservationHeaderTargets} from '../../../../../../packages/sensor-ui/src/observation';
|
import {cameraObservationViews,observationKey,type SensorObservationView,type ObservationHeaderTargets} from '../../../../../../packages/sensor-ui/src/observation';
|
||||||
|
import {unboundObservationViews} from '../../../../../../packages/sensor-ui/src/observationCatalog';
|
||||||
|
import {sensorContribution} from '../../../../../../packages/sensor-ui/src/extensions';
|
||||||
|
import {ObservationDeviceSettings} from './ObservationDeviceSettings';
|
||||||
|
import {ObservationPanelHeader} from './ObservationPanelHeader';
|
||||||
import {ObservationDeck,ObservationMount} from './ObservationDeck';
|
import {ObservationDeck,ObservationMount} from './ObservationDeck';
|
||||||
import {ObservationMap} from './ObservationMap';
|
import {ObservationMap} from './ObservationMap';
|
||||||
import {useObservationInventory} from './useObservationInventory';
|
import {useObservationInventory} from './useObservationInventory';
|
||||||
@@ -19,62 +23,66 @@ export function BoardObservationCenter({vehicleID,name,enabled,back,configure,he
|
|||||||
const {registry}=useDevicePluginHost(),transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
const {registry}=useDevicePluginHost(),transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
||||||
const inventory=useObservationInventory(transport),storageKey=`missioncore.observation.v1:${vehicleID}`;
|
const inventory=useObservationInventory(transport),storageKey=`missioncore.observation.v1:${vehicleID}`;
|
||||||
const [layout,setLayout]=useState(()=>{try{return decodeObservationLayout(JSON.parse(localStorage.getItem(storageKey)??'null'));}catch{return emptyObservationLayout();}});
|
const [layout,setLayout]=useState(()=>{try{return decodeObservationLayout(JSON.parse(localStorage.getItem(storageKey)??'null'));}catch{return emptyObservationLayout();}});
|
||||||
const [layersOpen,setLayersOpen]=useState(false),[focused,setFocused]=useState<string|null>(null);
|
const [settingsDevice,setSettingsDevice]=useState<string|null>(null);
|
||||||
|
const [layersOpen,setLayersOpen]=useState(false);
|
||||||
const [full,setFull]=useState<string|null>(null),[expanded,setExpanded]=useState(false);
|
const [full,setFull]=useState<string|null>(null),[expanded,setExpanded]=useState(false);
|
||||||
const frame=useRef<HTMLDivElement>(null),[portal]=useState(()=>document.createElement('div'));
|
const frame=useRef<HTMLDivElement>(null),[portal]=useState(()=>document.createElement('div'));
|
||||||
useLayoutEffect(()=>{(expanded?document.body:frame.current!).append(portal);return()=>portal.remove();},[expanded,portal]);
|
useLayoutEffect(()=>{(expanded?document.body:frame.current!).append(portal);return()=>portal.remove();},[expanded,portal]);
|
||||||
const mounts=useRef(new Map<string,HTMLElement>()),media=useRef(new Map<string,HTMLElement>()),headers=useRef(new Map<string,ObservationHeaderTargets>());
|
const mounts=useRef(new Map<string,HTMLElement>()),media=useRef(new Map<string,HTMLElement>()),headers=useRef(new Map<string,ObservationHeaderTargets>());
|
||||||
const groups=(inventory?.items??[]).flatMap(device=>{
|
const groups=(inventory?.items??[]).flatMap(device=>{
|
||||||
const contribution=registry.sensorContributions.find(item=>item.kind===device.kind)?.observation;
|
const contribution=sensorContribution(registry.sensorContributions,device)?.observation;
|
||||||
const views=contribution?.views(device)??cameraObservationViews(device);
|
const views=contribution?.views(device)??cameraObservationViews(device);
|
||||||
return views.length?[{device,views,Session:contribution?.Session??CameraObservation}]:[];
|
return views.length?[{device,views,Session:contribution?.Session??CameraObservation}]:[];
|
||||||
});
|
});
|
||||||
const sources:{key:string;deviceID?:string;view:SensorObservationView}[]=groups.flatMap(group=>group.views.map(view=>({key:observationKey(group.device.id,view.id),deviceID:group.device.id,view})));
|
const sources:{key:string;deviceID?:string;catalog?:string;view:SensorObservationView}[]=groups.flatMap(group=>group.views.map(view=>({key:observationKey(group.device.id,view.id),deviceID:group.device.id,view})));
|
||||||
|
sources.push(...unboundObservationViews(inventory?.items??[],registry.sensorContributions));
|
||||||
sources.push({key:'map',view:{id:'map',label:'Карта'}},{key:'rover',view:{id:'rover',label:'3D View аппарата'}},{key:'telemetry',view:{id:'telemetry',label:'Телеметрия'}});
|
sources.push({key:'map',view:{id:'map',label:'Карта'}},{key:'rover',view:{id:'rover',label:'3D View аппарата'}},{key:'telemetry',view:{id:'telemetry',label:'Телеметрия'}});
|
||||||
for(const source of sources){if(!mounts.current.has(source.key)){mounts.current.set(source.key,document.createElement('div'));media.current.set(source.key,document.createElement('div'));headers.current.set(source.key,{statusTarget:document.createElement('div'),actionsTarget:document.createElement('div')});mounts.current.get(source.key)!.className='observation-panel-mount';media.current.get(source.key)!.className='observation-media-mount';}}
|
for(const source of sources){if(!mounts.current.has(source.key)){mounts.current.set(source.key,document.createElement('div'));media.current.set(source.key,document.createElement('div'));headers.current.set(source.key,{statusTarget:document.createElement('div'),actionsTarget:document.createElement('div')});mounts.current.get(source.key)!.className='observation-panel-mount';media.current.get(source.key)!.className='observation-media-mount';}}
|
||||||
const ids=orderedObservationIDs(layout,sources.map(source=>source.key)),visible=ids.filter(id=>!layout.hidden.includes(id));
|
const ids=orderedObservationIDs(layout,sources.map(source=>source.key)),visible=ids.filter(id=>!layout.hidden.includes(id)&&(!sources.find(source=>source.key===id)?.catalog||layout.catalogVisible?.includes(id)));
|
||||||
const effectiveFull=full&&visible.includes(full)?full:null;
|
const effectiveFull=full&&visible.includes(full)?full:null;
|
||||||
const rover=useRoverControl(vehicleID,enabled&&(visible.includes('rover')||visible.includes('telemetry')));
|
const rover=useRoverControl(vehicleID,enabled&&(visible.includes('rover')||visible.includes('telemetry')));
|
||||||
const drivingVisible=visible.includes('rover')&&(!effectiveFull||effectiveFull==='rover')&&!layersOpen;
|
const drivingVisible=visible.includes('rover')&&(!effectiveFull||effectiveFull==='rover')&&!layersOpen&&!settingsDevice;
|
||||||
useEffect(()=>{if(!drivingVisible)rover.stop();},[drivingVisible,rover.stop]);
|
useEffect(()=>{if(!drivingVisible)rover.stop();},[drivingVisible,rover.stop]);
|
||||||
const active=sources.find(source=>source.key===focused);
|
const configuredDevice=inventory?.items.find(device=>device.id===settingsDevice);
|
||||||
useEffect(()=>{try{localStorage.setItem(storageKey,JSON.stringify(layout));}catch{/* The workspace remains usable without browser storage. */}},[storageKey,layout]);
|
useEffect(()=>{try{localStorage.setItem(storageKey,JSON.stringify(layout));}catch{/* The workspace remains usable without browser storage. */}},[storageKey,layout]);
|
||||||
useEffect(()=>{const escape=(event:KeyboardEvent)=>{if(event.key!=='Escape'||layersOpen||document.querySelector('[role=dialog], [role=listbox]'))return;if(effectiveFull){setFull(null);event.stopImmediatePropagation();}else if(expanded){setExpanded(false);event.stopImmediatePropagation();}};window.addEventListener('keydown',escape,true);return()=>window.removeEventListener('keydown',escape,true);},[effectiveFull,expanded,layersOpen]);
|
useEffect(()=>{const escape=(event:KeyboardEvent)=>{if(event.key!=='Escape'||layersOpen||document.querySelector('[role=dialog], [role=listbox]'))return;if(effectiveFull){setFull(null);event.stopImmediatePropagation();}else if(expanded){setExpanded(false);event.stopImmediatePropagation();}};window.addEventListener('keydown',escape,true);return()=>window.removeEventListener('keydown',escape,true);},[effectiveFull,expanded,layersOpen]);
|
||||||
const setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,hidden:show?value.hidden.filter(key=>key!==id):[...new Set([...value.hidden,id])]}));
|
const setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,catalogVisible:show?[...new Set([...(value.catalogVisible??[]),...(sources.find(source=>source.key===id)?.catalog?[id]:[])])]:(value.catalogVisible??[]).filter(key=>key!==id),hidden:show?value.hidden.filter(key=>key!==id):[...new Set([...value.hidden,id])]}));
|
||||||
const layerFor=(key:string,view:SensorObservationView)=>view.layers?.find(layer=>layer.value===layout.layers[key])?.value??view.layers?.[0]?.value??view.id;
|
const layerFor=(key:string,view:SensorObservationView)=>view.layers?.find(layer=>layer.value===layout.layers[key])?.value??view.layers?.[0]?.value??view.id;
|
||||||
return <>{headerToolsHost&&createPortal(<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>,headerToolsHost)}<div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения и управления · ${name}`}>
|
const navigationActions=<><IconButton label="Конфигуратор" onClick={configure}><Icon name="sliders"/></IconButton><IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton></>;
|
||||||
<header className="observation-toolbar"><div><h2>Центр наблюдения и управления</h2><span>{name}</span></div><div className="observation-actions">{(!headerToolsHost||expanded)&&<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>}<Button onClick={configure}><Icon name="sliders"/>Конфигуратор</Button><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения и управления'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
|
return <>{headerToolsHost&&!expanded&&createPortal(navigationActions,headerToolsHost)}<div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения и управления · ${name}`}>
|
||||||
|
<header className="observation-toolbar"><div><h2>Центр наблюдения и управления</h2><span>{name}</span></div><div className="observation-actions">{(!headerToolsHost||expanded)&&navigationActions}<Button onClick={()=>{setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения и управления'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
|
||||||
<div className="observation-summary"><StatusBadge tone={enabled&&inventory&&inventory.fresh!==false?'success':'neutral'}>{!inventory?'Получение источников':!enabled||inventory.fresh===false?'Нет свежих данных с БК':`Устройства: ${groups.length} · Окна: ${visible.length}`}</StatusBadge>{effectiveFull&&<Button onClick={()=>setFull(null)}>Все окна</Button>}</div>
|
<div className="observation-summary"><StatusBadge tone={enabled&&inventory&&inventory.fresh!==false?'success':'neutral'}>{!inventory?'Получение источников':!enabled||inventory.fresh===false?'Нет свежих данных с БК':`Устройства: ${groups.length} · Окна: ${visible.length}`}</StatusBadge>{effectiveFull&&<Button onClick={()=>setFull(null)}>Все окна</Button>}</div>
|
||||||
<DragDropRoot onDragEnd={({activeId,overId})=>{if(overId)setLayout(value=>moveObservation(value,ids,activeId,overId));}}>
|
<DragDropRoot onDragEnd={({activeId,overId})=>{if(overId)setLayout(value=>moveObservation(value,ids,activeId,overId));}}>
|
||||||
<div className="observation-deck">{!inventory?<LoadingRegion loading label="Получение визуальных источников"/>:visible.length?<ObservationDeck ids={effectiveFull?[effectiveFull]:visible} mounts={mounts.current} layout={layout} onSplit={(key,value)=>setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:<div className="observation-empty"><p>Все окна скрыты.</p><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}>Доступные слои</Button></div>}</div>
|
<div className="observation-deck">{!inventory?<LoadingRegion loading label="Получение визуальных источников"/>:visible.length?<ObservationDeck ids={effectiveFull?[effectiveFull]:visible} mounts={mounts.current} layout={layout} onSplit={(key,value)=>setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:<div className="observation-empty"><p>Все окна скрыты.</p><Button onClick={()=>{setLayersOpen(true);}}>Доступные слои</Button></div>}</div>
|
||||||
{sources.filter(source=>visible.includes(source.key)).map(source=>createPortal(
|
{sources.filter(source=>visible.includes(source.key)).map(source=>createPortal(
|
||||||
<DropZone id={source.key} className="observation-panel">
|
<DropZone id={source.key} className="observation-panel">
|
||||||
<DraggableItem id={source.key} className="observation-panel-drag">{({handle})=>
|
<DraggableItem id={source.key} className="observation-panel-drag">{({handle})=>
|
||||||
<GlassSurface tone="soft" radius="panel" padding="none" materialRim={false} className="observation-panel__surface">
|
<GlassSurface tone="soft" radius="panel" padding="none" materialRim={false} className="observation-panel__surface observation-panel__surface--overlay">
|
||||||
<header className="observation-panel__header">
|
<ObservationPanelHeader>
|
||||||
{handle}
|
{handle}
|
||||||
<div className="observation-panel__title"><h3 title={source.view.label}>{source.view.label}</h3><ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.statusTarget}/></div>
|
<div className="observation-panel__title"><h3 title={source.view.label}>{source.view.label}</h3><ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.statusTarget}/></div>
|
||||||
<div className="observation-actions">
|
<div className="observation-actions">
|
||||||
{source.view.layers&&<Select className="observation-layer-select" label={`Слой · ${source.view.label}`} value={layerFor(source.key,source.view)} options={source.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[source.key]:value}}))} variant="inline"/>}
|
{source.view.layers&&<Select className="observation-layer-select" label={`Слой · ${source.view.label}`} value={layerFor(source.key,source.view)} options={source.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[source.key]:value}}))} variant="inline"/>}
|
||||||
<ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.actionsTarget}/>
|
<ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.actionsTarget}/>
|
||||||
{source.deviceID&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>{setFocused(source.key);setLayersOpen(true);}}><Icon name="settings"/></IconButton>}
|
{source.deviceID&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>setSettingsDevice(source.deviceID!)}><Icon name="settings"/></IconButton>}
|
||||||
<IconButton label={`${effectiveFull===source.key?'Восстановить':'Развернуть'} · ${source.view.label}`} onClick={()=>setFull(value=>value===source.key?null:source.key)}><Icon name={effectiveFull===source.key?'minimize':'expand'}/></IconButton>
|
<IconButton label={`${effectiveFull===source.key?'Восстановить':'Развернуть'} · ${source.view.label}`} onClick={()=>setFull(value=>value===source.key?null:source.key)}><Icon name={effectiveFull===source.key?'minimize':'expand'}/></IconButton>
|
||||||
<IconButton label={`Скрыть · ${source.view.label}`} onClick={()=>setVisible(source.key,false)}><Icon name="close"/></IconButton>
|
<IconButton label={`Скрыть · ${source.view.label}`} onClick={()=>setVisible(source.key,false)}><Icon name="close"/></IconButton>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</ObservationPanelHeader>
|
||||||
<ObservationMount element={media.current.get(source.key)!}/>
|
<ObservationMount element={media.current.get(source.key)!}/>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
}</DraggableItem>
|
}</DraggableItem>
|
||||||
</DropZone>,mounts.current.get(source.key)!,source.key))}
|
</DropZone>,mounts.current.get(source.key)!,source.key))}
|
||||||
</DragDropRoot>
|
</DragDropRoot>
|
||||||
{groups.map(({device,views,Session})=>visible.some(key=>views.some(view=>key===observationKey(device.id,view.id)))&&<Session key={device.id} device={device} transport={transport} enabled={enabled&&inventory?.fresh!==false&&device.online} createRerunHost={createIsolatedRerunHost} views={views.map(view=>({id:view.id,layer:layerFor(observationKey(device.id,view.id),view),target:media.current.get(observationKey(device.id,view.id))!,...headers.current.get(observationKey(device.id,view.id))!}))}/>)}
|
{groups.map(({device,views,Session})=>visible.some(key=>views.some(view=>key===observationKey(device.id,view.id)))&&<Session key={device.id} device={device} transport={transport} enabled={enabled&&inventory?.fresh!==false&&device.online} createRerunHost={createIsolatedRerunHost} views={views.map(view=>({id:view.id,layer:layerFor(observationKey(device.id,view.id),view),target:media.current.get(observationKey(device.id,view.id))!,...headers.current.get(observationKey(device.id,view.id))!}))}/>)}
|
||||||
|
{sources.filter(source=>source.catalog&&visible.includes(source.key)).map(source=>createPortal(<div className="observation-source"><div className="observation-source__notice"><p>{source.catalog} не подключён к аппарату.</p><Button onClick={configure}>Подключить в конфигураторе</Button></div></div>,media.current.get(source.key)!,source.key))}
|
||||||
|
{configuredDevice&&<ObservationDeviceSettings key={configuredDevice.id} device={configuredDevice} transport={transport} contributions={registry.sensorContributions} enabled={enabled&&inventory?.fresh!==false&&configuredDevice.online} close={()=>setSettingsDevice(null)} configure={configure}/>}
|
||||||
{visible.includes('map')&&createPortal(<ObservationMap vehicleID={vehicleID} header={headers.current.get('map')!}/>,media.current.get('map')!,'map-session')}
|
{visible.includes('map')&&createPortal(<ObservationMap vehicleID={vehicleID} header={headers.current.get('map')!}/>,media.current.get('map')!,'map-session')}
|
||||||
{visible.includes('rover')&&createPortal(<RoverView vehicleID={vehicleID} controller={rover} header={headers.current.get('rover')!} active={drivingVisible}/>,media.current.get('rover')!,'rover-session')}
|
{visible.includes('rover')&&createPortal(<RoverView vehicleID={vehicleID} controller={rover} header={headers.current.get('rover')!} active={drivingVisible}/>,media.current.get('rover')!,'rover-session')}
|
||||||
{visible.includes('telemetry')&&createPortal(<RoverTelemetry state={rover.state}/>,media.current.get('telemetry')!,'telemetry-session')}
|
{visible.includes('telemetry')&&createPortal(<RoverTelemetry state={rover.state}/>,media.current.get('telemetry')!,'telemetry-session')}
|
||||||
<Window open={layersOpen} onClose={()=>setLayersOpen(false)} title={active?active.view.label:'Доступные слои'} size="md"><div className="observation-settings">
|
<Window open={layersOpen} onClose={()=>setLayersOpen(false)} title="Доступные слои" size="md"><div className="observation-settings">
|
||||||
{!active&&<Select label="Расположение окон" value={layout.arrangement} options={[{value:'auto',label:'Автоматически'},{value:'columns',label:'В ряд'},{value:'rows',label:'Друг под другом'}]} onChange={value=>setLayout(current=>({...current,arrangement:value as typeof layout.arrangement}))}/>}
|
<Select label="Расположение окон" value={layout.arrangement} options={[{value:'auto',label:'Автоматически'},{value:'columns',label:'В ряд'},{value:'rows',label:'Друг под другом'}]} onChange={value=>setLayout(current=>({...current,arrangement:value as typeof layout.arrangement}))}/>
|
||||||
{ids.filter(id=>!active||id===active.key).map(id=>{const source=sources.find(item=>item.key===id)!;return <div className="observation-layer" key={id}><Switch label={source.view.label} checked={visible.includes(id)} onChange={value=>setVisible(id,value)}/><Select label={`Позиция · ${source.view.label}`} value={String(ids.indexOf(id))} options={ids.map((_,i)=>({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/></div>;})}
|
{ids.map(id=>{const source=sources.find(item=>item.key===id)!;return <div className="observation-layer" key={id}><Switch label={source.catalog?`${source.view.label} · Источник не подключён`:source.view.label} checked={visible.includes(id)} onChange={value=>setVisible(id,value)}/><Select label={`Позиция · ${source.view.label}`} value={String(ids.indexOf(id))} options={ids.map((_,i)=>({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/></div>;})}
|
||||||
{active?.view.layers&&<Select label="Слой источника" value={layerFor(active.key,active.view)} options={active.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[active.key]:value}}))}/>}
|
|
||||||
<Button onClick={()=>{setLayout(emptyObservationLayout());setFull(null);}}>Восстановить расположение</Button>
|
<Button onClick={()=>{setLayout(emptyObservationLayout());setFull(null);}}>Восстановить расположение</Button>
|
||||||
</div></Window>
|
</div></Window>
|
||||||
</section>,portal)}</>;
|
</section>,portal)}</>;
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ export function ObservationDeck({ids,mounts,layout,onSplit,depth=0}:{ids:string[
|
|||||||
const defaultColumns=depth===0&&layout.arrangement==='auto'&&!layout.order.some(id=>id==='rover'||id==='telemetry')&&ids.some(id=>!['map','rover','telemetry'].includes(id));
|
const defaultColumns=depth===0&&layout.arrangement==='auto'&&!layout.order.some(id=>id==='rover'||id==='telemetry')&&ids.some(id=>!['map','rover','telemetry'].includes(id));
|
||||||
const cameraCount=ids.filter(id=>!['map','rover','telemetry'].includes(id)).length;
|
const cameraCount=ids.filter(id=>!['map','rover','telemetry'].includes(id)).length;
|
||||||
const mid=defaultColumns&&cameraCount<ids.length?cameraCount:layout.arrangement==='auto'&&ids[0]==='map'&&ids.includes('rover')?1:Math.ceil(ids.length/2),key=JSON.stringify(ids),orientation=layout.arrangement==='rows'?'horizontal':layout.arrangement==='columns'?'vertical':depth%2?'horizontal':'vertical';
|
const mid=defaultColumns&&cameraCount<ids.length?cameraCount:layout.arrangement==='auto'&&ids[0]==='map'&&ids.includes('rover')?1:Math.ceil(ids.length/2),key=JSON.stringify(ids),orientation=layout.arrangement==='rows'?'horizontal':layout.arrangement==='columns'?'vertical':depth%2?'horizontal':'vertical';
|
||||||
return <SplitPane className="observation-split" orientation={orientation} primarySize={layout.splits[key]??50} onPrimarySizeChange={value=>onSplit(key,value)} separatorLabel="Изменить размеры окон" primary={<ObservationDeck ids={ids.slice(0,mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>} secondary={<ObservationDeck ids={ids.slice(mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>}/>;
|
return <SplitPane separatorAppearance="invisible" className="observation-split" orientation={orientation} primarySize={layout.splits[key]??50} onPrimarySizeChange={value=>onSplit(key,value)} separatorLabel="Изменить размеры окон" primary={<ObservationDeck ids={ids.slice(0,mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>} secondary={<ObservationDeck ids={ids.slice(mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>}/>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {useState} from 'react';
|
||||||
|
import {Button,Window} from '@nodedc/ui-react';
|
||||||
|
import {SensorDetail} from '../../../../../../packages/sensor-ui/src/SensorDetail';
|
||||||
|
import {sensorContribution,type SensorUiContribution} from '../../../../../../packages/sensor-ui/src/extensions';
|
||||||
|
import type {Sensor,SensorTransport} from '../../../../../../packages/sensor-ui/src/contracts';
|
||||||
|
import {createIsolatedRerunHost} from '../../../components/rerun/isolatedRerunHost';
|
||||||
|
|
||||||
|
export function ObservationDeviceSettings({device,transport,contributions,enabled,close,configure}:{device:Sensor;transport:SensorTransport;contributions:readonly SensorUiContribution[];enabled:boolean;close:()=>void;configure:()=>void}) {
|
||||||
|
const [error,setError]=useState('');
|
||||||
|
const Detail=sensorContribution(contributions,device)?.Detail??SensorDetail;
|
||||||
|
return <Window open title={`Настройки · ${device.name}`} size="lg" placement="end" draggable closeOnBackdrop={false} lockBodyScroll={false} trapFocus={false} onClose={close}>
|
||||||
|
{!enabled&&<p>Нет связи с устройством. Настройки оборудования будут доступны после подключения.</p>}
|
||||||
|
{!device.prepared&&<Button onClick={configure}>Подготовить устройство в конфигураторе</Button>}
|
||||||
|
{error&&<p role="alert">{error}</p>}
|
||||||
|
<Detail device={device} transport={transport} enabled={enabled} back={close} refresh={async()=>{await transport.inventory();}} failure={reason=>setError(reason instanceof Error?reason.message:reason?String(reason):'')} createRerunHost={createIsolatedRerunHost}/>
|
||||||
|
</Window>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import {useLayoutEffect,useRef,type ReactNode} from 'react';
|
||||||
|
|
||||||
|
/** Reserve the actual overlay height for text/status content, including wrapped controls. */
|
||||||
|
export function ObservationPanelHeader({children}:{children:ReactNode}) {
|
||||||
|
const ref=useRef<HTMLElement>(null);
|
||||||
|
useLayoutEffect(()=>{
|
||||||
|
const header=ref.current!;
|
||||||
|
const measure=()=>header.parentElement?.style.setProperty('--observation-overlay-header-space',`${header.getBoundingClientRect().height}px`);
|
||||||
|
measure();const observer=new ResizeObserver(measure);observer.observe(header);
|
||||||
|
return()=>observer.disconnect();
|
||||||
|
},[]);
|
||||||
|
return <header ref={ref} className="observation-panel__header">{children}</header>;
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
.observation-mount, .observation-panel-mount, .observation-media-mount, .observation-panel, .observation-split { height:100%; min-height:0; min-width:0; }
|
.observation-mount, .observation-panel-mount, .observation-media-mount, .observation-panel, .observation-split { height:100%; min-height:0; min-width:0; }
|
||||||
.observation-panel__surface { display:flex; flex-direction:column; height:100%; overflow:hidden; container-type:inline-size; }
|
.observation-panel__surface { display:flex; flex-direction:column; height:100%; overflow:hidden; container-type:inline-size; }
|
||||||
.observation-panel__surface > .observation-mount { flex:1; }
|
.observation-panel__surface > .observation-mount { flex:1; }
|
||||||
|
.observation-panel__surface--overlay { position:relative; --observation-overlay-header-space:62px; }
|
||||||
|
.observation-panel__surface--overlay > .observation-panel__header { position:absolute; inset:0 0 auto; z-index:1; background:transparent; pointer-events:none; }
|
||||||
|
.observation-panel__surface--overlay > .observation-panel__header > * { pointer-events:auto; }
|
||||||
.observation-panel__header { display:flex; align-items:center; gap:var(--nodedc-space-2); padding:var(--nodedc-space-2); flex-shrink:0; }
|
.observation-panel__header { display:flex; align-items:center; gap:var(--nodedc-space-2); padding:var(--nodedc-space-2); flex-shrink:0; }
|
||||||
.observation-panel__header h3 { min-width:0; font-size:var(--nodedc-font-size-sm); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
.observation-panel__header h3 { min-width:0; font-size:var(--nodedc-font-size-sm); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
.observation-source { position:relative; display:flex; flex-direction:column; height:100%; min-height:0; }
|
.observation-source { position:relative; display:flex; flex-direction:column; height:100%; min-height:0; }
|
||||||
@@ -21,7 +24,7 @@
|
|||||||
.observation-source__media { position:relative; flex:1; min-height:0; overflow:hidden; display:flex; align-items:center; justify-content:center; }
|
.observation-source__media { position:relative; flex:1; min-height:0; overflow:hidden; display:flex; align-items:center; justify-content:center; }
|
||||||
.observation-source__media video, .observation-source__media canvas { width:100%; height:100%; object-fit:contain; }
|
.observation-source__media video, .observation-source__media canvas { width:100%; height:100%; object-fit:contain; }
|
||||||
.observation-source__media[data-live="false"] > *, .observation-source__media [data-live="false"] { visibility:hidden; }
|
.observation-source__media[data-live="false"] > *, .observation-source__media [data-live="false"] { visibility:hidden; }
|
||||||
.observation-source__notice { position:absolute; inset:30% var(--nodedc-space-4) auto; text-align:center; color:var(--nodedc-text-secondary); font-size:calc(var(--nodedc-font-size-lg) * .7); }
|
.observation-source__notice { position:absolute; inset:max(30%, calc(var(--observation-overlay-header-space, 0px) + var(--nodedc-space-3))) var(--nodedc-space-4) auto; text-align:center; color:var(--nodedc-text-secondary); font-size:calc(var(--nodedc-font-size-lg) * .7); }
|
||||||
.observation-rerun, .observation-rerun > div, .observation-map > div { width:100%; height:100%; }
|
.observation-rerun, .observation-rerun > div, .observation-map > div { width:100%; height:100%; }
|
||||||
.observation-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-4); }
|
.observation-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-4); }
|
||||||
.observation-layer { display:grid; grid-template-columns:minmax(0, 1fr) 50%; align-items:center; gap:var(--nodedc-space-3); }
|
.observation-layer { display:grid; grid-template-columns:minmax(0, 1fr) 50%; align-items:center; gap:var(--nodedc-space-3); }
|
||||||
@@ -43,3 +46,5 @@
|
|||||||
.observation-panel__title { min-width:80px; }
|
.observation-panel__title { min-width:80px; }
|
||||||
.observation-panel__header > .observation-actions { max-width:100%; flex-wrap:wrap; justify-content:flex-end; margin-left:auto; }
|
.observation-panel__header > .observation-actions { max-width:100%; flex-wrap:wrap; justify-content:flex-end; margin-left:auto; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.observation-panel__surface--overlay .rover-telemetry { padding-top:calc(var(--observation-overlay-header-space) + var(--nodedc-space-3)); }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {workerConnectionStatus} from '../../core/system/workerConnectionStatus';
|
||||||
import {
|
import {
|
||||||
GlassSurface,
|
GlassSurface,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@@ -77,9 +78,7 @@ export function ComputeModulesWorkspace() {
|
|||||||
const node = telemetry?.node ?? null;
|
const node = telemetry?.node ?? null;
|
||||||
const missionCoreRuntimes = telemetry?.runtimes.filter((runtime) => !runtime.external) ?? [];
|
const missionCoreRuntimes = telemetry?.runtimes.filter((runtime) => !runtime.external) ?? [];
|
||||||
const externalRuntimes = telemetry?.runtimes.filter((runtime) => runtime.external) ?? [];
|
const externalRuntimes = telemetry?.runtimes.filter((runtime) => runtime.external) ?? [];
|
||||||
const connected = Boolean(
|
const connectionStatus = workerConnectionStatus(telemetry);
|
||||||
telemetry?.connection.reachable && telemetry.connection.identity_matches && node,
|
|
||||||
);
|
|
||||||
const agentTelemetry = telemetry?.connection.source === "agent-mqtt";
|
const agentTelemetry = telemetry?.connection.source === "agent-mqtt";
|
||||||
const history = telemetry?.history ?? [];
|
const history = telemetry?.history ?? [];
|
||||||
const cpuPercent = node?.cpu.load_percent;
|
const cpuPercent = node?.cpu.load_percent;
|
||||||
@@ -105,14 +104,13 @@ export function ComputeModulesWorkspace() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="system-workspace__actions">
|
<div className="system-workspace__actions">
|
||||||
<StatusBadge tone={connected ? "success" : "danger"}>
|
<StatusBadge tone={connectionStatus.tone}>
|
||||||
{connected
|
{connectionStatus.label}
|
||||||
? agentTelemetry ? "Агент доступен" : "SSH-диагностика"
|
|
||||||
: "Нет свежих данных"}
|
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{connectionStatus.detail && <p role="status">{connectionStatus.detail}</p>}
|
||||||
{error ? (
|
{error ? (
|
||||||
<GlassSurface className="system-workspace__notice" padding="md" tone="soft">
|
<GlassSurface className="system-workspace__notice" padding="md" tone="soft">
|
||||||
<StatusBadge tone="warning">{error}</StatusBadge>
|
<StatusBadge tone="warning">{error}</StatusBadge>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {workerConnectionStatus} from '../../core/system/workerConnectionStatus';
|
||||||
import {
|
import {
|
||||||
GlassSurface,
|
GlassSurface,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@@ -31,6 +32,7 @@ export function NetworkWorkspace() {
|
|||||||
const connected = Boolean(
|
const connected = Boolean(
|
||||||
telemetry?.connection.reachable && telemetry.connection.identity_matches,
|
telemetry?.connection.reachable && telemetry.connection.identity_matches,
|
||||||
);
|
);
|
||||||
|
const connectionStatus = workerConnectionStatus(telemetry);
|
||||||
const agentTelemetry = telemetry?.connection.source === "agent-mqtt";
|
const agentTelemetry = telemetry?.connection.source === "agent-mqtt";
|
||||||
const runtimeOnline = telemetry?.runtimes.some(
|
const runtimeOnline = telemetry?.runtimes.some(
|
||||||
(runtime) => !runtime.external && runtime.state === "running",
|
(runtime) => !runtime.external && runtime.state === "running",
|
||||||
@@ -43,17 +45,18 @@ export function NetworkWorkspace() {
|
|||||||
<span className="section-eyebrow">СИСТЕМА / СЕТЬ</span>
|
<span className="section-eyebrow">СИСТЕМА / СЕТЬ</span>
|
||||||
<h2>{selectedContour?.display_name ?? "Контур не выбран"}</h2>
|
<h2>{selectedContour?.display_name ?? "Контур не выбран"}</h2>
|
||||||
<p>
|
<p>
|
||||||
Фактический локальный маршрут и сетевые счётчики выбранного вычислительного
|
Фактический маршрут и сетевые счётчики выбранного вычислительного
|
||||||
контура. Адрес, транспорт и установка агента находятся в настройках контура.
|
контура. Адрес, транспорт и установка агента находятся в настройках контура.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="system-workspace__actions">
|
<div className="system-workspace__actions">
|
||||||
<StatusBadge tone={connected ? "success" : "danger"}>
|
<StatusBadge tone={connectionStatus.tone}>
|
||||||
{connected ? "Маршрут доступен" : "Нет свежих данных"}
|
{connectionStatus.label}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{connectionStatus.detail && <p role="status">{connectionStatus.detail}</p>}
|
||||||
{error ? (
|
{error ? (
|
||||||
<GlassSurface className="system-workspace__notice" padding="md" tone="soft">
|
<GlassSurface className="system-workspace__notice" padding="md" tone="soft">
|
||||||
<StatusBadge tone="warning">{error}</StatusBadge>
|
<StatusBadge tone="warning">{error}</StatusBadge>
|
||||||
@@ -109,7 +112,7 @@ export function NetworkWorkspace() {
|
|||||||
<small>127.0.0.1:8000</small>
|
<small>127.0.0.1:8000</small>
|
||||||
</div>
|
</div>
|
||||||
<i aria-hidden="true" data-state="online" />
|
<i aria-hidden="true" data-state="online" />
|
||||||
<div data-state={agentTelemetry ? "online" : "offline"}>
|
<div data-state={connected ? "online" : "unknown"}>
|
||||||
<span>Telemetry plane</span>
|
<span>Telemetry plane</span>
|
||||||
<strong>{agentTelemetry ? "Mosquitto + Timescale" : "SSH diagnostic"}</strong>
|
<strong>{agentTelemetry ? "Mosquitto + Timescale" : "SSH diagnostic"}</strong>
|
||||||
<small>
|
<small>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {after,before,test} from 'node:test';
|
import {after,before,test} from 'node:test';
|
||||||
import {createServer} from 'vite';
|
import {createServer} from 'vite';
|
||||||
let server,layout,source,fanout;
|
let server,layout,source,fanout,catalog;
|
||||||
before(async()=>{
|
before(async()=>{
|
||||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||||
layout=await server.ssrLoadModule('/src/core/fleet/observationLayout.ts');
|
layout=await server.ssrLoadModule('/src/core/fleet/observationLayout.ts');
|
||||||
source=await server.ssrLoadModule('../../packages/sensor-ui/src/observation.ts');
|
source=await server.ssrLoadModule('../../packages/sensor-ui/src/observation.ts');
|
||||||
|
catalog=await server.ssrLoadModule('../../packages/sensor-ui/src/observationCatalog.ts');
|
||||||
({observationRerun:fanout}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/observationRerun.ts'));
|
({observationRerun:fanout}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/observationRerun.ts'));
|
||||||
});
|
});
|
||||||
after(async()=>{await server?.close();});
|
after(async()=>{await server?.close();});
|
||||||
@@ -41,3 +42,21 @@ test('two Rerun views share bytes, apply independent blueprints once and dispose
|
|||||||
assert.equal(sent[0].length,2);assert.deepEqual(started,[0,1]);host.dispose();assert.deepEqual(closed,[0,1]);
|
assert.equal(sent[0].length,2);assert.deepEqual(started,[0,1]);host.dispose();assert.deepEqual(closed,[0,1]);
|
||||||
}finally{globalThis.fetch=fetchBefore;}
|
}finally{globalThis.fetch=fetchBefore;}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('known offline D455 remains selectable; connected capabilities override the catalog',()=>{
|
||||||
|
const offline={name:'RealSense D455',model:'RealSense D455',online:false,layers:[]};
|
||||||
|
assert.deepEqual(source.cameraObservationViews(offline)[0].layers.map(v=>v.value),['color','depth','infrared1','infrared2','points','motion']);
|
||||||
|
assert.deepEqual(source.cameraObservationViews({...offline,online:true,layers:['depth']})[0].layers.map(v=>v.value),['depth']);
|
||||||
|
assert.deepEqual(source.cameraObservationViews({...offline,model:'unknown'}),[]);
|
||||||
|
});
|
||||||
|
test('unbound catalog views have no device authority; connected instances replace templates without aliasing',()=>{
|
||||||
|
const plugin={kind:'k1',observation:{catalog:{label:'K1',views:[{id:'points',label:'Points'},{id:'camera',label:'Camera'}]}}};
|
||||||
|
const entries=catalog.unboundObservationViews([],[plugin]);
|
||||||
|
assert.equal(entries.length,2);assert.notEqual(entries[0].key,entries[1].key);
|
||||||
|
assert.equal(entries[0].deviceID,undefined);assert.equal(entries[0].device,undefined);
|
||||||
|
assert.deepEqual(catalog.unboundObservationViews([{id:'one',kind:'k1'}],[plugin]),[]);
|
||||||
|
assert.deepEqual(catalog.unboundObservationViews([],[plugin,plugin]),[]);
|
||||||
|
const persisted=layout.decodeObservationLayout({...layout.emptyObservationLayout(),catalogVisible:[entries[0].key,entries[0].key,null]});
|
||||||
|
assert.deepEqual(persisted.catalogVisible,[entries[0].key]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {before,after,test} from 'node:test';
|
||||||
|
import {createServer} from 'vite';
|
||||||
|
|
||||||
|
let server,readContourSnapshot,contourSummary,workerAvailable;
|
||||||
|
before(async()=>{
|
||||||
|
server=await createServer({server:{middlewareMode:true,hmr:false},optimizeDeps:{noDiscovery:true,include:[]},appType:'custom'});
|
||||||
|
({readContourSnapshot,contourSummary,workerAvailable}=await server.ssrLoadModule('/src/core/system/contourHealth.ts'));
|
||||||
|
});
|
||||||
|
after(async()=>{await server?.close();});
|
||||||
|
|
||||||
|
const core={ok:true,service:'mission-core-control-plane',version:'test',plugin_runtimes:{ready:1,total:1}};
|
||||||
|
const contour={contour_id:'test-compute',display_name:'Test compute',expected_node_id:'test-node'};
|
||||||
|
const worker={schema_version:'missioncore.worker-telemetry/v1',profile:{},connection:{reachable:true,identity_matches:true},node:{node_id:'test-node'},runtimes:[],pipeline:{},network:{},history:[]};
|
||||||
|
const vehicle={id:'test-vehicle',name:'Test rover',enrollment:'paired',connectivity:'offline'};
|
||||||
|
function installFetch(t,{failed=[],data={}}={}){
|
||||||
|
const calls=[];
|
||||||
|
t.mock.method(globalThis,'fetch',async url=>{
|
||||||
|
calls.push(url);
|
||||||
|
if(failed.includes(url))throw new Error('unavailable');
|
||||||
|
const defaults={
|
||||||
|
'/api/health':core,
|
||||||
|
'/api/v1/system/contours':{schema_version:'missioncore.compute-contour-catalog/v1',contours:[contour]},
|
||||||
|
'/api/v1/system/contours/test-compute/telemetry?history=90':worker,
|
||||||
|
'/api/v1/fleet':{items:[vehicle]},
|
||||||
|
};
|
||||||
|
assert.ok(url in defaults,`Unexpected probe: ${url}`);
|
||||||
|
return new Response(JSON.stringify(url in data?data[url]:defaults[url]));
|
||||||
|
});
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
test('actual registries determine node count and offline boards prevent all-online status',async t=>{
|
||||||
|
const calls=installFetch(t);
|
||||||
|
const snapshot=await readContourSnapshot(new AbortController().signal);
|
||||||
|
assert.equal(snapshot.workers[0].contour.display_name,'Test compute');
|
||||||
|
assert.deepEqual(contourSummary(snapshot),{tone:'warning',label:'Часть узлов не в сети',online:2,total:3});
|
||||||
|
assert.equal(calls.length,4);
|
||||||
|
});
|
||||||
|
test('failed checks discard previous online data and distinguish unknown from empty',async t=>{
|
||||||
|
installFetch(t,{failed:['/api/v1/fleet','/api/v1/system/contours/test-compute/telemetry?history=90']});
|
||||||
|
const snapshot=await readContourSnapshot(new AbortController().signal);
|
||||||
|
assert.equal(snapshot.vehicles,null);
|
||||||
|
assert.equal(snapshot.workers[0].telemetry,null);
|
||||||
|
assert.equal(snapshot.errors.length,2);
|
||||||
|
assert.deepEqual(contourSummary(snapshot),{tone:'warning',label:'Проверка неполная',online:1,total:null});
|
||||||
|
});
|
||||||
|
test('invalid health never becomes reachable; identity mismatch and stale worker are not online',async t=>{
|
||||||
|
installFetch(t,{data:{'/api/health':{ok:true}}});
|
||||||
|
const snapshot=await readContourSnapshot(new AbortController().signal);
|
||||||
|
assert.equal(snapshot.core,null);
|
||||||
|
assert.equal(workerAvailable({...worker,connection:{reachable:true,identity_matches:false}}),false);
|
||||||
|
assert.equal(workerAvailable({...worker,connection:{reachable:false,identity_matches:true}}),false);
|
||||||
|
assert.equal(workerAvailable({...worker,node:null}),false);
|
||||||
|
});
|
||||||
|
test('an empty configured contour contains only the operator, with no fabricated worker',async t=>{
|
||||||
|
installFetch(t,{data:{'/api/v1/system/contours':{schema_version:'missioncore.compute-contour-catalog/v1',contours:[]},'/api/v1/fleet':{items:[]}}});
|
||||||
|
assert.deepEqual(contourSummary(await readContourSnapshot(new AbortController().signal)),{tone:'success',label:'Все узлы в сети',online:1,total:1});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('receiver failure leaves worker unknown instead of declaring it offline',async t=>{
|
||||||
|
installFetch(t,{data:{'/api/v1/system/contours/test-compute/telemetry?history=90':{...worker,node:null,connection:{reachable:false,identity_matches:false,error_code:'telemetry-receiver-unavailable'}}}});
|
||||||
|
const snapshot=await readContourSnapshot(new AbortController().signal);
|
||||||
|
assert.equal(contourSummary(snapshot).label,'Состояние части узлов не подтверждено');
|
||||||
|
const {workerConnectionStatus}=await server.ssrLoadModule('/src/core/system/workerConnectionStatus.ts');
|
||||||
|
const status=workerConnectionStatus(snapshot.workers[0].telemetry);
|
||||||
|
assert.equal(status.state,'unknown');
|
||||||
|
assert.equal(status.label,'Не в сети');
|
||||||
|
assert.equal(workerConnectionStatus({...worker,node:null,connection:{reachable:false,error_code:'telemetry-agent-stale'}}).label,'Не в сети');
|
||||||
|
assert.equal(workerConnectionStatus(worker).state,'online');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a responding Core with recording capacity pressure remains reachable',async t=>{
|
||||||
|
installFetch(t,{data:{'/api/health':{...core,ok:false,components:{recording_cache:{status:'capacity-pressure'}}}}});
|
||||||
|
const snapshot=await readContourSnapshot(new AbortController().signal);
|
||||||
|
assert.equal(contourSummary(snapshot).online,2);
|
||||||
|
assert.equal(contourSummary(snapshot).label,'Mission Core работает с ограничениями');
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import {build} from 'esbuild';
|
||||||
|
const result=await build({entryPoints:[new URL('../src/core/roverScene/sceneDocument.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm'});
|
||||||
|
const {defaultScene,environmentFields,changeField,fieldDisabled,parseScene,createMaterial,assignMaterial}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
|
||||||
|
test('all donor camera and environment fields remain exposed, frame styling excluded',t=>{
|
||||||
|
const source=new URL('../../../../NODEDC_ENGINE_INFRA/nodedc-source/src/driveinspector/nodes/ThreeDAsset.definition.ts',import.meta.url);
|
||||||
|
if(!fs.existsSync(source)){t.skip('Donor checkout not installed; pinned catalog remains in repo');return;}
|
||||||
|
const donor=fs.readFileSync(source,'utf8');
|
||||||
|
const keys=[...donor.matchAll(/key: '(postFx\.[^']+|cameraZoomStrength|cameraAutoRotate|cameraResetTs)'/g)].map(m=>m[1]);
|
||||||
|
assert.equal(keys.length,109);
|
||||||
|
for(const key of keys)assert.ok(environmentFields.some(f=>f.key===key),key);
|
||||||
|
assert.ok(!environmentFields.some(f=>f.key==='nodeName'));
|
||||||
|
});
|
||||||
|
test('documents are independent, scene edits preserve material texture references',()=>{
|
||||||
|
const a=defaultScene(),b=defaultScene();a.materials['rover/body'].color='#123456';assert.notEqual(a.materials['rover/body'].color,b.materials['rover/body'].color);
|
||||||
|
const edited=changeField(a,'postFx.lighting.exposure',1.5);assert.equal(edited.postFx.lighting.exposure,1.5);assert.equal(a.postFx.lighting.exposure,defaultScene().postFx.lighting.exposure);assert.equal(edited.materials,a.materials);
|
||||||
|
});
|
||||||
|
test('numeric enums, bounds, invalid color and unknown paths are validated',()=>{
|
||||||
|
const a=defaultScene();assert.equal(changeField(a,'postFx.rendering.toneMapping','3').postFx.rendering.toneMapping,3);
|
||||||
|
assert.equal(changeField(a,'postFx.grid.step',0).postFx.grid.step,.01);
|
||||||
|
for(const [key,value] of [['__proto__.x',1],['postFx.grid.step',NaN],['postFx.grid.colorMain','red'],['postFx.rendering.fog','invalid']])assert.equal(changeField(a,key,value),a);
|
||||||
|
});
|
||||||
|
test('fog interval, atlas/skybox exclusion and field availability stay coherent',()=>{
|
||||||
|
let a=changeField(defaultScene(),'postFx.rendering.fogRange',[18,3]);assert.deepEqual(a.postFx.rendering.fogRange,[3,18]);assert.equal(a.postFx.rendering.fogStart,3);assert.equal(a.postFx.rendering.fogEnd,18);
|
||||||
|
a=changeField(a,'postFx.skybox.enabled',true);assert.equal(a.postFx.envAtlas.enabled,false);
|
||||||
|
assert.equal(fieldDisabled(a,environmentFields.find(f=>f.key==='postFx.envAtlas.enabled')),true);
|
||||||
|
});
|
||||||
|
test('scene JSON round trips materials, texture data and environment without authority',()=>{
|
||||||
|
const a=defaultScene();a.materials['rover/box'].textures.diffuse={name:'test.png',data:'data:image/png;base64,YWJj'};a.materials['rover/box'].roughness=.28;
|
||||||
|
assert.deepEqual(parseScene(JSON.parse(JSON.stringify(a))),a);
|
||||||
|
assert.throws(()=>parseScene({schema:'other'}));
|
||||||
|
assert.throws(()=>parseScene({...a,model:'wrong'}));
|
||||||
|
assert.equal('armed' in a,false);
|
||||||
|
});
|
||||||
|
test('import rejects texture URLs and normalizes out-of-range PBR values',()=>{
|
||||||
|
const a=defaultScene();a.materials['rover/box'].roughness=20;assert.equal(parseScene(a).materials['rover/box'].roughness,1);
|
||||||
|
a.materials['rover/box'].textures.normal={name:'remote',data:'https://unknown/texture'};assert.throws(()=>parseScene(a));
|
||||||
|
});
|
||||||
|
test('editing excludes drive bindings and key controls without changing arm authority',()=>{
|
||||||
|
const view=fs.readFileSync(new URL('../src/components/rover/RoverView.tsx',import.meta.url),'utf8');
|
||||||
|
assert.match(view,/if\(!c.ready\|\|!active\|\|open\|\|editing/);assert.match(view,/c.ready&&!editing/);assert.match(view,/c.pauseInput\(\);setHeld/);
|
||||||
|
const editor=fs.readFileSync(new URL('../src/components/rover/editor/RoverScene.tsx',import.meta.url),'utf8');assert.doesNotMatch(editor,/\.arm\(|setDemand|\/api\/v1\/fleet/);
|
||||||
|
});
|
||||||
|
test('export manifest retains complete geometry and all editable material groups',()=>{
|
||||||
|
const manifest=JSON.parse(fs.readFileSync(new URL('../public/rover-scene/dcd006-v020.json',import.meta.url),'utf8'));
|
||||||
|
assert.equal(manifest.source_object_count,1426);assert.equal(manifest.render_mesh_count,9);assert.equal(manifest.source_saved,false);assert.match(manifest.uv,/cube/);
|
||||||
|
assert.deepEqual(Object.keys(manifest.materials).sort(),Object.keys(defaultScene().materials).map(k=>k.replace('rover/','')).sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('named custom materials and assignments survive JSON and reject unknown groups',()=>{
|
||||||
|
let a=createMaterial(defaultScene(),'custom/example');
|
||||||
|
a.materials['custom/example'].name='Мой металл';a=assignMaterial(a,'rover/body','custom/example');
|
||||||
|
assert.deepEqual(parseScene(JSON.parse(JSON.stringify(a))),a);
|
||||||
|
assert.equal(assignMaterial(a,'unknown','custom/example'),a);
|
||||||
|
assert.equal(assignMaterial(a,'rover/body','missing'),a);
|
||||||
|
assert.equal(createMaterial(a,'__proto__'),a);
|
||||||
|
const legacy=defaultScene();delete legacy.assignments;assert.deepEqual(parseScene(legacy).assignments,{});
|
||||||
|
});
|
||||||
|
test('default environment is the original NodeDC preset without neutral overrides',()=>{
|
||||||
|
const a=defaultScene();assert.equal(a.postFx.chromaticAberration.enabled,true);
|
||||||
|
assert.equal(a.postFx.rendering.backgroundColor,'#111113');
|
||||||
|
assert.equal(a.postFx.lighting.ambientIntensity,0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {build} from 'esbuild';
|
||||||
|
const bundle=await build({stdin:{contents:"export * from './src/core/roverScene/sceneDocument'; export * from './src/core/roverScene/sceneMigration';",resolveDir:process.cwd()},bundle:true,write:false,platform:'node',format:'esm'});
|
||||||
|
const {defaultScene,loadStoredScene,parseScene}=await import('data:text/javascript;base64,'+Buffer.from(bundle.outputFiles[0].contents).toString('base64'));
|
||||||
|
// Historical settings released by the first editor, independent of migration code.
|
||||||
|
function savedFirstEditor(){
|
||||||
|
const d=defaultScene();delete d.environmentVersion;
|
||||||
|
d.postFx={...d.postFx,
|
||||||
|
lighting:{exposure:1.1,skyBoxIntensity:1,ambientIntensity:.22},
|
||||||
|
envAtlas:{...d.postFx.envAtlas,reflectionIntensity:1,brightness:1.2},
|
||||||
|
rendering:{...d.postFx.rendering,backgroundColor:'#202227',fog:'none',toneMapping:4},
|
||||||
|
grid:{...d.postFx.grid,colorMain:'#747982',colorX:'#93969c',colorZ:'#93969c',alphaMain:.25,alphaX:.35,alphaZ:.35,fadeStart:2,fadeEnd:5,step:.5,diameter:12},
|
||||||
|
chromaticAberration:{...d.postFx.chromaticAberration,enabled:false},vignette:{...d.postFx.vignette,enabled:false},grading:{...d.postFx.grading,enabled:false},bloom:{...d.postFx.bloom,enabled:false},
|
||||||
|
directionalLight:{...d.postFx.directionalLight,intensity:1.8,azimuth:135,elevation:45},shadowCatcher:{...d.postFx.shadowCatcher,size:4,lightIntensity:.5,shadowIntensity:.25}
|
||||||
|
};
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
test('previous saved neutral preset migrates to NodeDC while retaining material work',()=>{
|
||||||
|
const raw=savedFirstEditor();raw.materials['rover/body'].name='Мой корпус';raw.materials['rover/body'].roughness=.73;raw.materials['rover/body'].textures.diffuse={name:'my.png',data:'data:image/png;base64,YWJj'};
|
||||||
|
raw.assignments['rover/box']='rover/body';raw.cameraZoomStrength=.1;
|
||||||
|
const original=structuredClone(raw),r=loadStoredScene(raw);
|
||||||
|
assert.equal(r.restoredEnvironment,true);assert.equal(r.updated,true);
|
||||||
|
assert.deepEqual(r.document.postFx,defaultScene().postFx);
|
||||||
|
assert.deepEqual(r.document.materials,raw.materials);assert.deepEqual(r.document.assignments,raw.assignments);assert.equal(r.document.cameraZoomStrength,.1);
|
||||||
|
assert.deepEqual(raw,original,'backup must retain the exact input');
|
||||||
|
assert.equal(loadStoredScene(r.document).updated,false,'migration runs once');
|
||||||
|
});
|
||||||
|
test('a single custom environment edit prevents a destructive reset',()=>{
|
||||||
|
const raw=savedFirstEditor();raw.postFx.lighting.exposure=1.3;
|
||||||
|
const r=loadStoredScene(raw);assert.equal(r.restoredEnvironment,false);assert.deepEqual(r.document.postFx,raw.postFx);
|
||||||
|
});
|
||||||
|
test('a deliberate new gray scene and explicit JSON imports retain their appearance',()=>{
|
||||||
|
const raw=savedFirstEditor();raw.environmentVersion=2;
|
||||||
|
assert.equal(loadStoredScene(raw).restoredEnvironment,false);
|
||||||
|
delete raw.environmentVersion;
|
||||||
|
const imported=parseScene(raw);assert.equal(imported.environmentVersion,2);assert.deepEqual(imported.postFx,raw.postFx);
|
||||||
|
assert.equal(loadStoredScene(imported).restoredEnvironment,false);
|
||||||
|
});
|
||||||
|
test('already restored legacy NodeDC scene is only versioned, not altered',()=>{
|
||||||
|
const raw=defaultScene();delete raw.environmentVersion;const r=loadStoredScene(raw);
|
||||||
|
assert.equal(r.updated,true);assert.equal(r.restoredEnvironment,false);assert.deepEqual(r.document.postFx,raw.postFx);
|
||||||
|
});
|
||||||
@@ -149,9 +149,11 @@ globally unique because Mosquitto ACL ownership is username-based.
|
|||||||
|
|
||||||
## Worker agent
|
## Worker agent
|
||||||
|
|
||||||
MQTT outputs use `startup_error_behavior="retry"`, with a 2000-metric buffer and
|
MQTT outputs request `startup_error_behavior="retry"`, with a 2000-metric buffer
|
||||||
the configured flush interval. A missing broker at agent startup must not end
|
and the configured flush interval. This setting alone does not guarantee
|
||||||
the service. See [Telegraf's startup policy](https://docs.influxdata.com/telegraf/v1/configuration/plugin-options/).
|
recovery: Telegraf 1.38.4 can exit cleanly when MQTT is unavailable at startup.
|
||||||
|
The Tailscale profile below therefore also installs a system recovery task.
|
||||||
|
See [Telegraf's startup policy](https://docs.influxdata.com/telegraf/v1/configuration/plugin-options/).
|
||||||
If Docker lost a published listener while the saved LAN address is unchanged,
|
If Docker lost a published listener while the saved LAN address is unchanged,
|
||||||
the explicit broker Apply action reconciles that listener; a telemetry GET never
|
the explicit broker Apply action reconciles that listener; a telemetry GET never
|
||||||
restarts infrastructure. Node connectivity does not prove a profile is ready.
|
restarts infrastructure. Node connectivity does not prove a profile is ready.
|
||||||
@@ -228,3 +230,26 @@ changing the K1 command sequence.
|
|||||||
|
|
||||||
The stack and agent are intentionally not started by repository tests. Provisioning a
|
The stack and agent are intentionally not started by repository tests. Provisioning a
|
||||||
machine is a separate, explicit operation.
|
machine is a separate, explicit operation.
|
||||||
|
|
||||||
|
## Tailscale operator profile and startup recovery
|
||||||
|
|
||||||
|
The accepted 2026-09-25 operator profile no longer depends on a shared LAN or
|
||||||
|
DHCP address. A prepared Windows agent uses `127.0.0.1:1883`; the existing strict
|
||||||
|
Tailscale SSH identity carries a loopback reverse forward to the operator
|
||||||
|
broker. Operator deployment is owned by `scripts/manage_telemetry_startup.py`
|
||||||
|
(plan, hash-bound apply, rollback). This is a macOS **login** profile, not a
|
||||||
|
pre-login Docker daemon. Preserve the explicit prepared stack root and its
|
||||||
|
private credentials when moving the Core source checkout.
|
||||||
|
|
||||||
|
For this profile the Windows install/update bundle includes and invokes
|
||||||
|
`Install-NdcMissionCoreTelemetryRecovery.ps1`: boot + once-per-minute SYSTEM
|
||||||
|
reconciliation for a stopped Telegraf service, independent of login. Its
|
||||||
|
maintenance/rollback boundary is described in the audit. Do not rely solely on
|
||||||
|
SCM failure actions or the MQTT `startup_error_behavior` setting: the pinned
|
||||||
|
1.38.4 release was observed to terminate with a clean service exit when the
|
||||||
|
broker was unavailable during boot.
|
||||||
|
|
||||||
|
See [the measured recovery audit](../../docs/audits/2026-09-25-worker-telemetry-recovery.md)
|
||||||
|
for exact acceptance, remaining cold-boot/clean-host gates and bounded test
|
||||||
|
entrypoints. The earlier LAN workflow remains a legacy explicit configuration;
|
||||||
|
it is not the current Worker 006 transport.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ SAFE_IDENTIFIER: Final = re.compile(
|
|||||||
)
|
)
|
||||||
SAFE_INTERVAL: Final = re.compile(r"^[1-9][0-9]{0,2}s$")
|
SAFE_INTERVAL: Final = re.compile(r"^[1-9][0-9]{0,2}s$")
|
||||||
WINDOWS_BUNDLE_FILES: Final = (
|
WINDOWS_BUNDLE_FILES: Final = (
|
||||||
|
"Install-NdcMissionCoreTelemetryRecovery.ps1",
|
||||||
"Get-NdcMissionCorePipelineTelemetry.ps1",
|
"Get-NdcMissionCorePipelineTelemetry.ps1",
|
||||||
"Install-NdcMissionCoreTelegraf.ps1",
|
"Install-NdcMissionCoreTelegraf.ps1",
|
||||||
"Update-NdcMissionCoreTelegraf.ps1",
|
"Update-NdcMissionCoreTelegraf.ps1",
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ foreach ($name in @(
|
|||||||
Set-Item -Path "Env:$name" -Value ([string]$value)
|
Set-Item -Path "Env:$name" -Value ([string]$value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$recoveryInstaller = Join-Path $PSScriptRoot 'Install-NdcMissionCoreTelemetryRecovery.ps1'
|
||||||
|
if ($payload.MISSIONCORE_MQTT_HOST -eq '127.0.0.1' -and -not (Test-Path $recoveryInstaller -PathType Leaf)) {
|
||||||
|
throw 'Telemetry recovery installer missing from bundle'
|
||||||
|
}
|
||||||
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
|
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
|
||||||
throw "Service '$serviceName' already exists; refusing an implicit replacement"
|
throw "Service '$serviceName' already exists; refusing an implicit replacement"
|
||||||
}
|
}
|
||||||
@@ -137,6 +141,10 @@ try {
|
|||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "Failed to enable Telegraf recovery for non-crash failures"
|
throw "Failed to enable Telegraf recovery for non-crash failures"
|
||||||
}
|
}
|
||||||
|
$recoveryTask = $null
|
||||||
|
if ($payload.MISSIONCORE_MQTT_HOST -eq '127.0.0.1') {
|
||||||
|
$recoveryTask = & $recoveryInstaller -ExpectedNodeId $env:COMPUTERNAME -Action Apply | ConvertFrom-Json
|
||||||
|
}
|
||||||
Start-Service -Name $serviceName
|
Start-Service -Name $serviceName
|
||||||
$service = Get-Service -Name $serviceName
|
$service = Get-Service -Name $serviceName
|
||||||
$service.WaitForStatus([ServiceProcess.ServiceControllerStatus]::Running, [TimeSpan]::FromSeconds(20))
|
$service.WaitForStatus([ServiceProcess.ServiceControllerStatus]::Running, [TimeSpan]::FromSeconds(20))
|
||||||
@@ -150,6 +158,7 @@ try {
|
|||||||
Status = $service.Status.ToString()
|
Status = $service.Status.ToString()
|
||||||
StartType = $service.StartType.ToString()
|
StartType = $service.StartType.ToString()
|
||||||
RecoveryConfigured = $true
|
RecoveryConfigured = $true
|
||||||
|
RecoveryTask = $recoveryTask
|
||||||
Configuration = $configurationPath
|
Configuration = $configurationPath
|
||||||
PipelineCollector = $collectorPath
|
PipelineCollector = $collectorPath
|
||||||
PipelineJournal = $PipelineJournal
|
PipelineJournal = $PipelineJournal
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$ExpectedNodeId,
|
||||||
|
[ValidateSet('Plan','Apply','Rollback')][string]$Action = 'Plan',
|
||||||
|
[string]$BackupDirectory
|
||||||
|
)
|
||||||
|
# Telegraf 1.38.4 can report MQTT startup failure as a clean Windows-service exit.
|
||||||
|
# SCM failure actions alone cannot recover that stopped service.
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
|
if ($env:COMPUTERNAME -cne $ExpectedNodeId) { throw 'Worker identity mismatch' }
|
||||||
|
$taskName = 'ndc-mission-core-telemetry-recovery'
|
||||||
|
$root = Join-Path $env:ProgramFiles 'NDC\Mission Core\TelemetryRecovery'
|
||||||
|
$guardPath = Join-Path $root 'Resume-Telemetry.ps1'
|
||||||
|
$service = Get-CimInstance Win32_Service -Filter "Name='telegraf'"
|
||||||
|
if (-not $service -or $service.PathName -notlike '*NDC\Mission Core\Telegraf\telegraf.exe*') { throw 'Managed Telegraf service required' }
|
||||||
|
$guard = @'
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$service = Get-Service -Name 'telegraf'
|
||||||
|
if ($service.Status -eq 'Running') { exit 0 }
|
||||||
|
if ($service.Status -ne 'Stopped') { exit 0 }
|
||||||
|
$values = @{}
|
||||||
|
foreach ($entry in (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\telegraf').Environment) {
|
||||||
|
$name, $value = $entry -split '=', 2
|
||||||
|
if ($name -in @('MISSIONCORE_MQTT_HOST','MISSIONCORE_MQTT_PORT')) { $values[$name] = $value }
|
||||||
|
}
|
||||||
|
if ($values['MISSIONCORE_MQTT_HOST'] -ne '127.0.0.1' -or $values['MISSIONCORE_MQTT_PORT'] -ne '1883') { exit 1 }
|
||||||
|
$client = [Net.Sockets.TcpClient]::new()
|
||||||
|
try {
|
||||||
|
$connect = $client.ConnectAsync('127.0.0.1', 1883)
|
||||||
|
if (-not $connect.Wait(3000) -or -not $client.Connected) { exit 0 }
|
||||||
|
} catch { exit 0 } finally { $client.Dispose() }
|
||||||
|
Start-Service -Name 'telegraf'
|
||||||
|
(Get-Service -Name 'telegraf').WaitForStatus([ServiceProcess.ServiceControllerStatus]::Running, [TimeSpan]::FromSeconds(15))
|
||||||
|
'@
|
||||||
|
if ($Action -eq 'Plan') {
|
||||||
|
[ordered]@{node=$env:COMPUTERNAME;task=$taskName;path=$guardPath;startMode=$service.StartMode;triggers=@('boot','every-minute');scope='Start stopped telemetry service only';requiresInteractiveLogin=$false}|ConvertTo-Json -Compress
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
if ($Action -eq 'Rollback') {
|
||||||
|
if (-not $BackupDirectory) { throw 'BackupDirectory required' }
|
||||||
|
$receipt = Get-Content (Join-Path $BackupDirectory 'receipt.json') -Raw | ConvertFrom-Json
|
||||||
|
if ($receipt.node -cne $ExpectedNodeId -or $receipt.task -ne $taskName) { throw 'Backup identity mismatch' }
|
||||||
|
if ((Get-FileHash $guardPath -Algorithm SHA256).Hash -ne $receipt.installedHash) { throw 'Installed recovery script changed' }
|
||||||
|
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||||
|
if (Test-Path (Join-Path $BackupDirectory 'task.xml')) {
|
||||||
|
Register-ScheduledTask -TaskName $taskName -Xml (Get-Content (Join-Path $BackupDirectory 'task.xml') -Raw) -Force | Out-Null
|
||||||
|
}
|
||||||
|
if (Test-Path (Join-Path $BackupDirectory 'guard.ps1')) {
|
||||||
|
Copy-Item (Join-Path $BackupDirectory 'guard.ps1') $guardPath -Force
|
||||||
|
} else { Remove-Item $guardPath }
|
||||||
|
[ordered]@{restored=$true;task=$taskName}|ConvertTo-Json -Compress
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Administrative installer required' }
|
||||||
|
if (Test-Path $root) {
|
||||||
|
if ((Get-Item $root -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Recovery directory must not be a reparse point' }
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $root -Force | Out-Null
|
||||||
|
# SYSTEM executes this file: only SYSTEM and Administrators may modify it.
|
||||||
|
& icacls.exe $root /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Recovery directory ACL failed' }
|
||||||
|
$backup = Join-Path $root ('backups\' + [Guid]::NewGuid().ToString('N'))
|
||||||
|
New-Item -ItemType Directory -Path $backup -Force | Out-Null
|
||||||
|
$previousTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||||
|
if ($previousTask) { Export-ScheduledTask -TaskName $taskName | Set-Content (Join-Path $backup 'task.xml') -Encoding UTF8 }
|
||||||
|
if (Test-Path $guardPath) { Copy-Item $guardPath (Join-Path $backup 'guard.ps1') }
|
||||||
|
try {
|
||||||
|
[IO.File]::WriteAllText($guardPath, $guard, [Text.UTF8Encoding]::new($false))
|
||||||
|
$exe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||||
|
$run = New-ScheduledTaskAction -Execute $exe -Argument ('-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + $guardPath + '"')
|
||||||
|
$triggers = @((New-ScheduledTaskTrigger -AtStartup), (New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 1)))
|
||||||
|
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Seconds 45) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||||
|
$identity = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
||||||
|
Register-ScheduledTask -TaskName $taskName -Action $run -Trigger $triggers -Settings $settings -Principal $identity -Force | Out-Null
|
||||||
|
$receipt = [ordered]@{node=$env:COMPUTERNAME;task=$taskName;installedHash=(Get-FileHash $guardPath -Algorithm SHA256).Hash;backup=$backup}
|
||||||
|
$receipt|ConvertTo-Json|Set-Content (Join-Path $backup 'receipt.json') -Encoding UTF8
|
||||||
|
Start-ScheduledTask -TaskName $taskName
|
||||||
|
$receipt|ConvertTo-Json -Compress
|
||||||
|
} catch {
|
||||||
|
if ($previousTask) { Register-ScheduledTask -TaskName $taskName -Xml (Get-Content (Join-Path $backup 'task.xml') -Raw) -Force | Out-Null }
|
||||||
|
else { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue }
|
||||||
|
if (Test-Path (Join-Path $backup 'guard.ps1')) { Copy-Item (Join-Path $backup 'guard.ps1') $guardPath -Force }
|
||||||
|
else { Remove-Item $guardPath -ErrorAction SilentlyContinue }
|
||||||
|
throw
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param([Parameter(Mandatory=$true)][string]$ExpectedNodeId)
|
||||||
|
$ErrorActionPreference='Stop'
|
||||||
|
$ProgressPreference='SilentlyContinue'
|
||||||
|
if ($env:COMPUTERNAME -cne $ExpectedNodeId) { throw 'Worker identity mismatch' }
|
||||||
|
$task=Get-ScheduledTask -TaskName 'ndc-mission-core-telemetry-recovery'
|
||||||
|
$account=[Security.Principal.NTAccount]::new($task.Principal.UserId)
|
||||||
|
$sid=$account.Translate([Security.Principal.SecurityIdentifier]).Value
|
||||||
|
if (-not $task.Settings.Enabled -or $sid -ne 'S-1-5-18') { throw 'Recovery task not enabled as SYSTEM' }
|
||||||
|
$before=Get-CimInstance Win32_Service -Filter "Name='telegraf'"
|
||||||
|
if ($before.State -ne 'Running') { throw 'A running baseline is required' }
|
||||||
|
$started=[DateTime]::UtcNow
|
||||||
|
Stop-Service telegraf
|
||||||
|
(Get-Service telegraf).WaitForStatus([ServiceProcess.ServiceControllerStatus]::Stopped,[TimeSpan]::FromSeconds(20))
|
||||||
|
$automatic=$false
|
||||||
|
try {
|
||||||
|
while (([DateTime]::UtcNow-$started).TotalSeconds -lt 80) {
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
$after=Get-CimInstance Win32_Service -Filter "Name='telegraf'"
|
||||||
|
if ($after.State -eq 'Running' -and $after.ProcessId -ne $before.ProcessId) { $automatic=$true;break }
|
||||||
|
}
|
||||||
|
[ordered]@{node=$ExpectedNodeId;test='clean-service-stop';automaticRecovery=$automatic;elapsedSeconds=[Math]::Round(([DateTime]::UtcNow-$started).TotalSeconds,2);previousPid=$before.ProcessId;newPid=$after.ProcessId;state=$after.State}|ConvertTo-Json -Compress
|
||||||
|
if (-not $automatic) { throw 'Automatic recovery failed; restoring baseline' }
|
||||||
|
} finally {
|
||||||
|
if ((Get-Service telegraf).Status -ne 'Running') { Start-Service telegraf }
|
||||||
|
}
|
||||||
@@ -67,6 +67,10 @@ if (-not ($serviceEnvironmentCandidate | Where-Object {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$recoveryInstaller = Join-Path $PSScriptRoot 'Install-NdcMissionCoreTelemetryRecovery.ps1'
|
||||||
|
if ($env:MISSIONCORE_MQTT_HOST -eq '127.0.0.1' -and -not (Test-Path $recoveryInstaller -PathType Leaf)) {
|
||||||
|
throw 'Telemetry recovery installer missing from bundle'
|
||||||
|
}
|
||||||
$temporaryRoot = Join-Path $env:TEMP "ndc-mission-core-telegraf-update-$([Guid]::NewGuid().ToString('N'))"
|
$temporaryRoot = Join-Path $env:TEMP "ndc-mission-core-telegraf-update-$([Guid]::NewGuid().ToString('N'))"
|
||||||
$validationOutput = Join-Path $temporaryRoot "validation.out.log"
|
$validationOutput = Join-Path $temporaryRoot "validation.out.log"
|
||||||
$validationError = Join-Path $temporaryRoot "validation.error.log"
|
$validationError = Join-Path $temporaryRoot "validation.error.log"
|
||||||
@@ -127,6 +131,10 @@ try {
|
|||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "Failed to enable Telegraf recovery for non-crash failures"
|
throw "Failed to enable Telegraf recovery for non-crash failures"
|
||||||
}
|
}
|
||||||
|
$recoveryTask = $null
|
||||||
|
if ($env:MISSIONCORE_MQTT_HOST -eq '127.0.0.1') {
|
||||||
|
$recoveryTask = & $recoveryInstaller -ExpectedNodeId $env:COMPUTERNAME -Action Apply | ConvertFrom-Json
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Stop-Service -Name $serviceName -Force -ErrorAction SilentlyContinue
|
Stop-Service -Name $serviceName -Force -ErrorAction SilentlyContinue
|
||||||
@@ -155,6 +163,7 @@ try {
|
|||||||
ServiceName = $serviceName
|
ServiceName = $serviceName
|
||||||
Status = (Get-Service -Name $serviceName).Status.ToString()
|
Status = (Get-Service -Name $serviceName).Status.ToString()
|
||||||
RecoveryConfigured = $true
|
RecoveryConfigured = $true
|
||||||
|
RecoveryTask = $recoveryTask
|
||||||
Configuration = $configurationPath
|
Configuration = $configurationPath
|
||||||
Backup = $backupPath
|
Backup = $backupPath
|
||||||
PipelineCollector = $collectorPath
|
PipelineCollector = $collectorPath
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Rover scene material and environment editor
|
||||||
|
|
||||||
|
Owner request: 2026-09-25. The operator adjusts the appearance of the selected
|
||||||
|
vehicle without leaving its observation viewport. The pencil in the existing
|
||||||
|
3D View header enters an editing mode. A bottom material palette and movable,
|
||||||
|
modeless material/environment inspectors apply changes immediately.
|
||||||
|
|
||||||
|
The earlier combined control/design modal was explicitly superseded by the
|
||||||
|
owner. Driving settings retain their existing modal. A separate workspace
|
||||||
|
would hide the live scene and duplicate navigation; the admitted surface is an
|
||||||
|
edit mode of the existing rover view, with no new product root.
|
||||||
|
|
||||||
|
The scene document belongs to vehicle + model revision, persists in IndexedDB
|
||||||
|
on this operator computer, includes imported texture data, and can be exported
|
||||||
|
or imported as JSON. Loading and persistence failures remain visible; closing
|
||||||
|
an inspector never waits for saving. This is appearance only, not a firmware,
|
||||||
|
calibration or hardware profile. Editing pauses local drive input and prevents
|
||||||
|
new drive input until exit; it never arms a vehicle.
|
||||||
|
|
||||||
|
NodeDC ThreeDAsset.definition provides the environment field catalog and
|
||||||
|
PlayCanvas remains the renderer. The NodeDC inspector chrome is not copied.
|
||||||
|
Design Guideline Window (modeless, draggable), Inspector, ControlRow,
|
||||||
|
InspectorSelectField, RangeControl, ColorField, Switch, Button, IconButton and
|
||||||
|
ToastStack own all controls and surfaces. Canonical edit/layers/globe icons
|
||||||
|
identify edit, materials and environment. Material thumbnails are domain PBR
|
||||||
|
previews inside canonical buttons.
|
||||||
|
|
||||||
|
The v020 Blender file is an immutable source. Export retains separate semantic
|
||||||
|
material groups and UVs; clicking a rendered surface selects its shared material.
|
||||||
|
The palette explicitly states that edits affect every surface using that
|
||||||
|
material. Appearance groups include chassis, rubber, track steel, enclosure,
|
||||||
|
MOLLE, motor aluminum, black hardware and accessories.
|
||||||
|
|
||||||
|
Acceptance: donor field coverage, scene validation/persistence isolation,
|
||||||
|
renderer resource cleanup, edit/control exclusion, TypeScript/unit/build and
|
||||||
|
browser QA for picking, live edits, dragging, normal/expanded layout, keyboard,
|
||||||
|
texture loading, persistence and return to observation. No physical motion is
|
||||||
|
part of editor acceptance.
|
||||||
|
|
||||||
|
Owner refinement: the 3D header overlays the viewport with no opaque strip.
|
||||||
|
The original NodeDC environment preset remains the default and is restored
|
||||||
|
through the environment inspector, preserving materials. Every material has an
|
||||||
|
editable display name. A permanent final plus tile creates a new PBR material;
|
||||||
|
explicit assignment applies it to the picked semantic group. IDs remain stable
|
||||||
|
when renamed. Custom materials and assignments persist in the same document.
|
||||||
|
|
||||||
|
Verified on 2026-09-25: TypeScript/production build, all 972 frontend tests,
|
||||||
|
normal and expanded browser layout, transparent header, the NodeDC reset control,
|
||||||
|
GPU picking, material naming/creation/assignment, draggable inspector and Escape.
|
||||||
|
A synthetic checker texture was uploaded, rendered and retained with its custom
|
||||||
|
material, name and assignment after a page reload. The texture was then removed
|
||||||
|
and the enclosure's original material reassigned. Browser error/warning log was
|
||||||
|
empty. No motor commands were sent; the canonical service remains on port 8000.
|
||||||
|
The inherited renderer still reduces MSAA to one sample. Local scene storage is
|
||||||
|
not cross-computer synchronization. Both bounds are recorded in MISSIONCOR-85.
|
||||||
|
|
||||||
|
Follow-up: the owner still saw the withdrawn gray preset after clearing browser
|
||||||
|
cache. Changing defaults and using the reset in one browser did not migrate
|
||||||
|
other saved IndexedDB scene documents. Added environmentVersion 2 and a narrow
|
||||||
|
load migration: only the exact original neutral environment is replaced with
|
||||||
|
NodeDC defaults. Material edits, textures, assignments and camera options survive.
|
||||||
|
An atomic IndexedDB transaction retains the old document under
|
||||||
|
`:before-environment-v2` before replacing it. Individually edited environments
|
||||||
|
and explicit JSON imports are preserved; the version prevents future resets.
|
||||||
|
Regression tests cover the legacy scene, material preservation, idempotence,
|
||||||
|
custom environments, deliberate gray scenes, imports and already restored scenes.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 0049 — Observation overlays, offline source catalog and section navigation
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
All apparatus observation panels place titles/actions over their viewport, without a separate header strip. A ResizeObserver reserves the real header height for status and telemetry content. DG SplitPane owns the invisible separator variant: pointer hit area, keyboard resize and keyboard focus remain available. Ratios still use the existing per-vehicle JSON layout.
|
||||||
|
|
||||||
|
The apparatus detail and board monitor return through a shared circular header action. Explicit left-menu navigation remounts the workspace entry view, including repeated selection; persisted configuration and window arrangements are retained. Scene tool windows and recorded-session overview close. System contour selection returns to its default modules view. Leaving rover control invokes its existing cleanup/stop.
|
||||||
|
|
||||||
|
## Sources and authority
|
||||||
|
|
||||||
|
A known D455 with an empty offline capability list remains available as a camera viewport with its supported layer choices. A published nonempty capability list takes precedence. Detailed camera settings reuse SensorDetail; its capabilities and preview are not requested from an offline board.
|
||||||
|
|
||||||
|
Plugins may declare a view catalog independent of physical inventory. K1 declares point cloud (3D), point cloud (top), and camera. These optional windows are listed even before enrollment, start hidden, and display an explicit disconnected-source message when selected. They never fabricate a Sensor, session, device identifier or command authority. The catalog choice is persisted in bounded catalogVisible layout data. Actual devices retain distinct device-ID-based views and replace unbound catalog entries; no ambiguous model-to-instance assignment is inferred. K1 acquisition remains an explicit operator action in its existing configurator.
|
||||||
|
|
||||||
|
Source gear actions open the existing device settings in a draggable window. The layers dialog continues to control visibility and order.
|
||||||
|
|
||||||
|
## Validation scope
|
||||||
|
|
||||||
|
Build and offline UI acceptance cover section reset/back navigation, overlay headers, invisible pointer/keyboard resizing, layer visibility/persistence, disconnected notices and settings access. Unit cases cover D455 capability precedence, ambiguous plugin rejection, template/physical-device separation and bounded catalog persistence. The board was offline during this change: live RealSense/XGRIDS delivery and hardware settings are not claimed as accepted.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 0050 — Apparatus composition and its onboard device branch
|
||||||
|
|
||||||
|
## Operator job and decision
|
||||||
|
|
||||||
|
Owner-approved 25.09.2026: configure the rover as a whole while seeing which
|
||||||
|
computer owns each device. Keep the apparatus identity above its computer.
|
||||||
|
The apparatus has a compact name/class summary, apparatus settings, and an
|
||||||
|
Equipment section. The current single computer is an expandable resource row;
|
||||||
|
its devices appear beneath it. Host facts and unpairing belong to its settings.
|
||||||
|
|
||||||
|
The previously proposed flat peer list of computer and cameras was rejected:
|
||||||
|
it hides the actual USB/runtime dependency. The present implementation supports
|
||||||
|
one onboard computer; multiple computers and flight-controller transports are
|
||||||
|
not claimed. A future controller's placement must follow its actual connection,
|
||||||
|
not an invented independent link. Actuation role is separate from topology.
|
||||||
|
|
||||||
|
## Actions and persistence
|
||||||
|
|
||||||
|
The outer plus is owned by the vehicle list and creates an apparatus. Inside a
|
||||||
|
vehicle it is absent. Equipment has an attach-computer action and inventory
|
||||||
|
refresh; the computer row has wireless-device enrollment, settings and monitor.
|
||||||
|
USB devices are discovered by Node. Wireless enrollment reuses plugin-owned
|
||||||
|
forms, including K1, and cannot run while the owning computer is unavailable.
|
||||||
|
|
||||||
|
ResourceRow, Inspector, IconButton, SettingsCard and Window are existing DG
|
||||||
|
primitives. No new visual primitive or product root is introduced. The stored
|
||||||
|
v1 board-layout ids remain compatible: settings/devices control the two outer
|
||||||
|
sections; computer now controls the nested branch. Collapsing an outer section
|
||||||
|
does not erase the nested preference. Scene and observation layout identities
|
||||||
|
remain keyed by the apparatus.
|
||||||
|
|
||||||
|
Owner refinement later on 25.09: the apparatus name and class are appended to
|
||||||
|
the shared panel title. The separate summary card is removed; observation and
|
||||||
|
configuration are opposite header icon actions. Computer branch collapse is
|
||||||
|
the rightmost row action. When cached devices are shown, their explanation is
|
||||||
|
inline after the computer status rather than a separate offline card.
|
||||||
|
|
||||||
|
Offline Core reads only its cached fleet inventory, never queries the absent
|
||||||
|
hardware or shows an endless discovery spinner. Cached devices stay visible;
|
||||||
|
their current connection is unconfirmed, and mutations stay disabled.
|
||||||
|
|
||||||
|
Apparatus metadata is an exception to the hardware mutation gate: the name is
|
||||||
|
editable in Apparatus settings while the board is offline. PATCH /fleet/{id}
|
||||||
|
accepts only name and expected_revision through the local-operator boundary.
|
||||||
|
The transaction preserves apparatus/node identity, binding, platform, device
|
||||||
|
assignments and history. It checks revisions to prevent a stale editor from
|
||||||
|
overwriting another change, emits the existing fleet snapshot event, and
|
||||||
|
returns public metadata. Repeating the already-saved name is idempotent.
|
||||||
|
The list, panel name/type title, monitor and observation center use this same
|
||||||
|
registry name. No board request or OS configuration is involved.
|
||||||
|
|
||||||
|
The Park navigation now contains only Contour status and Vehicles. Sensor and
|
||||||
|
composition catalog placeholders are removed. Contour status reads local Core
|
||||||
|
health, the configured compute-contour catalog and its telemetry, plus the
|
||||||
|
fleet registry. Simulation gateway fallback identifiers are not inventory.
|
||||||
|
Node counts are derived from these registries; unavailable/invalid checks are
|
||||||
|
explicitly unknown, not empty or online. Worker availability requires both
|
||||||
|
reachability and identity match, with backend telemetry freshness checks;
|
||||||
|
board status uses the authenticated fleet heartbeat expiry. Polling is bounded,
|
||||||
|
sequential and stopped when the workspace unmounts.
|
||||||
|
|
||||||
|
## Computer replacement boundary
|
||||||
|
|
||||||
|
POST /fleet/{vehicle}/computer consumes a verified Node invitation and an
|
||||||
|
expected vehicle revision. The vehicle id/name/class/creation time stay intact.
|
||||||
|
The prior private binding row is archived transactionally in board_history;
|
||||||
|
monitor/configuration archives remain keyed by their original node identity.
|
||||||
|
The new computer does not inherit commands, sensor sessions, calibration or
|
||||||
|
claims of physical wiring from the previous computer. A new verified inventory
|
||||||
|
is required. Existing devices bound to another apparatus are not transferred.
|
||||||
|
|
||||||
|
Expired previews, stale revisions, pending pairing, active rover control and
|
||||||
|
unexpired device operations block replacement. Verification/listener failures
|
||||||
|
before commit leave the original row intact. At explicit replacement commit,
|
||||||
|
the old binding loses authority; handshake completion of the new binding is
|
||||||
|
shown separately. Failure does not silently restore or authorize the old one.
|
||||||
|
Each stream frame rechecks its binding. Command admission resolves the node
|
||||||
|
under the same fleet lock as replacement. Preview retry is idempotent only for
|
||||||
|
the exact resulting binding. This is not live hot-swapping acceptance.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Synthetic pairing tests cover preserved vehicle identity/layout, archived old
|
||||||
|
binding, old-certificate rejection, duplicate node rejection, stale/expired
|
||||||
|
previews, idempotency, active operation rejection and transactional rollback.
|
||||||
|
Frontend checks and offline browser QA cover both plus scopes, nested folding,
|
||||||
|
offline inventory, K1 enrollment state, host settings and back navigation.
|
||||||
|
Acceptance on 25.09.2026: all 69 fleet tests, 978 Control Station tests and
|
||||||
|
the Node UI contract test passed. Both production builds passed TypeScript.
|
||||||
|
Browser QA on canonical port 8000 verified normal/expanded presentation,
|
||||||
|
Escape dismissal, list/computer/device action scopes, cached devices, and
|
||||||
|
folding persistence across navigation and a full page reload. The existing
|
||||||
|
LaunchAgent restarted the sole Core process; exact health recovered.
|
||||||
|
The real board is powered off: new pairing/replacement and live device operation
|
||||||
|
require subsequent hardware acceptance; no such action is performed here.
|
||||||
|
|
||||||
|
Later name/status acceptance on the same date: 44 focused identity, pairing
|
||||||
|
and replacement tests passed, as did all 982 Control Station unit tests,
|
||||||
|
TypeScript and the production build. Final presentation refinements passed
|
||||||
|
the architecture/status checks again (8 tests), typecheck and build. Browser
|
||||||
|
QA covered normal/expanded views, empty-name rejection, cancellation, a
|
||||||
|
temporary rename visible in the list/header/observation center, persistence
|
||||||
|
after reload and restoration of the original name. The live status view
|
||||||
|
showed Core reachable, a configured worker without fresh telemetry and the
|
||||||
|
powered-off board without a link. No actuator or device commands were sent.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Worker telemetry recovery, 2026-09-25
|
||||||
|
|
||||||
|
## Failure and evidence
|
||||||
|
|
||||||
|
Worker 006 is a remote compute host, not a device required to share the
|
||||||
|
operator's LAN. The owner explicitly requires Tailscale access across networks.
|
||||||
|
At the initial inspection the Worker was disconnected from its network; after
|
||||||
|
Wi-Fi returned the existing strict-pinned Tailscale SSH profile connected.
|
||||||
|
|
||||||
|
Read-only audit then established:
|
||||||
|
|
||||||
|
- Tailscale and OpenSSH were Running/Automatic; Tailscale ForceDaemon was true.
|
||||||
|
- Compute Docker containers were running. A failed historical StartCompute task
|
||||||
|
did not mean the entire Worker or every container was offline.
|
||||||
|
- Native Telegraf 1.38.4 was Stopped/Automatic, exit code zero. SCM restart actions
|
||||||
|
and non-crash recovery were already configured.
|
||||||
|
- Windows Application events on September 19, 21 and 23 showed Telegraf starting,
|
||||||
|
then terminating about 15 seconds later because its old LAN MQTT destination
|
||||||
|
was unreachable. Source inspection of the pinned upstream release confirms
|
||||||
|
that output retry handles only errors marked retryable; merely configuring
|
||||||
|
`startup_error_behavior=retry` did not cover the observed MQTT failure.
|
||||||
|
- Operator Docker was stopped, with login autostart disabled. No broker/query
|
||||||
|
listeners existed. Its saved broker bind belonged to an older DHCP lease.
|
||||||
|
- Private telemetry configuration remained in the original installation while
|
||||||
|
Core's network-apply handler assumed its current source checkout owned it.
|
||||||
|
|
||||||
|
No credentials, database volumes, Worker computation profiles or inference jobs
|
||||||
|
were replaced. Docker Desktop restored its existing unrelated restart-policy
|
||||||
|
containers when its engine started; those were not renamed or reconfigured.
|
||||||
|
|
||||||
|
## Implemented installation profile
|
||||||
|
|
||||||
|
Windows native Telegraf publishes to Worker `127.0.0.1:1883`. A dedicated
|
||||||
|
Mac-owned SSH reverse forward exposes that loopback listener and carries data
|
||||||
|
through the existing strictly pinned Tailscale SSH profile to operator
|
||||||
|
`127.0.0.1:1883`. Broker publication is loopback-only. Query remains
|
||||||
|
`127.0.0.1:18030`; Core remains `127.0.0.1:8000`. No `.local` name or LAN lease is
|
||||||
|
required by this transport. MQTT credentials and per-agent ACLs are preserved.
|
||||||
|
SSH supplies authenticated encryption; this does not claim public MQTT/mTLS
|
||||||
|
or multi-tenant enrollment acceptance.
|
||||||
|
|
||||||
|
`scripts/manage_telemetry_startup.py` owns a hash-bound plan/apply/rollback:
|
||||||
|
|
||||||
|
- preserved prepared stack directory, credentials and named volumes;
|
||||||
|
- explicit `MISSIONCORE_TELEMETRY_PLANE_ROOT` for the existing Core handler;
|
||||||
|
- `com.nodedc.telemetry-startup.local`: login startup and bounded reconciliation
|
||||||
|
every 30 seconds, including delayed Docker Desktop availability;
|
||||||
|
- `com.nodedc.telemetry-tunnel.local`: keepalive/reconnect and loopback-only
|
||||||
|
reverse forwarding, strict known-host verification;
|
||||||
|
- only broker, Timescale and normalizer are selected by Compose, no build or
|
||||||
|
image pull; existing database-bootstrap is an idempotent dependency;
|
||||||
|
- configuration backups outside Git; apply preserves canonical Core health;
|
||||||
|
- rollback restores declarations and credentials; it does not reset volumes or
|
||||||
|
stop Docker/unrelated containers. The restored broker declaration is applied
|
||||||
|
on subsequent reconciliation. Rollback rehearsal is not yet accepted.
|
||||||
|
|
||||||
|
`Install-NdcMissionCoreTelemetryRecovery.ps1` is included in the Windows agent
|
||||||
|
bundle. Loopback agent install/update invokes it. It installs one SYSTEM task
|
||||||
|
at boot and every minute, with no interactive-login requirement. Its script is
|
||||||
|
writable only by SYSTEM/Administrators. It starts only a stopped managed
|
||||||
|
Telegraf service, and only after the configured loopback endpoint is reachable.
|
||||||
|
A missing connection leaves the task retryable. It never restarts a healthy
|
||||||
|
agent or starts inference. Maintenance can disable this named task before an
|
||||||
|
intentional extended Telegraf stop; rollback restores its predecessor.
|
||||||
|
|
||||||
|
## Product status and refresh
|
||||||
|
|
||||||
|
Receiver failure now has `telemetry-receiver-unavailable`, distinct from stale
|
||||||
|
agent observations. Fleet contour, compute and network views show receiver
|
||||||
|
failure as an unconfirmed Worker state, not proof that its host is off. Visible
|
||||||
|
connectivity labels are consistently «В сети» / «Не в сети»; failure details
|
||||||
|
explain which part of the observation path failed. The
|
||||||
|
network view no longer paints the receiver green merely because the configured
|
||||||
|
source is agent-mqtt.
|
||||||
|
|
||||||
|
A responding Core with `recording_cache=capacity-pressure` is reachable with a
|
||||||
|
recording limitation. It is displayed as «В сети» with the limitation separately. This matters on the
|
||||||
|
operator host, where free space fell below the existing 2 GiB recording reserve
|
||||||
|
(about 1.8 GiB observed); no owner data was deleted.
|
||||||
|
|
||||||
|
The contour Refresh action now uses the canonical circular IconButton in the
|
||||||
|
outer window header, alongside expand/close. The body copy button is removed.
|
||||||
|
|
||||||
|
## Measured acceptance
|
||||||
|
|
||||||
|
- Fresh agent-mqtt data reached the canonical Core, with expected node identity.
|
||||||
|
- Native Windows service clean-stop test: automatic recovery in 51.7 s, new PID;
|
||||||
|
no manual Start-Service was needed (failure restoration path was not used).
|
||||||
|
- Dedicated tunnel SIGKILL: new tunnel process and new source observations in
|
||||||
|
10.43 s. The API's 30 s freshness window did not expire during this short cut.
|
||||||
|
- Normalizer stop: receiver-unavailable was observed, then automatic Compose
|
||||||
|
recovery and new source observations in 19.58 s.
|
||||||
|
- Tests are reproducible with `Test-NdcMissionCoreTelemetryRecovery.ps1` and
|
||||||
|
`scripts/check_telemetry_recovery.py`; they target telemetry only.
|
||||||
|
- 36 focused backend tests, Ruff, 984 frontend tests, TypeScript and production
|
||||||
|
build passed. Four installer/test PowerShell artifacts were parsed by the
|
||||||
|
native Windows parser; install/update hooks were not a clean-host rehearsal.
|
||||||
|
- Browser acceptance on canonical port 8000 confirms fresh Worker observations,
|
||||||
|
standardized connectivity labels and the circular Refresh control in the
|
||||||
|
window header (including its checking state).
|
||||||
|
|
||||||
|
## Qualification boundary
|
||||||
|
|
||||||
|
The accepted live profile is macOS operator session + Windows native telemetry
|
||||||
|
agent + the existing Tailscale trust relationship. Automatic recovery of the
|
||||||
|
three injected failures is proved. A full physical host power cycle, distinct
|
||||||
|
physical networks, extended network-loss soak and clean-host reinstall have
|
||||||
|
not been run in this increment. Tailscale may choose a direct encrypted path
|
||||||
|
when the peers happen to share a LAN; that does not reintroduce LAN addressing.
|
||||||
|
|
||||||
|
Mac LaunchAgents and Docker Desktop start after operator login, not before
|
||||||
|
macOS login. Windows Tailscale/SSH/telemetry recovery use system services/tasks.
|
||||||
|
Worker Docker computation still has its separate interactive-session lifecycle;
|
||||||
|
this work does not claim all GPU jobs should auto-resume after power loss.
|
||||||
|
Linux agent lifecycle and an unattended headless compute installation need a
|
||||||
|
separate qualified OS profile. Do not label the whole system universally
|
||||||
|
portable or cold-boot accepted from these component-level tests.
|
||||||
|
|
||||||
|
## Upstream references
|
||||||
|
|
||||||
|
- [Telegraf 1.38.4 output lifecycle](https://github.com/influxdata/telegraf/blob/v1.38.4/models/running_output.go)
|
||||||
|
- [Telegraf 1.38.4 MQTT connect](https://github.com/influxdata/telegraf/blob/v1.38.4/plugins/outputs/mqtt/mqtt.go)
|
||||||
|
- [Windows Tailscale unattended](https://tailscale.com/docs/how-to/run-unattended)
|
||||||
|
- [Docker Desktop login startup](https://docs.docker.com/desktop/settings-and-maintenance/settings/)
|
||||||
@@ -3,11 +3,11 @@ import {LoadingRegion,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui
|
|||||||
import {perform,type Sensor,type SensorTransport} from './contracts';
|
import {perform,type Sensor,type SensorTransport} from './contracts';
|
||||||
import {PointViewport} from './PointViewport';
|
import {PointViewport} from './PointViewport';
|
||||||
interface Telemetry {points?:number[];motion?:Record<string,{x:number;y:number;z:number}>;acquisition?:string;frame?:{observed_at:string}}
|
interface Telemetry {points?:number[];motion?:Record<string,{x:number;y:number;z:number}>;acquisition?:string;frame?:{observed_at:string}}
|
||||||
export function LiveViewport({device,layer,transport,failure,title,note,inactiveMessage}:{device:Sensor;layer:string;transport:SensorTransport;failure:(e:unknown)=>void;title?:string;note?:string;inactiveMessage?:string}){
|
export function LiveViewport({device,layer,transport,failure,title,note,inactiveMessage,enabled=true}:{enabled?:boolean;device:Sensor;layer:string;transport:SensorTransport;failure:(e:unknown)=>void;title?:string;note?:string;inactiveMessage?:string}){
|
||||||
const video=useRef<HTMLVideoElement>(null);const frame=useRef<HTMLDivElement>(null);const [expanded,setExpanded]=useState(false);
|
const video=useRef<HTMLVideoElement>(null);const frame=useRef<HTMLDivElement>(null);const [expanded,setExpanded]=useState(false);
|
||||||
const [state,setState]=useState('Подключение просмотра');const [telemetry,setTelemetry]=useState<Telemetry>({});
|
const [state,setState]=useState('Подключение просмотра');const [telemetry,setTelemetry]=useState<Telemetry>({});
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
if(device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
|
if(!enabled||device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
|
||||||
let cancelled=false;let terminal=false;let peerID='';let timer:ReturnType<typeof setInterval>|undefined;let last=Date.now();let frameHandle:number|undefined;let decoded=0;
|
let cancelled=false;let terminal=false;let peerID='';let timer:ReturnType<typeof setInterval>|undefined;let last=Date.now();let frameHandle:number|undefined;let decoded=0;
|
||||||
const live=()=>{if(cancelled||terminal)return;last=Date.now();setState(device.playback_id?'Исходная запись':'Прямой эфир');};
|
const live=()=>{if(cancelled||terminal)return;last=Date.now();setState(device.playback_id?'Исходная запись':'Прямой эфир');};
|
||||||
const frameSeen=()=>{live();if(!cancelled)frameHandle=video.current?.requestVideoFrameCallback(frameSeen);};
|
const frameSeen=()=>{live();if(!cancelled)frameHandle=video.current?.requestVideoFrameCallback(frameSeen);};
|
||||||
@@ -28,9 +28,9 @@ export function LiveViewport({device,layer,transport,failure,title,note,inactive
|
|||||||
await pc.setRemoteDescription({type:'answer',sdp:answer.sdp});
|
await pc.setRemoteDescription({type:'answer',sdp:answer.sdp});
|
||||||
}catch(e){if(!cancelled){terminal=true;setState('Просмотр недоступен');failure(e);}}}
|
}catch(e){if(!cancelled){terminal=true;setState('Просмотр недоступен');failure(e);}}}
|
||||||
void connect();return()=>{cancelled=true;if(timer)clearInterval(timer);if(frameHandle!==undefined)video.current?.cancelVideoFrameCallback(frameHandle);pc.close();if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);};
|
void connect();return()=>{cancelled=true;if(timer)clearInterval(timer);if(frameHandle!==undefined)video.current?.cancelVideoFrameCallback(frameHandle);pc.close();if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);};
|
||||||
},[device.id,device.snapshot.context.session_id,device.snapshot.acquisition,device.playback_id,layer,transport]);
|
},[enabled,device.id,device.snapshot.context.session_id,device.snapshot.acquisition,device.playback_id,layer,transport]);
|
||||||
useEffect(()=>{const escape=(e:KeyboardEvent)=>{if(e.key==='Escape')setExpanded(false);};document.addEventListener('keydown',escape);return()=>document.removeEventListener('keydown',escape);},[]);
|
useEffect(()=>{const escape=(e:KeyboardEvent)=>{if(e.key==='Escape')setExpanded(false);};document.addEventListener('keydown',escape);return()=>document.removeEventListener('keydown',escape);},[]);
|
||||||
const active=device.snapshot.acquisition==='streaming';
|
const active=enabled&&device.snapshot.acquisition==='streaming';
|
||||||
return <div ref={frame} className={expanded?'sensor-viewer sensor-viewer-expanded':'sensor-viewer'}><SettingsCard title={title??(layer==='points'?'Облако точек':layer==='motion'?'Движение':({color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2'}[layer]??layer))} actions={<><StatusBadge tone={active&&(state==='Прямой эфир'||state==='Исходная запись')?'success':'neutral'}>{state}</StatusBadge><IconButton label={expanded?'Восстановить размер просмотра':'Развернуть просмотр'} onClick={()=>setExpanded(!expanded)}><Icon name={expanded?'minimize':'expand'}/></IconButton></>}>
|
return <div ref={frame} className={expanded?'sensor-viewer sensor-viewer-expanded':'sensor-viewer'}><SettingsCard title={title??(layer==='points'?'Облако точек':layer==='motion'?'Движение':({color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2'}[layer]??layer))} actions={<><StatusBadge tone={active&&(state==='Прямой эфир'||state==='Исходная запись')?'success':'neutral'}>{state}</StatusBadge><IconButton label={expanded?'Восстановить размер просмотра':'Развернуть просмотр'} onClick={()=>setExpanded(!expanded)}><Icon name={expanded?'minimize':'expand'}/></IconButton></>}>
|
||||||
{!active?<p>{inactiveMessage??'Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.'}</p>:<>
|
{!active?<p>{inactiveMessage??'Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.'}</p>:<>
|
||||||
<LoadingRegion loading={state==='Подключение просмотра'||state==='Ожидание изображения'} label={state}>
|
<LoadingRegion loading={state==='Подключение просмотра'||state==='Ожидание изображения'} label={state}>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {LiveViewport} from './LiveViewport';
|
|||||||
export function SensorDetail({device,transport,back,refresh,failure,enabled=true}:{enabled?:boolean;device:Sensor;transport:SensorTransport;back:()=>void;refresh:()=>Promise<void>;failure:(e:unknown)=>void}){
|
export function SensorDetail({device,transport,back,refresh,failure,enabled=true}:{enabled?:boolean;device:Sensor;transport:SensorTransport;back:()=>void;refresh:()=>Promise<void>;failure:(e:unknown)=>void}){
|
||||||
const [detail,setDetail]=useState<Sensor|null>(null);const [selected,setSelected]=useState<Record<string,string>>({});const [layer,setLayer]=useState('color');const [pendingAction,setPendingAction]=useState<string|null>(null);const pending=pendingAction!==null;const [option,setOption]=useState('');const [optionValue,setOptionValue]=useState('');
|
const [detail,setDetail]=useState<Sensor|null>(null);const [selected,setSelected]=useState<Record<string,string>>({});const [layer,setLayer]=useState('color');const [pendingAction,setPendingAction]=useState<string|null>(null);const pending=pendingAction!==null;const [option,setOption]=useState('');const [optionValue,setOptionValue]=useState('');
|
||||||
async function load(){const value=await perform<Sensor>(transport,device,'details');setDetail(value);return value;}
|
async function load(){const value=await perform<Sensor>(transport,device,'details');setDetail(value);return value;}
|
||||||
useEffect(()=>{let live=true;if(device.prepared)void load().then(value=>{if(live){const selection:Record<string,string>={};for(const p of value.profiles??[])if(value.defaults?.includes(p.id))selection[p.stream+':'+p.index]=p.id;setSelected(selection);}}).catch(failure);return()=>{live=false;};},[device.id,device.prepared,device.snapshot.context.session_id]);
|
useEffect(()=>{let live=true;if(enabled&&device.online&&device.prepared)void load().then(value=>{if(live){const selection:Record<string,string>={};for(const p of value.profiles??[])if(value.defaults?.includes(p.id))selection[p.stream+':'+p.index]=p.id;setSelected(selection);}}).catch(failure);return()=>{live=false;};},[enabled,device.online,device.id,device.prepared,device.snapshot.context.session_id]);
|
||||||
async function act(action:string,parameters:Record<string,unknown>={}){if(pending||!enabled)return;failure(null);setPendingAction(action==='start'?(parameters.record?'record.start':'preview.start'):action==='replay'?'replay:'+String(parameters.recording_id):action);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPendingAction(null);}}
|
async function act(action:string,parameters:Record<string,unknown>={}){if(pending||!enabled)return;failure(null);setPendingAction(action==='start'?(parameters.record?'record.start':'preview.start'):action==='replay'?'replay:'+String(parameters.recording_id):action);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPendingAction(null);}}
|
||||||
const supported=(detail?.profiles??[]).filter(p=>['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)&&(p.stream!=='infrared'||[1,2].includes(p.index)));
|
const supported=(detail?.profiles??[]).filter(p=>['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)&&(p.stream!=='infrared'||[1,2].includes(p.index)));
|
||||||
const groups=[...new Set(supported.map(p=>p.stream+':'+p.index))];
|
const groups=[...new Set(supported.map(p=>p.stream+':'+p.index))];
|
||||||
@@ -21,10 +21,10 @@ export function SensorDetail({device,transport,back,refresh,failure,enabled=true
|
|||||||
{device.snapshot.acquisition==='stopping'&&<p>Сохранение исходной записи и проверка целостности.</p>}
|
{device.snapshot.acquisition==='stopping'&&<p>Сохранение исходной записи и проверка целостности.</p>}
|
||||||
{device.recording&&device.snapshot.acquisition!=='stopping'&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
|
{device.recording&&device.snapshot.acquisition!=='stopping'&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
{detail&&<><Select label="Слой камеры" value={layer} options={available.map(value=>({value,label:videoNames[value]??value}))} onChange={setLayer}/><LiveViewport device={device} layer={layer} transport={transport} failure={failure}/>
|
{detail&&<><Select label="Слой камеры" value={layer} options={available.map(value=>({value,label:videoNames[value]??value}))} onChange={setLayer}/><LiveViewport enabled={enabled&&device.online} device={device} layer={layer} transport={transport} failure={failure}/>
|
||||||
<SettingsCard title="Профили потоков" description="Применяются при следующем запуске захвата."><div className="sensor-fields">{groups.map(group=><Select key={group} label={group} value={selected[group]??''} options={[{value:'',label:'Выключен'},...(detail.profiles??[]).filter(p=>p.stream+':'+p.index===group&&['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)).map(p=>({value:p.id,label:`${p.width?p.width+' × '+p.height+' · ':''}${p.fps} Гц · ${p.format}`}))]} onChange={value=>setSelected(v=>({...v,[group]:value}))} disabled={!enabled||pending||active}/>)}</div></SettingsCard>
|
<SettingsCard title="Профили потоков" description="Применяются при следующем запуске захвата."><div className="sensor-fields">{groups.map(group=><Select key={group} label={group} value={selected[group]??''} options={[{value:'',label:'Выключен'},...(detail.profiles??[]).filter(p=>p.stream+':'+p.index===group&&['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)).map(p=>({value:p.id,label:`${p.width?p.width+' × '+p.height+' · ':''}${p.fps} Гц · ${p.format}`}))]} onChange={value=>setSelected(v=>({...v,[group]:value}))} disabled={!enabled||pending||active}/>)}</div></SettingsCard>
|
||||||
<SettingsCard title="Параметры камеры"><div className="sensor-fields"><Select searchable label="Параметр" value={option} options={(detail.options??[]).map(v=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<><TextField type="number" label={`${currentOption.min} … ${currentOption.max}${currentOption.read_only?' · только чтение':''}`} min={currentOption.min} max={currentOption.max} step={currentOption.step||'any'} value={optionValue} onChange={e=>setOptionValue(e.target.value)} disabled={!enabled||pending||currentOption.read_only||!!device.playback_id}/><Button disabled={!enabled||pending||currentOption.read_only||!!device.playback_id||optionValue===''} loading={pendingAction==='option'} onClick={()=>void act('option',{id:option,value:Number(optionValue)})}>Применить параметр</Button></>}</div></SettingsCard>
|
<SettingsCard title="Параметры камеры"><div className="sensor-fields"><Select searchable label="Параметр" value={option} options={(detail.options??[]).map(v=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<><TextField type="number" label={`${currentOption.min} … ${currentOption.max}${currentOption.read_only?' · только чтение':''}`} min={currentOption.min} max={currentOption.max} step={currentOption.step||'any'} value={optionValue} onChange={e=>setOptionValue(e.target.value)} disabled={!enabled||pending||currentOption.read_only||!!device.playback_id}/><Button disabled={!enabled||pending||currentOption.read_only||!!device.playback_id||optionValue===''} loading={pendingAction==='option'} onClick={()=>void act('option',{id:option,value:Number(optionValue)})}>Применить параметр</Button></>}</div></SettingsCard>
|
||||||
<SettingsCard title="Исходные записи">{detail.recordings?.length?detail.recordings.map(item=><div className="sensor-record" key={item.id}><span>{new Date(item.started_at).toLocaleString('ru-RU')}</span><span>{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='finalizing'?'Сохранение записи':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}</span><span>{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}</span><IconButton label={`Открыть запись: ${new Date(item.started_at).toLocaleString('ru-RU')}`} disabled={!enabled||pending||active||item.state!=='complete'} loading={pendingAction==='replay:'+item.id} onClick={()=>void act('replay',{recording_id:item.id})}><Icon name="eye"/></IconButton></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
|
<SettingsCard title="Исходные записи">{detail.recordings?.length?detail.recordings.map(item=><div className="sensor-record" key={item.id}><span>{new Date(item.started_at).toLocaleString('ru-RU')}</span><span>{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='finalizing'?'Сохранение записи':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}</span><span>{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}</span><IconButton label={`Открыть запись: ${new Date(item.started_at).toLocaleString('ru-RU')}`} disabled={!enabled||pending||active||item.state!=='complete'} loading={pendingAction==='replay:'+item.id} onClick={()=>void act('replay',{recording_id:item.id})}><Icon name="eye"/></IconButton></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
|
||||||
{!detail&&<p>{device.prepared?'Получение возможностей камеры…':'Подготовьте устройство в списке.'}</p>}
|
{!detail&&<p>{!enabled||!device.online?'Возможности камеры будут прочитаны после подключения.':device.prepared?'Получение возможностей камеры…':'Подготовьте устройство в списке.'}</p>}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useCallback,useEffect,useRef,useState} from 'react';
|
import {useCallback,useEffect,useRef,useState,type ReactNode} from 'react';
|
||||||
import {LoadingRegion,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
|
import {LoadingRegion,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||||
import {perform,enrolledDevice,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
|
import {perform,enrolledDevice,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
|
||||||
import {SensorDetail} from './SensorDetail';
|
import {SensorDetail} from './SensorDetail';
|
||||||
@@ -11,20 +11,21 @@ import type {RerunHostFactory} from './rerunHost';
|
|||||||
import './sensors.css';
|
import './sensors.css';
|
||||||
import {BoardSections,type BoardSectionsProps} from './BoardSections';
|
import {BoardSections,type BoardSectionsProps} from './BoardSections';
|
||||||
import {WirelessEnrollmentWindow} from './WirelessEnrollmentWindow';
|
import {WirelessEnrollmentWindow} from './WirelessEnrollmentWindow';
|
||||||
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[],board}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[];board?:BoardSectionsProps}){
|
export interface SensorBoardContent {settings:ReactNode;devices:ReactNode;refresh:()=>Promise<void>;addDevice:()=>void;canAddDevice:boolean;showingSavedDevices:boolean}
|
||||||
|
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[],board,renderBoard,readOfflineInventory=false}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[];board?:BoardSectionsProps;renderBoard?:(view:SensorBoardContent)=>ReactNode;readOfflineInventory?:boolean}){
|
||||||
const [adding,setAdding]=useState(false);
|
const [adding,setAdding]=useState(false);
|
||||||
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<ReadonlyMap<string,string>>(()=>new Map());const activeActions=useRef(new Map<string,string>());const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
|
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<ReadonlyMap<string,string>>(()=>new Map());const activeActions=useRef(new Map<string,string>());const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
|
||||||
const failure=useCallback((e:unknown)=>{setError(e===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
|
const failure=useCallback((e:unknown)=>{setError(e===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
|
||||||
const refresh=useCallback(async()=>{if(!enabled){setFresh(false);return;}try{const value=await transport.inventory();setInventory(value);setFresh(value.fresh!==false);}catch(e){setFresh(false);failure(e);}},[transport,enabled,failure]);
|
const refresh=useCallback(async()=>{if(!enabled&&!readOfflineInventory){setFresh(false);return;}try{const value=await transport.inventory();setInventory(value);setFresh(enabled&&value.fresh!==false);}catch(e){setFresh(false);failure(e);}},[transport,enabled,readOfflineInventory,failure]);
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
if(!enabled){setFresh(false);return;}
|
if(!enabled&&!readOfflineInventory){setFresh(false);return;}
|
||||||
let active=true;let fallback:ReturnType<typeof setInterval>|undefined;
|
let active=true;let fallback:ReturnType<typeof setInterval>|undefined;
|
||||||
const unavailable=()=>{if(!active)return;setFresh(false);if(!fallback)fallback=setInterval(()=>void refresh(),3000);};
|
const unavailable=()=>{if(!active)return;setFresh(false);if(!fallback)fallback=setInterval(()=>void refresh(),3000);};
|
||||||
void refresh();
|
void refresh();
|
||||||
const close=transport.subscribe?.(value=>{if(!active)return;if(fallback){clearInterval(fallback);fallback=undefined;}setInventory(value);setFresh(value.fresh!==false);},unavailable);
|
const close=transport.subscribe?.(value=>{if(!active)return;if(fallback){clearInterval(fallback);fallback=undefined;}setInventory(value);setFresh(enabled&&value.fresh!==false);},unavailable);
|
||||||
if(!close)unavailable();
|
if(!close)unavailable();
|
||||||
return()=>{active=false;close?.();if(fallback)clearInterval(fallback);};
|
return()=>{active=false;close?.();if(fallback)clearInterval(fallback);};
|
||||||
},[transport,enabled,refresh]);
|
},[transport,enabled,readOfflineInventory,refresh]);
|
||||||
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){
|
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){
|
||||||
if(activeActions.current.has(device.id))return;
|
if(activeActions.current.has(device.id))return;
|
||||||
activeActions.current.set(device.id,action);setBusy(new Map(activeActions.current));setError('');
|
activeActions.current.set(device.id,action);setBusy(new Map(activeActions.current));setError('');
|
||||||
@@ -41,25 +42,27 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
|||||||
setInventory(value);setFresh(true);setSelected(target.id);setAdding(false);
|
setInventory(value);setFresh(true);setSelected(target.id);setAdding(false);
|
||||||
}
|
}
|
||||||
const device=inventory?.items.find(v=>v.id===selected);
|
const device=inventory?.items.find(v=>v.id===selected);
|
||||||
const connected=inventory?.items.filter(v=>v.online||sensorContribution(contributions,v)?.retainOffline)??[];
|
const connected=inventory?.items.filter(v=>readOfflineInventory||v.online||sensorContribution(contributions,v)?.retainOffline)??[];
|
||||||
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
|
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
|
||||||
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
|
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
|
||||||
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
|
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
|
||||||
const inventoryView=<>
|
const inventoryView=<>
|
||||||
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&wirelessContributions(contributions).length>0&&<IconButton label="Подключить беспроводное устройство к БК" disabled={!enabled} onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>}<IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>{void refresh();}}><Icon name="refresh"/></IconButton></div></div>
|
{!renderBoard&&<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&wirelessContributions(contributions).length>0&&<IconButton label="Подключить беспроводное устройство к БК" disabled={!enabled} onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>}<IconButton label="Обновить устройства" disabled={!enabled&&!readOfflineInventory} onClick={()=>{void refresh();}}><Icon name="refresh"/></IconButton></div></div>}
|
||||||
{!inventory?<LoadingRegion loading label="Получение устройств БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||||
const operation=inventory.operations?.find(v=>v.device_id===item.id&&['queued','running'].includes(v.state));const busy=!!operation||localBusy.has(item.id);
|
const operation=enabled&&fresh?inventory?.operations?.find(v=>v.device_id===item.id&&['queued','running'].includes(v.state)):undefined;const busy=!!operation||localBusy.has(item.id);
|
||||||
const pending=localBusy.get(item.id)??operation?.action_id;
|
const pending=localBusy.get(item.id)??operation?.action_id;
|
||||||
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
||||||
const prep=devicePreparation(inventory,item);
|
const prep=devicePreparation(inventory!,item);
|
||||||
const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?(operation?.action_id==='prepare'?'Подготовка устройства':'Выполняется команда'):status.label;
|
const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?(operation?.action_id==='prepare'?'Подготовка устройства':'Выполняется команда'):status.label;
|
||||||
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy&&pending==='prepare'?preparationProgress(prep,label):undefined} status={<StatusBadge variant={configured?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured?null:label}</StatusBadge>} actions={<><SensorRowActions device={item} actions={sensorContribution(contributions,item)?.rowActions?.(item)} enabled={enabled&&fresh} busy={busy} pending={pending} perform={(name,parameters)=>void action(item,name,parameters)}/>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.initializable===false||item.preparation_safe===false||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}{sensorContribution(contributions,item)?.detailLabel?<Button disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}>{sensorContribution(contributions,item)?.detailLabel}</Button>:<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton>}</>}/></li>;})}</ResourceList>}
|
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy&&pending==='prepare'?preparationProgress(prep,label):undefined} status={<StatusBadge variant={configured?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured?null:label}</StatusBadge>} actions={<><SensorRowActions device={item} actions={sensorContribution(contributions,item)?.rowActions?.(item)} enabled={enabled&&fresh} busy={busy} pending={pending} perform={(name,parameters)=>void action(item,name,parameters)}/>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.initializable===false||item.preparation_safe===false||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}{sensorContribution(contributions,item)?.detailLabel?<Button disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}>{sensorContribution(contributions,item)?.detailLabel}</Button>:<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton>}</>}/></li>;})}</ResourceList>
|
||||||
<SensorPreparations inventory={inventory}/>
|
{!inventory&&enabled&&!error?<LoadingRegion loading label="Получение устройств БК"/>:!enabled?(!renderBoard?<SettingsCard title="БК недоступен" description="Показаны последние сохранённые устройства. Их состояние обновится после восстановления связи."/>:null):inventory&&connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:null}
|
||||||
|
<SensorPreparations inventory={enabled&&fresh?inventory:null}/>
|
||||||
</>;
|
</>;
|
||||||
const boardSettings=contributions.filter(value=>value.BoardSettings&&contributions.filter(other=>other.kind===value.kind).length===1);
|
const boardSettings=contributions.filter(value=>value.BoardSettings&&contributions.filter(other=>other.kind===value.kind).length===1);
|
||||||
return <div className="sensor-workspace">{device?Detail?<Detail key={device.snapshot.context.session_id} enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:(board?<BoardSections {...board} settings={<div className="sensor-content">{boardSettings.map(contribution=>{const View=contribution.BoardSettings!;return <View key={contribution.kind} inventory={inventory} transport={transport} enabled={enabled&&fresh} refresh={refresh} failure={failure} openDevice={setSelected}/>;})}{!boardSettings.length&&<p>Для подключённых устройств общие настройки пока недоступны.</p>}</div>} devices={inventoryView}/>:inventoryView)}
|
const settingsView=<div className="sensor-content">{boardSettings.map(contribution=>{const View=contribution.BoardSettings!;return <View key={contribution.kind} inventory={inventory} transport={transport} enabled={enabled&&fresh} refresh={refresh} failure={failure} openDevice={setSelected}/>;})}{!boardSettings.length&&<p>Для подключённых устройств общие настройки пока недоступны.</p>}</div>;
|
||||||
|
return <div className="sensor-workspace">{device?Detail?<Detail key={device.snapshot.context.session_id} enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:(renderBoard?renderBoard({settings:settingsView,devices:inventoryView,showingSavedDevices:connected.length>0&&(!enabled||!fresh),refresh,addDevice:()=>setAdding(true),canAddDevice:!!transport.enrollment&&wirelessContributions(contributions).length>0}):board?<BoardSections {...board} settings={settingsView} devices={inventoryView}/>:inventoryView)}
|
||||||
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={localBusy.has(editing?.id??'')} onClick={()=>setEditing(null)}>Отмена</Button><Button loading={localBusy.get(editing?.id??'')==='rename'} disabled={localBusy.has(editing?.id??'')||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={localBusy.has(editing?.id??'')}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={localBusy.has(editing?.id??'')||!enabled||!fresh||!editingCurrent?.online||editingCurrent?.initializable===false||editingCurrent?.preparation_safe===false||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
|
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={localBusy.has(editing?.id??'')} onClick={()=>setEditing(null)}>Отмена</Button><Button loading={localBusy.get(editing?.id??'')==='rename'} disabled={localBusy.has(editing?.id??'')||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={localBusy.has(editing?.id??'')}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={localBusy.has(editing?.id??'')||!enabled||!fresh||!editingCurrent?.online||editingCurrent?.initializable===false||editingCurrent?.preparation_safe===false||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
|
||||||
{adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}} onComplete={completeEnrollment}/>}
|
{adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow enabled={enabled} contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}} onComplete={completeEnrollment}/>}
|
||||||
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {Select,Window,WindowFooterActions} from '@nodedc/ui-react';
|
import {Select,SettingsCard,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||||
import {wirelessContributions,type SensorEnrollmentProps,type SensorUiContribution} from './extensions';
|
import {wirelessContributions,type SensorEnrollmentProps,type SensorUiContribution} from './extensions';
|
||||||
|
|
||||||
export function WirelessEnrollmentWindow({contributions,transport,onClose,onChange,onComplete}:{
|
export function WirelessEnrollmentWindow({contributions,transport,onClose,onChange,onComplete,enabled=true}:{
|
||||||
contributions:readonly SensorUiContribution[];
|
contributions:readonly SensorUiContribution[];
|
||||||
transport:SensorEnrollmentProps['transport'];onClose:()=>void;onChange:()=>void;
|
transport:SensorEnrollmentProps['transport'];onClose:()=>void;onChange:()=>void;
|
||||||
onComplete:SensorEnrollmentProps['onComplete'];
|
onComplete:SensorEnrollmentProps['onComplete'];
|
||||||
|
enabled?:boolean;
|
||||||
}) {
|
}) {
|
||||||
const [selected,setSelected]=useState('');
|
const [selected,setSelected]=useState('');
|
||||||
const supported=wirelessContributions(contributions);
|
const supported=wirelessContributions(contributions);
|
||||||
const Enrollment=supported.find(value=>value.kind===selected)?.wirelessEnrollment?.View;
|
const Enrollment=supported.find(value=>value.kind===selected)?.wirelessEnrollment?.View;
|
||||||
const renderWindow:SensorEnrollmentProps['renderWindow']=({content,actions,busy=false})=>(
|
const renderWindow:SensorEnrollmentProps['renderWindow']=({content,actions,busy=false})=>(
|
||||||
<Window open title="Подключение беспроводных устройств к БК" onClose={onClose}
|
<Window open title="Подключение беспроводных устройств к БК" closeOnBackdrop={false} closeOnEscape={!busy} onClose={()=>{if(!busy)onClose();}}
|
||||||
footer={actions?<WindowFooterActions style={{width:'100%'}}>{actions}</WindowFooterActions>:undefined}>
|
footer={actions?<WindowFooterActions style={{width:'100%'}}>{actions}</WindowFooterActions>:undefined}>
|
||||||
<div className="sensor-content">
|
<div className="sensor-content">
|
||||||
<Select label="Выбор поддерживаемого устройства" value={selected} disabled={busy}
|
<Select label="Тип устройства" value={selected} disabled={busy}
|
||||||
options={[{value:'',label:'Выберите поддерживаемое устройство',disabled:true},
|
options={[{value:'',label:'Выберите поддерживаемое устройство',disabled:true},
|
||||||
...supported.map(value=>({value:value.kind,label:value.wirelessEnrollment!.label}))]}
|
...supported.map(value=>({value:value.kind,label:value.wirelessEnrollment!.label}))]}
|
||||||
onChange={setSelected}/>
|
onChange={setSelected}/>
|
||||||
@@ -22,5 +23,6 @@ export function WirelessEnrollmentWindow({contributions,transport,onClose,onChan
|
|||||||
</div>
|
</div>
|
||||||
</Window>
|
</Window>
|
||||||
);
|
);
|
||||||
|
if(Enrollment&&!enabled)return renderWindow({content:<SettingsCard title="Бортовой компьютер не в сети" description="Для подключения беспроводного устройства включите привязанный БК и дождитесь связи с Mission Core."/>});
|
||||||
return Enrollment?<Enrollment key={selected} transport={transport} onClose={onClose} onChange={onChange} onComplete={onComplete} renderWindow={renderWindow}/>:renderWindow({content:null});
|
return Enrollment?<Enrollment key={selected} transport={transport} onClose={onClose} onChange={onChange} onComplete={onComplete} renderWindow={renderWindow}/>:renderWindow({content:null});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ export interface SensorObservationProps {
|
|||||||
export interface SensorObservationContribution {
|
export interface SensorObservationContribution {
|
||||||
views:(device:Sensor)=>SensorObservationView[];
|
views:(device:Sensor)=>SensorObservationView[];
|
||||||
Session:ComponentType<SensorObservationProps>;
|
Session:ComponentType<SensorObservationProps>;
|
||||||
|
catalog?:{label:string;views:SensorObservationView[]};
|
||||||
}
|
}
|
||||||
export const sensorLayerLabels:Record<string,string>={color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2',points:'Облако точек',motion:'Движение'};
|
export const sensorLayerLabels:Record<string,string>={color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2',points:'Облако точек',motion:'Движение'};
|
||||||
export function cameraObservationViews(device:Sensor):SensorObservationView[] {
|
export function cameraObservationViews(device:Sensor):SensorObservationView[] {
|
||||||
const layers=device.layers.filter(value=>Object.hasOwn(sensorLayerLabels,value)).map(value=>({value,label:sensorLayerLabels[value]}));
|
const published=device.layers.length?device.layers:/^RealSense D455$/i.test(device.model??'')?['color','depth','infrared1','infrared2','points','motion']:[];
|
||||||
|
const layers=published.filter(value=>Object.hasOwn(sensorLayerLabels,value)).map(value=>({value,label:sensorLayerLabels[value]}));
|
||||||
layers.sort((a,b)=>Number(b.value==='color')-Number(a.value==='color'));
|
layers.sort((a,b)=>Number(b.value==='color')-Number(a.value==='color'));
|
||||||
return layers.length?[{id:'camera',label:device.name,layers}]:[];
|
return layers.length?[{id:'camera',label:device.name,layers}]:[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type {Sensor} from './contracts';
|
||||||
|
import type {SensorUiContribution} from './extensions';
|
||||||
|
|
||||||
|
/** Supported view slots are not physical devices and carry no command authority. */
|
||||||
|
export function unboundObservationViews(devices:Sensor[],contributions:readonly SensorUiContribution[]) {
|
||||||
|
return contributions.flatMap(contribution=>{
|
||||||
|
const catalog=contribution.observation?.catalog;
|
||||||
|
if(!catalog||devices.some(device=>device.kind===contribution.kind)||contributions.filter(other=>other.kind===contribution.kind).length!==1)return [];
|
||||||
|
return catalog.views.map(view=>({key:JSON.stringify(['catalog',contribution.kind,view.id]),catalog:catalog.label,view}));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,5 +8,5 @@ export const xgridsK1SensorUi:SensorUiContribution={
|
|||||||
kind:'k1', Detail:K1Detail, icon:'network', retainOffline:true, supportsPreparation:false,
|
kind:'k1', Detail:K1Detail, icon:'network', retainOffline:true, supportsPreparation:false,
|
||||||
supportsRenaming:false,status:k1Status,
|
supportsRenaming:false,status:k1Status,
|
||||||
wirelessEnrollment:{label:'XGRIDS LixelKity K1',View:DeviceEnrollmentWindow},
|
wirelessEnrollment:{label:'XGRIDS LixelKity K1',View:DeviceEnrollmentWindow},
|
||||||
observation:{Session:K1Observation,views:device=>[{id:'points',label:`${device.name} Point Cloud · 3D`},{id:'plan',label:`${device.name} Point Cloud · Сверху`},{id:'camera',label:`${device.name} Cam Right`}]},
|
observation:{Session:K1Observation,catalog:{label:'XGRIDS LixelKity K1',views:[{id:'points',label:'XGRIDS K1 · Облако точек · 3D'},{id:'plan',label:'XGRIDS K1 · Облако точек · Сверху'},{id:'camera',label:'XGRIDS K1 · Камера'}]},views:device=>[{id:'points',label:`${device.name} Point Cloud · 3D`},{id:'plan',label:`${device.name} Point Cloud · Сверху`},{id:'camera',label:`${device.name} Cam Right`}]},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Bounded operator acceptance of installed telemetry recovery, no inference jobs.
|
||||||
|
|
||||||
|
Each case injects one failure in an exact Mission Core telemetry component.
|
||||||
|
An existing service's automatic supervisor must restore fresh worker telemetry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
DOCKER = "/Applications/Docker.app/Contents/Resources/bin/docker"
|
||||||
|
BASE = "http://127.0.0.1:8000/api/v1/system/contours/worker-006/telemetry?history=1"
|
||||||
|
|
||||||
|
|
||||||
|
def read():
|
||||||
|
return json.load(urllib.request.urlopen(BASE, timeout=5))
|
||||||
|
|
||||||
|
|
||||||
|
def tunnel_pid():
|
||||||
|
r = subprocess.run(
|
||||||
|
["launchctl", "print", f"gui/{os.getuid()}/com.nodedc.telemetry-tunnel.local"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
m = re.search(r"\bpid = (\d+)", r.stdout)
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("case", choices=["tunnel", "receiver"])
|
||||||
|
args = parser.parse_args()
|
||||||
|
baseline = read()["connection"]
|
||||||
|
if not baseline["reachable"] or not baseline["identity_matches"]:
|
||||||
|
raise RuntimeError("Fresh confirmed baseline required")
|
||||||
|
started = time.monotonic()
|
||||||
|
baseline_time = datetime.fromisoformat(baseline["observed_at_utc"].replace("Z", "+00:00"))
|
||||||
|
before_pid = tunnel_pid()
|
||||||
|
if args.case == "tunnel":
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"launchctl",
|
||||||
|
"kill",
|
||||||
|
"SIGKILL",
|
||||||
|
f"gui/{os.getuid()}/com.nodedc.telemetry-tunnel.local",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
subprocess.run(
|
||||||
|
[DOCKER, "stop", "--time", "5", "ndc-mission-core-telemetry-normalizer"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
seen = set()
|
||||||
|
while time.monotonic() - started < 90:
|
||||||
|
time.sleep(2)
|
||||||
|
try:
|
||||||
|
probe = read()["connection"]
|
||||||
|
code = probe.get("error_code")
|
||||||
|
seen.add(code or "fresh")
|
||||||
|
observed = datetime.fromisoformat(probe["observed_at_utc"].replace("Z", "+00:00"))
|
||||||
|
if (
|
||||||
|
probe["reachable"]
|
||||||
|
and probe["identity_matches"]
|
||||||
|
and (observed - baseline_time).total_seconds() >= 10
|
||||||
|
and time.monotonic() - started >= 10
|
||||||
|
and (
|
||||||
|
args.case != "tunnel"
|
||||||
|
or (tunnel_pid() is not None and tunnel_pid() != before_pid)
|
||||||
|
)
|
||||||
|
and (args.case != "receiver" or "telemetry-receiver-unavailable" in seen)
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"case": args.case,
|
||||||
|
"automaticRecovery": True,
|
||||||
|
"elapsedSeconds": round(time.monotonic() - started, 2),
|
||||||
|
"observedStates": sorted(seen),
|
||||||
|
"observedAt": probe["observed_at_utc"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except (OSError, ValueError):
|
||||||
|
seen.add("query-unavailable")
|
||||||
|
raise RuntimeError("Automatic telemetry recovery not accepted within 90 seconds")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Versioned macOS telemetry startup: plan/apply/rollback, no worker job launch.
|
||||||
|
|
||||||
|
The prepared stack owns credentials and volumes. SSH provides only a loopback
|
||||||
|
MQTT forward through an existing strictly pinned Tailscale SSH profile.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import plistlib
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from manage_mission_core_launch_agent import _wait_for_health, _write_atomic
|
||||||
|
from migrate_worker_tunnel_logs import reload_agent
|
||||||
|
|
||||||
|
from k1link.launchd_logs import launchd_log_path, prepare_launchd_log
|
||||||
|
from k1link.web.compute_contour_network import _replace_environment_value
|
||||||
|
|
||||||
|
STARTUP = "com.nodedc.telemetry-startup.local"
|
||||||
|
TUNNEL = "com.nodedc.telemetry-tunnel.local"
|
||||||
|
CORE = "com.nodedc.mission-core.local"
|
||||||
|
DOCKER = "/Applications/Docker.app/Contents/Resources/bin/docker"
|
||||||
|
|
||||||
|
|
||||||
|
def command(args, timeout=30):
|
||||||
|
# Never echo Compose output: expanded environments can contain credentials.
|
||||||
|
return subprocess.run(args, capture_output=True, timeout=timeout, check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def receiver_ready():
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen("http://127.0.0.1:18030/health", timeout=3) as r:
|
||||||
|
d = json.loads(r.read())
|
||||||
|
return (
|
||||||
|
d.get("ok") is True
|
||||||
|
and d.get("mqtt_connected") is True
|
||||||
|
and d.get("database_reachable") is True
|
||||||
|
)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def broker_loopback():
|
||||||
|
result = command(
|
||||||
|
[
|
||||||
|
DOCKER,
|
||||||
|
"inspect",
|
||||||
|
"--format",
|
||||||
|
"{{json .NetworkSettings.Ports}}",
|
||||||
|
"ndc-mission-core-mqtt-broker",
|
||||||
|
],
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
bindings = json.loads(result.stdout).get("1883/tcp", [])
|
||||||
|
return result.returncode == 0 and bindings == [{"HostIp": "127.0.0.1", "HostPort": "1883"}]
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile(stack: Path):
|
||||||
|
"""One bounded retry, repeated by launchd after delayed Docker/network start."""
|
||||||
|
if receiver_ready() and broker_loopback():
|
||||||
|
return "ready"
|
||||||
|
if command([DOCKER, "info", "--format", "{{.ServerVersion}}"], 10).returncode:
|
||||||
|
# Desktop's own login setting is not a dependency manager. No VM/resource
|
||||||
|
# setting is changed and no user inference job is started here.
|
||||||
|
command(["/usr/bin/open", "-g", "-a", "/Applications/Docker.app"], 10)
|
||||||
|
return "waiting-for-docker"
|
||||||
|
compose = [
|
||||||
|
DOCKER,
|
||||||
|
"compose",
|
||||||
|
"--project-directory",
|
||||||
|
str(stack),
|
||||||
|
"--env-file",
|
||||||
|
str(stack / ".env"),
|
||||||
|
"-f",
|
||||||
|
str(stack / "compose.yaml"),
|
||||||
|
]
|
||||||
|
if command([*compose, "config", "--quiet"], 20).returncode:
|
||||||
|
return "configuration-unavailable"
|
||||||
|
if command(
|
||||||
|
[
|
||||||
|
*compose,
|
||||||
|
"up",
|
||||||
|
"-d",
|
||||||
|
"--no-build",
|
||||||
|
"--pull",
|
||||||
|
"never",
|
||||||
|
"--wait",
|
||||||
|
"--wait-timeout",
|
||||||
|
"45",
|
||||||
|
"broker",
|
||||||
|
"timescale",
|
||||||
|
"normalizer",
|
||||||
|
],
|
||||||
|
60,
|
||||||
|
).returncode:
|
||||||
|
return "waiting-for-receiver"
|
||||||
|
return "ready" if receiver_ready() and broker_loopback() else "waiting-for-receiver"
|
||||||
|
|
||||||
|
|
||||||
|
def private_file(path):
|
||||||
|
if path.is_symlink() or not path.is_file() or path.stat().st_uid != os.getuid():
|
||||||
|
raise ValueError("Expected owned regular configuration file")
|
||||||
|
return path.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def plan(stack: Path, ssh_alias: str, repository: Path, agents: Path):
|
||||||
|
if not stack.is_absolute() or stack.resolve() != stack or not repository.is_absolute():
|
||||||
|
raise ValueError("Canonical absolute installation paths required")
|
||||||
|
if re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,80}", ssh_alias) is None:
|
||||||
|
raise ValueError("Invalid SSH profile")
|
||||||
|
for name in (
|
||||||
|
"compose.yaml",
|
||||||
|
"runtime/agents.json",
|
||||||
|
"runtime/mosquitto/acl",
|
||||||
|
"runtime/mosquitto/passwords",
|
||||||
|
):
|
||||||
|
private_file(stack / name)
|
||||||
|
env = private_file(stack / ".env")
|
||||||
|
public = dict(
|
||||||
|
line.split("=", 1)
|
||||||
|
for line in env.decode().splitlines()
|
||||||
|
if "=" in line and not line.startswith("#")
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
public.get("MISSIONCORE_MQTT_PORT", "1883") != "1883"
|
||||||
|
or public.get("MISSIONCORE_TELEMETRY_QUERY_PORT", "18030") != "18030"
|
||||||
|
):
|
||||||
|
raise ValueError("This deployment profile requires MQTT 1883 and query 18030")
|
||||||
|
# Verify the existing trust boundary; never enroll a host or accept a new key.
|
||||||
|
ssh = command(["/usr/bin/ssh", "-G", ssh_alias])
|
||||||
|
settings = dict(line.split(" ", 1) for line in ssh.stdout.decode().splitlines() if " " in line)
|
||||||
|
if (
|
||||||
|
ssh.returncode
|
||||||
|
or settings.get("stricthostkeychecking") not in ("true", "yes")
|
||||||
|
or not settings.get("hostname", "").endswith(".ts.net")
|
||||||
|
):
|
||||||
|
raise ValueError("A strictly pinned Tailscale SSH profile is required")
|
||||||
|
python = repository / ".venv/bin/python"
|
||||||
|
if not python.is_file() or not Path(DOCKER).is_file():
|
||||||
|
raise ValueError("Prepared application Python and Docker Desktop are required")
|
||||||
|
common = dict(
|
||||||
|
RunAtLoad=True, AbandonProcessGroup=False, ProcessType="Background", ExitTimeOut=15
|
||||||
|
)
|
||||||
|
startup = dict(
|
||||||
|
common,
|
||||||
|
Label=STARTUP,
|
||||||
|
StartInterval=30,
|
||||||
|
ProgramArguments=[
|
||||||
|
str(python),
|
||||||
|
str(repository / "scripts/manage_telemetry_startup.py"),
|
||||||
|
"reconcile",
|
||||||
|
"--stack-root",
|
||||||
|
str(stack),
|
||||||
|
],
|
||||||
|
StandardOutPath="/dev/null",
|
||||||
|
StandardErrorPath=str(launchd_log_path("telemetry-startup.log")),
|
||||||
|
)
|
||||||
|
tunnel = dict(
|
||||||
|
common,
|
||||||
|
Label=TUNNEL,
|
||||||
|
KeepAlive=True,
|
||||||
|
ThrottleInterval=10,
|
||||||
|
ProgramArguments=[
|
||||||
|
"/usr/bin/ssh",
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=yes",
|
||||||
|
"-o",
|
||||||
|
"ExitOnForwardFailure=yes",
|
||||||
|
"-o",
|
||||||
|
"ConnectTimeout=8",
|
||||||
|
"-o",
|
||||||
|
"ServerAliveInterval=10",
|
||||||
|
"-o",
|
||||||
|
"ServerAliveCountMax=3",
|
||||||
|
"-N",
|
||||||
|
"-T",
|
||||||
|
"-R",
|
||||||
|
"127.0.0.1:1883:127.0.0.1:1883",
|
||||||
|
ssh_alias,
|
||||||
|
],
|
||||||
|
StandardOutPath="/dev/null",
|
||||||
|
StandardErrorPath=str(launchd_log_path("telemetry-tunnel.log")),
|
||||||
|
)
|
||||||
|
core_path = agents / (CORE + ".plist")
|
||||||
|
core = plistlib.loads(private_file(core_path))
|
||||||
|
if core.get("Label") != CORE or core.get("WorkingDirectory") != str(repository):
|
||||||
|
raise ValueError("Canonical Mission Core installation changed")
|
||||||
|
core["EnvironmentVariables"]["MISSIONCORE_TELEMETRY_PLANE_ROOT"] = str(stack)
|
||||||
|
desired = {
|
||||||
|
stack / ".env": _replace_environment_value(
|
||||||
|
env.decode(), "MISSIONCORE_MQTT_BIND_ADDRESS", "127.0.0.1"
|
||||||
|
).encode()
|
||||||
|
}
|
||||||
|
for label, doc in ((STARTUP, startup), (TUNNEL, tunnel), (CORE, core)):
|
||||||
|
path = agents / (label + ".plist")
|
||||||
|
if path.exists() and plistlib.loads(private_file(path)).get("Label") != label:
|
||||||
|
raise ValueError("Foreign LaunchAgent at target path")
|
||||||
|
desired[path] = plistlib.dumps(doc, sort_keys=True)
|
||||||
|
changes = []
|
||||||
|
for path, data in desired.items():
|
||||||
|
before = private_file(path) if path.exists() else None
|
||||||
|
changes.append(
|
||||||
|
dict(
|
||||||
|
path=str(path),
|
||||||
|
before=hashlib.sha256(before).hexdigest() if before is not None else None,
|
||||||
|
after=hashlib.sha256(data).hexdigest(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
document = dict(
|
||||||
|
schema_version="missioncore.telemetry-startup-plan/v1",
|
||||||
|
changes=changes,
|
||||||
|
stack_root=str(stack),
|
||||||
|
ssh_profile=ssh_alias,
|
||||||
|
mqtt="loopback-over-ssh-tailscale",
|
||||||
|
startup="macOS user login; retry every 30s",
|
||||||
|
worker_jobs_started=False,
|
||||||
|
)
|
||||||
|
document["artifact_sha256"] = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
|
||||||
|
document["compose_sha256"] = hashlib.sha256((stack / "compose.yaml").read_bytes()).hexdigest()
|
||||||
|
document["sha256"] = hashlib.sha256(json.dumps(document, sort_keys=True).encode()).hexdigest()
|
||||||
|
return document, desired
|
||||||
|
|
||||||
|
|
||||||
|
def restore(backup: Path, agents: Path):
|
||||||
|
manifest = json.loads(private_file(backup / "manifest.json"))
|
||||||
|
for item in manifest["changes"]:
|
||||||
|
path = Path(item["path"])
|
||||||
|
current = hashlib.sha256(private_file(path)).hexdigest() if path.exists() else None
|
||||||
|
if current not in (item["before"], item["after"]):
|
||||||
|
raise ValueError("Installed configuration changed; refusing rollback")
|
||||||
|
for label in (STARTUP, TUNNEL):
|
||||||
|
command(["launchctl", "bootout", f"gui/{os.getuid()}/{label}"])
|
||||||
|
for i, item in enumerate(manifest["changes"]):
|
||||||
|
path = Path(item["path"])
|
||||||
|
if item["before"] is None:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
old = private_file(backup / str(i))
|
||||||
|
if hashlib.sha256(old).hexdigest() != item["before"]:
|
||||||
|
raise ValueError("Rollback payload changed")
|
||||||
|
_write_atomic(path, old)
|
||||||
|
for label in (STARTUP, TUNNEL, CORE):
|
||||||
|
path = agents / (label + ".plist")
|
||||||
|
if path.exists():
|
||||||
|
reload_agent(path, label)
|
||||||
|
if not _wait_for_health(45):
|
||||||
|
raise RuntimeError("Core health not accepted after rollback")
|
||||||
|
return {
|
||||||
|
"restored": True,
|
||||||
|
"container_state": "not changed; declared endpoint restored on next reconciliation",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(description=__doc__)
|
||||||
|
p.add_argument("action", choices=["plan", "apply", "reconcile", "rollback"])
|
||||||
|
p.add_argument("--stack-root", type=Path)
|
||||||
|
p.add_argument("--repository-root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||||
|
p.add_argument("--ssh-alias", default="mission-gpu")
|
||||||
|
p.add_argument("--expected-sha256")
|
||||||
|
p.add_argument("--backup", type=Path)
|
||||||
|
a = p.parse_args()
|
||||||
|
agents = Path.home() / "Library/LaunchAgents"
|
||||||
|
if a.action == "rollback":
|
||||||
|
if a.backup is None:
|
||||||
|
p.error("--backup required")
|
||||||
|
print(json.dumps(restore(a.backup, agents)))
|
||||||
|
return
|
||||||
|
if a.stack_root is None:
|
||||||
|
p.error("--stack-root required")
|
||||||
|
if a.action == "reconcile":
|
||||||
|
try:
|
||||||
|
phase = reconcile(a.stack_root)
|
||||||
|
except (OSError, subprocess.SubprocessError, ValueError):
|
||||||
|
phase = "receiver-unavailable"
|
||||||
|
# No repeating stdout log; health remains independently observable.
|
||||||
|
print(json.dumps({"phase": phase}))
|
||||||
|
return
|
||||||
|
document, desired = plan(a.stack_root, a.ssh_alias, a.repository_root, agents)
|
||||||
|
if a.action == "plan":
|
||||||
|
print(json.dumps(document, indent=2))
|
||||||
|
return
|
||||||
|
if a.expected_sha256 != document["sha256"]:
|
||||||
|
raise ValueError("Startup plan changed before apply")
|
||||||
|
backup = (
|
||||||
|
a.repository_root
|
||||||
|
/ ".runtime/mission-core/telemetry-service-backups"
|
||||||
|
/ (str(time.time_ns()))
|
||||||
|
)
|
||||||
|
backup.mkdir(parents=True, mode=0o700)
|
||||||
|
_write_atomic(backup / "manifest.json", json.dumps(document).encode())
|
||||||
|
for i, path in enumerate(desired):
|
||||||
|
if path.exists():
|
||||||
|
_write_atomic(backup / str(i), private_file(path))
|
||||||
|
for name in ("telemetry-startup.log", "telemetry-tunnel.log"):
|
||||||
|
prepare_launchd_log(launchd_log_path(name))
|
||||||
|
try:
|
||||||
|
for path, data in desired.items():
|
||||||
|
_write_atomic(path, data)
|
||||||
|
for label in (STARTUP, TUNNEL, CORE):
|
||||||
|
reload_agent(agents / (label + ".plist"), label)
|
||||||
|
if not _wait_for_health(45):
|
||||||
|
raise RuntimeError("Canonical Core failed health acceptance")
|
||||||
|
except BaseException:
|
||||||
|
restore(backup, agents)
|
||||||
|
raise
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"installed": True,
|
||||||
|
"backup": str(backup),
|
||||||
|
"plan_sha256": document["sha256"],
|
||||||
|
"telemetry_acceptance": "pending fresh worker sample",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -52,6 +52,10 @@ class FleetRegistry:
|
|||||||
"CREATE TABLE IF NOT EXISTS vehicles (id TEXT PRIMARY KEY, "
|
"CREATE TABLE IF NOT EXISTS vehicles (id TEXT PRIMARY KEY, "
|
||||||
"node_id TEXT UNIQUE NOT NULL, body TEXT NOT NULL)"
|
"node_id TEXT UNIQUE NOT NULL, body TEXT NOT NULL)"
|
||||||
)
|
)
|
||||||
|
self.db.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS board_history (vehicle_id TEXT NOT NULL, "
|
||||||
|
"revision INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(vehicle_id, revision))"
|
||||||
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
from k1link.device_plugins.vesc.archive import Archive
|
from k1link.device_plugins.vesc.archive import Archive
|
||||||
|
|
||||||
@@ -74,11 +78,14 @@ class FleetRegistry:
|
|||||||
json.loads(row[0]) for row in self.db.execute("SELECT body FROM vehicles ORDER BY id")
|
json.loads(row[0]) for row in self.db.execute("SELECT body FROM vehicles ORDER BY id")
|
||||||
]
|
]
|
||||||
|
|
||||||
def save(self, row):
|
def save(self, row, *, previous=None):
|
||||||
with self.db:
|
with self.db:
|
||||||
|
if previous is not None:
|
||||||
|
self.db.execute("INSERT INTO board_history VALUES(?,?,?)",
|
||||||
|
(previous["id"], previous["revision"], json.dumps(previous)))
|
||||||
self.db.execute(
|
self.db.execute(
|
||||||
"INSERT INTO vehicles VALUES(?,?,?) "
|
"INSERT INTO vehicles VALUES(?,?,?) "
|
||||||
"ON CONFLICT(id) DO UPDATE SET body=excluded.body",
|
"ON CONFLICT(id) DO UPDATE SET node_id=excluded.node_id, body=excluded.body",
|
||||||
(row["id"], row["node_id"], json.dumps(row)),
|
(row["id"], row["node_id"], json.dumps(row)),
|
||||||
)
|
)
|
||||||
self.events.notify()
|
self.events.notify()
|
||||||
@@ -164,17 +171,31 @@ class FleetRegistry:
|
|||||||
"endpoint": invitation["endpoint"],
|
"endpoint": invitation["endpoint"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def add(self, preview_id: str, name: str, platform: str) -> dict:
|
def add(self, preview_id: str, name: str, platform: str, *,
|
||||||
|
vehicle_id: str | None = None, expected_revision: int | None = None) -> dict:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
preview = self.previews.get(preview_id)
|
preview = self.previews.get(preview_id)
|
||||||
if preview is None or preview["expires"] <= time.time():
|
if preview is None or preview["expires"] <= time.time():
|
||||||
raise PairingError("Проверка приглашения истекла. Вставьте код снова.")
|
raise PairingError("Проверка приглашения истекла. Вставьте код снова.")
|
||||||
if preview.get("created_id"):
|
if preview.get("created_id"):
|
||||||
return self.public(self.find(preview["created_id"]))
|
current = self.find(preview["created_id"])
|
||||||
|
if (preview.get("target_id") != vehicle_id
|
||||||
|
or current["binding"]["binding_id"] != preview.get("created_binding")):
|
||||||
|
raise PairingError("Приглашение уже использовано. Проверьте БК заново.")
|
||||||
|
return self.public(current)
|
||||||
|
target = self.find(vehicle_id) if vehicle_id is not None else None
|
||||||
|
if target is not None:
|
||||||
|
if expected_revision != target["revision"]:
|
||||||
|
raise PairingError("Привязка БК изменилась. Обновите аппарат и повторите проверку.")
|
||||||
|
from .replacement import check_replacement
|
||||||
|
check_replacement(self, target)
|
||||||
|
name, platform = target["name"], target["platform"]
|
||||||
invitation = preview["invitation"]
|
invitation = preview["invitation"]
|
||||||
existing = next(
|
existing = next(
|
||||||
(row for row in self.rows() if row["node_id"] == invitation["node_id"]), None
|
(row for row in self.rows() if row["node_id"] == invitation["node_id"]), None
|
||||||
)
|
)
|
||||||
|
if target is not None and existing is not None and existing["id"] != target["id"]:
|
||||||
|
raise PairingError("Этот БК относится к другому аппарату. Выберите другое приглашение.")
|
||||||
if existing and existing["enrollment"] in ("pending", "paired"):
|
if existing and existing["enrollment"] in ("pending", "paired"):
|
||||||
if existing.get("invitation_id") == invitation["id"]:
|
if existing.get("invitation_id") == invitation["id"]:
|
||||||
return self.public(existing)
|
return self.public(existing)
|
||||||
@@ -201,13 +222,14 @@ class FleetRegistry:
|
|||||||
"ca_pem": pem(self.trust.ca),
|
"ca_pem": pem(self.trust.ca),
|
||||||
"client_pem": self.trust.leaf(invitation["node_id"], preview["public_key"]),
|
"client_pem": self.trust.leaf(invitation["node_id"], preview["public_key"]),
|
||||||
}
|
}
|
||||||
|
previous = target or existing
|
||||||
row = {
|
row = {
|
||||||
"id": existing["id"] if existing else secrets.token_urlsafe(16),
|
"id": previous["id"] if previous else secrets.token_urlsafe(16),
|
||||||
"node_id": invitation["node_id"],
|
"node_id": invitation["node_id"],
|
||||||
"name": name.strip(),
|
"name": name.strip(),
|
||||||
"platform": platform,
|
"platform": platform,
|
||||||
"enrollment": "pending",
|
"enrollment": "pending",
|
||||||
"revision": (existing["revision"] + 1) if existing else 1,
|
"revision": (previous["revision"] + 1) if previous else 1,
|
||||||
"binding": binding,
|
"binding": binding,
|
||||||
"invitation_id": invitation["id"],
|
"invitation_id": invitation["id"],
|
||||||
"invitation": invitation,
|
"invitation": invitation,
|
||||||
@@ -218,10 +240,11 @@ class FleetRegistry:
|
|||||||
"runtime": None,
|
"runtime": None,
|
||||||
"certificate_previous": None,
|
"certificate_previous": None,
|
||||||
"notice": "Подтверждаем привязку с БК",
|
"notice": "Подтверждаем привязку с БК",
|
||||||
"created_at": time.time(),
|
"created_at": previous["created_at"] if previous else time.time(),
|
||||||
}
|
}
|
||||||
self.save(row)
|
self.save(row, previous=previous)
|
||||||
self.previews[preview_id] = {"created_id": row["id"], "expires": preview["expires"]}
|
self.previews[preview_id] = {"created_id": row["id"], "created_binding": binding["binding_id"],
|
||||||
|
"target_id": vehicle_id, "expires": preview["expires"]}
|
||||||
return self.public(row)
|
return self.public(row)
|
||||||
|
|
||||||
def public(self, row):
|
def public(self, row):
|
||||||
@@ -250,6 +273,21 @@ class FleetRegistry:
|
|||||||
"core_endpoint": row["binding"]["endpoint"],
|
"core_endpoint": row["binding"]["endpoint"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def rename(self, identifier: str, name: str, expected_revision: int) -> dict:
|
||||||
|
"""Update operator metadata without contacting or re-enrolling the board."""
|
||||||
|
if (not name.strip() or len(name.strip()) > 80
|
||||||
|
or any(ord(c) < 32 or 127 <= ord(c) < 160 for c in name)):
|
||||||
|
raise PairingError("Введите название аппарата от 1 до 80 символов без управляющих знаков.")
|
||||||
|
with self.lock:
|
||||||
|
row = self.find(identifier)
|
||||||
|
if row["name"] == name.strip():
|
||||||
|
return self.public(row) # Safe retry after a lost response.
|
||||||
|
if row["revision"] != expected_revision:
|
||||||
|
raise PairingError("Аппарат изменился. Отмените правку и повторите её по свежим данным.")
|
||||||
|
row.update(name=name.strip(), revision=row["revision"] + 1)
|
||||||
|
self.save(row)
|
||||||
|
return self.public(row)
|
||||||
|
|
||||||
def listing(self):
|
def listing(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Admission for replacing the computer of an existing vehicle, never its identity."""
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .trust import PairingError
|
||||||
|
|
||||||
|
|
||||||
|
def check_replacement(fleet, row):
|
||||||
|
if row["enrollment"] == "pending":
|
||||||
|
raise PairingError("Дождитесь завершения текущей привязки БК или отзовите её.")
|
||||||
|
control = fleet.rover_control.view(row["node_id"])
|
||||||
|
if control["controlling"] or (control["fresh"] and control["snapshot"].get("state") in
|
||||||
|
("preparing", "ready", "driving", "stopping")):
|
||||||
|
raise PairingError("Сначала завершите управление аппаратом и дождитесь остановки.")
|
||||||
|
fleet.device_enrollment.prune()
|
||||||
|
if any(entry["binding"] == row["binding"]["binding_id"]
|
||||||
|
and entry["public"]["state"] in ("queued", "running")
|
||||||
|
for (vehicle_id, _), entry in fleet.device_enrollment.pending.items()
|
||||||
|
if vehicle_id == row["id"]):
|
||||||
|
raise PairingError("Дождитесь завершения подключения беспроводного устройства.")
|
||||||
|
for receipt in row.get("sensor_commands", {}).values():
|
||||||
|
if receipt.get("state") not in ("queued", "running"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
deadline = datetime.fromisoformat(receipt["command"]["deadline_at"].replace("Z", "+00:00")).timestamp()
|
||||||
|
except (KeyError, ValueError, TypeError):
|
||||||
|
deadline = float("inf")
|
||||||
|
if deadline > time.time():
|
||||||
|
raise PairingError("Дождитесь завершения операции с устройствами БК.")
|
||||||
@@ -423,7 +423,9 @@ def build_compute_contour_router(*, root_provider: RootProvider) -> APIRouter:
|
|||||||
try:
|
try:
|
||||||
contour = store.get(contour_id)
|
contour = store.get(contour_id)
|
||||||
telemetry_plane_root = (
|
telemetry_plane_root = (
|
||||||
Path(__file__).resolve().parents[3] / "deploy" / "telemetry-plane"
|
Path(os.environ["MISSIONCORE_TELEMETRY_PLANE_ROOT"])
|
||||||
|
if os.environ.get("MISSIONCORE_TELEMETRY_PLANE_ROOT")
|
||||||
|
else Path(__file__).resolve().parents[3] / "deploy" / "telemetry-plane"
|
||||||
)
|
)
|
||||||
return apply_broker_network(
|
return apply_broker_network(
|
||||||
_network_target(contour),
|
_network_target(contour),
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ class AddRequest(BaseModel):
|
|||||||
platform: str = Field(pattern="^(ugv|uav|stationary|other)$")
|
platform: str = Field(pattern="^(ugv|uav|stationary|other)$")
|
||||||
|
|
||||||
|
|
||||||
|
class AttachBoardRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
preview_id: str = Field(min_length=43, max_length=43)
|
||||||
|
expected_revision: int = Field(ge=1, strict=True)
|
||||||
|
|
||||||
|
|
||||||
|
class RenameRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
name: str = Field(min_length=1, max_length=80)
|
||||||
|
expected_revision: int = Field(ge=1, strict=True)
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/fleet", tags=["fleet"])
|
router = APIRouter(prefix="/api/v1/fleet", tags=["fleet"])
|
||||||
|
|
||||||
|
|
||||||
@@ -157,6 +169,16 @@ def fleet_add(
|
|||||||
raise HTTPException(409, str(error)) from None
|
raise HTTPException(409, str(error)) from None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{vehicle_id}")
|
||||||
|
def fleet_rename(vehicle_id: str, body: RenameRequest, response: Response,
|
||||||
|
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
try:
|
||||||
|
return fleet.rename(vehicle_id, body.name, body.expected_revision)
|
||||||
|
except PairingError as error:
|
||||||
|
raise HTTPException(409, str(error)) from None
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{vehicle_id}")
|
@router.delete("/{vehicle_id}")
|
||||||
def fleet_revoke(vehicle_id: str, fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
def fleet_revoke(vehicle_id: str, fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||||
try:
|
try:
|
||||||
@@ -165,6 +187,17 @@ def fleet_revoke(vehicle_id: str, fleet: Annotated[FleetRegistry, Depends(local_
|
|||||||
raise HTTPException(404, str(error)) from None
|
raise HTTPException(404, str(error)) from None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{vehicle_id}/computer")
|
||||||
|
def fleet_attach_board(vehicle_id: str, body: AttachBoardRequest, response: Response,
|
||||||
|
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
try:
|
||||||
|
return fleet.add(body.preview_id, "", "", vehicle_id=vehicle_id,
|
||||||
|
expected_revision=body.expected_revision)
|
||||||
|
except PairingError as error:
|
||||||
|
raise HTTPException(409, str(error)) from None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{vehicle_id}/devices/operations")
|
@router.post("/{vehicle_id}/devices/operations")
|
||||||
def sensor_command(
|
def sensor_command(
|
||||||
vehicle_id: str, body: dict, fleet: Annotated[FleetRegistry, Depends(local_operator)]
|
vehicle_id: str, body: dict, fleet: Annotated[FleetRegistry, Depends(local_operator)]
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ def rover_state(vehicle_id: str, response: Response,
|
|||||||
def rover_arm(vehicle_id: str, body: dict,
|
def rover_arm(vehicle_id: str, body: dict,
|
||||||
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||||
try:
|
try:
|
||||||
return fleet.rover_control.arm(node(fleet, vehicle_id), body)
|
# Admission and node resolution must not race a computer replacement.
|
||||||
|
with fleet.lock:
|
||||||
|
return fleet.rover_control.arm(node(fleet, vehicle_id), body)
|
||||||
except (ValueError, PairingError) as error:
|
except (ValueError, PairingError) as error:
|
||||||
raise HTTPException(409, str(error)) from None
|
raise HTTPException(409, str(error)) from None
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ def rover_arm(vehicle_id: str, body: dict,
|
|||||||
def rover_command(vehicle_id: str, body: dict,
|
def rover_command(vehicle_id: str, body: dict,
|
||||||
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||||
try:
|
try:
|
||||||
return fleet.rover_control.command(node(fleet, vehicle_id), body)
|
with fleet.lock:
|
||||||
|
return fleet.rover_control.command(node(fleet, vehicle_id), body)
|
||||||
except (ValueError, PairingError) as error:
|
except (ValueError, PairingError) as error:
|
||||||
raise HTTPException(409, str(error)) from None
|
raise HTTPException(409, str(error)) from None
|
||||||
|
|||||||
@@ -722,7 +722,7 @@ def run_worker_agent_probe(
|
|||||||
):
|
):
|
||||||
return _failed_probe(
|
return _failed_probe(
|
||||||
profile,
|
profile,
|
||||||
"telemetry-agent-unavailable",
|
"telemetry-receiver-unavailable",
|
||||||
started,
|
started,
|
||||||
source="agent-mqtt",
|
source="agent-mqtt",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Apparatus metadata is independent of its powered/offline board."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from test_pairing import setup, create, cert, heartbeat
|
||||||
|
from k1link.fleet.registry import FleetRegistry
|
||||||
|
from k1link.fleet.trust import PairingError
|
||||||
|
from k1link.web.fleet_api import router
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_rename_persists_without_touching_board_or_authority(setup):
|
||||||
|
fleet, _, _, calls = setup
|
||||||
|
public, _ = create(setup)
|
||||||
|
fleet.advance(public['id'])
|
||||||
|
before = fleet.find(public['id'])
|
||||||
|
network_calls = len(calls)
|
||||||
|
result = fleet.rename(before['id'], ' New rover ', before['revision'])
|
||||||
|
expected = {**before, 'name': 'New rover', 'revision': before['revision']+1}
|
||||||
|
assert fleet.find(before['id']) == expected
|
||||||
|
assert result['connectivity'] == 'offline'
|
||||||
|
assert len(calls) == network_calls
|
||||||
|
assert fleet.rename(before['id'], 'New rover', before['revision']) == result
|
||||||
|
reopened = FleetRegistry(fleet.root)
|
||||||
|
try:
|
||||||
|
assert reopened.find(before['id']) == expected
|
||||||
|
finally:
|
||||||
|
reopened.close()
|
||||||
|
# The existing board certificate and heartbeat continue to work unchanged.
|
||||||
|
assert fleet.receive(cert(before), '/v1/node/heartbeat', heartbeat(before))[0] == 200
|
||||||
|
assert fleet.find(before['id'])['name'] == 'New rover'
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_editor_cannot_overwrite_newer_name(setup):
|
||||||
|
fleet = setup[0]
|
||||||
|
row, _ = create(setup)
|
||||||
|
fleet.rename(row['id'], 'Other editor', row['revision'])
|
||||||
|
with pytest.raises(PairingError, match='изменился'):
|
||||||
|
fleet.rename(row['id'], 'Stale edit', row['revision'])
|
||||||
|
assert fleet.find(row['id'])['name'] == 'Other editor'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('name', ['', ' ', 'a'*81, 'bad\nname', 'bad\x7fname'])
|
||||||
|
def test_invalid_name_does_not_change_saved_apparatus(setup, name):
|
||||||
|
fleet = setup[0]
|
||||||
|
row, _ = create(setup)
|
||||||
|
before = fleet.find(row['id'])
|
||||||
|
with pytest.raises(PairingError):
|
||||||
|
fleet.rename(row['id'], name, row['revision'])
|
||||||
|
assert fleet.find(row['id']) == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_failure_keeps_previous_name(setup):
|
||||||
|
fleet = setup[0]
|
||||||
|
row, _ = create(setup)
|
||||||
|
fleet.db.execute("CREATE TRIGGER fail_rename BEFORE UPDATE ON vehicles BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END")
|
||||||
|
with pytest.raises(sqlite3.IntegrityError):
|
||||||
|
fleet.rename(row['id'], 'Unsaved', row['revision'])
|
||||||
|
assert fleet.find(row['id'])['name'] == row['name']
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_endpoint_is_local_scoped_and_returns_public_metadata(setup):
|
||||||
|
fleet = setup[0]
|
||||||
|
row, _ = create(setup)
|
||||||
|
app = FastAPI(); app.state.fleet = fleet; app.include_router(router)
|
||||||
|
path = '/api/v1/fleet/'+row['id']
|
||||||
|
body = {'name':'Updated rover','expected_revision':row['revision']}
|
||||||
|
with TestClient(app, base_url='http://127.0.0.1:8000', client=('127.0.0.1',55555)) as client:
|
||||||
|
assert client.patch(path, json=body, headers={'Origin':'https://attacker.test'}).status_code == 403
|
||||||
|
assert client.patch(path, json={**body,'node_id':'injected'}).status_code == 422
|
||||||
|
assert client.patch(path, json={**body,'expected_revision':True}).status_code == 422
|
||||||
|
response = client.patch(path, json=body)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers['cache-control'] == 'no-store'
|
||||||
|
assert response.json()['name'] == 'Updated rover'
|
||||||
|
assert fleet.find(row['id'])['binding']['binding_id'] not in response.text
|
||||||
|
assert client.patch(path, json={**body,'name':'Stale'}).status_code == 409
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Replacement uses synthetic identities; no device or network access."""
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from test_pairing import setup, create, code, cert, heartbeat
|
||||||
|
from k1link.fleet import board_layout
|
||||||
|
from k1link.fleet.registry import FleetRegistry
|
||||||
|
from k1link.fleet.trust import SCHEMA, PairingError, public_id
|
||||||
|
from k1link.web.fleet_api import router
|
||||||
|
|
||||||
|
|
||||||
|
def candidate(fleet, monkeypatch):
|
||||||
|
key = Ed25519PrivateKey.generate()
|
||||||
|
invite = dict(schema=SCHEMA, node_id=public_id('node_', key.public_key()),
|
||||||
|
id=secrets.token_urlsafe(32), secret=secrets.token_urlsafe(32),
|
||||||
|
expires_at=int(time.time())+600, endpoint='https://192.168.20.8:8781')
|
||||||
|
def request(invitation, path, payload):
|
||||||
|
return dict(schema=SCHEMA, node_id=invite['node_id'], name='Replacement board',
|
||||||
|
host={'os':'Linux'}, receipt=secrets.token_urlsafe(32)), key.public_key(), '192.168.20.5'
|
||||||
|
monkeypatch.setattr('k1link.fleet.registry.node_request', request)
|
||||||
|
return fleet.preview(code(invite))
|
||||||
|
|
||||||
|
|
||||||
|
def current(setup):
|
||||||
|
fleet = setup[0]
|
||||||
|
public, _ = create(setup)
|
||||||
|
fleet.advance(public['id'])
|
||||||
|
return fleet.find(public['id'])
|
||||||
|
|
||||||
|
|
||||||
|
def attach(fleet, old, preview, **kwargs):
|
||||||
|
return fleet.add(preview['preview_id'], '', '', vehicle_id=old['id'],
|
||||||
|
expected_revision=kwargs.get('revision', old['revision']))
|
||||||
|
|
||||||
|
|
||||||
|
def test_replacement_keeps_vehicle_and_layout_but_not_old_authority(setup, monkeypatch):
|
||||||
|
fleet = setup[0]
|
||||||
|
old = current(setup)
|
||||||
|
board_layout.update(fleet.root, old['id'], 'settings', False)
|
||||||
|
before_layout = board_layout.read(fleet.root, old['id'])
|
||||||
|
preview = candidate(fleet, monkeypatch)
|
||||||
|
new = attach(fleet, old, preview)
|
||||||
|
assert new['id'] == old['id'] and new['name'] == old['name'] and new['platform'] == old['platform']
|
||||||
|
assert new['node_id'] == preview['node_id'] != old['node_id']
|
||||||
|
assert new['revision'] == old['revision']+1 and new['enrollment'] == 'pending'
|
||||||
|
assert new['host'] is None and new['sensor_state']['items'] == []
|
||||||
|
assert board_layout.read(fleet.root, old['id']) == before_layout
|
||||||
|
assert fleet.find(old['id'])['created_at'] == old['created_at']
|
||||||
|
archived = fleet.db.execute('SELECT body FROM board_history WHERE vehicle_id=?', (old['id'],)).fetchone()[0]
|
||||||
|
assert json.loads(archived) == old
|
||||||
|
assert fleet.db.execute('SELECT node_id FROM vehicles').fetchone()[0] == preview['node_id']
|
||||||
|
assert fleet.receive(cert(old), '/v1/node/heartbeat', heartbeat(old))[0] == 410
|
||||||
|
assert attach(fleet, old, preview) == new # A lost operator response is retried safely.
|
||||||
|
fresh = fleet.find(old['id'])
|
||||||
|
assert fleet.receive(cert(fresh), '/v1/node/heartbeat', heartbeat(fresh))[0] == 200
|
||||||
|
assert fleet.public(fleet.find(old['id']))['connectivity'] == 'online'
|
||||||
|
reopened = FleetRegistry(fleet.root)
|
||||||
|
try:
|
||||||
|
assert reopened.find(old['id'])['node_id'] == preview['node_id']
|
||||||
|
assert reopened.db.execute('SELECT count(*) FROM board_history').fetchone()[0] == 1
|
||||||
|
finally:
|
||||||
|
reopened.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_and_expired_preview_do_not_touch_existing_binding(setup, monkeypatch):
|
||||||
|
fleet = setup[0]
|
||||||
|
old = current(setup)
|
||||||
|
preview = candidate(fleet, monkeypatch)
|
||||||
|
with pytest.raises(PairingError, match='изменилась'):
|
||||||
|
attach(fleet, old, preview, revision=old['revision']+1)
|
||||||
|
fleet.previews[preview['preview_id']]['expires'] = 0
|
||||||
|
with pytest.raises(PairingError, match='истекла'):
|
||||||
|
attach(fleet, old, preview)
|
||||||
|
assert fleet.find(old['id']) == old
|
||||||
|
assert fleet.db.execute('SELECT count(*) FROM board_history').fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_apparatus_computer_is_never_silently_transferred(setup, monkeypatch):
|
||||||
|
fleet = setup[0]
|
||||||
|
old = current(setup)
|
||||||
|
preview = candidate(fleet, monkeypatch)
|
||||||
|
verified = dict(fleet.previews[preview['preview_id']])
|
||||||
|
other = fleet.add(preview['preview_id'], 'Other apparatus', 'ugv')
|
||||||
|
with pytest.raises(PairingError):
|
||||||
|
attach(fleet, old, preview)
|
||||||
|
# Another verified preview of the same computer cannot transfer its owner.
|
||||||
|
preview['preview_id'] = secrets.token_urlsafe(32)
|
||||||
|
fleet.previews[preview['preview_id']] = verified
|
||||||
|
with pytest.raises(PairingError, match='другому аппарату'):
|
||||||
|
attach(fleet, old, preview)
|
||||||
|
assert fleet.find(old['id']) == old
|
||||||
|
assert len(fleet.rows()) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('activity', ['pairing', 'control', 'device', 'wireless'])
|
||||||
|
def test_replacement_rejects_active_work(setup, monkeypatch, activity):
|
||||||
|
fleet = setup[0]
|
||||||
|
old = current(setup)
|
||||||
|
if activity == 'pairing':
|
||||||
|
old['enrollment'] = 'pending'
|
||||||
|
elif activity == 'control':
|
||||||
|
fleet.rover_control.exchange(old['node_id'], {'relay_id':'a'*32, 'rover':{'instance':'test','supported':True,'state':'idle'}})
|
||||||
|
fleet.rover_control.arm(old['node_id'], {'standstill_confirmed':True,'current_a':5,'max_erpm':1000})
|
||||||
|
elif activity == 'device':
|
||||||
|
old['sensor_commands'] = {'synthetic':{'state':'running','command':{'deadline_at':(datetime.now(UTC)+timedelta(seconds=60)).isoformat()}}}
|
||||||
|
else:
|
||||||
|
fleet.device_enrollment.pending[(old['id'],'synthetic')] = dict(binding=old['binding']['binding_id'], created=time.time(),deadline=time.time()+60,payload={},public={'state':'running'})
|
||||||
|
fleet.save(old)
|
||||||
|
preview = candidate(fleet, monkeypatch)
|
||||||
|
with pytest.raises(PairingError):
|
||||||
|
attach(fleet, old, preview)
|
||||||
|
assert fleet.find(old['id']) == old
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_transaction_preserves_old_binding_and_history(setup, monkeypatch):
|
||||||
|
fleet = setup[0]
|
||||||
|
old = current(setup)
|
||||||
|
preview = candidate(fleet, monkeypatch)
|
||||||
|
fleet.db.execute("CREATE TRIGGER fail_replace BEFORE UPDATE ON vehicles BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END")
|
||||||
|
with pytest.raises(sqlite3.IntegrityError):
|
||||||
|
attach(fleet, old, preview)
|
||||||
|
assert fleet.find(old['id']) == old
|
||||||
|
assert fleet.db.execute('SELECT count(*) FROM board_history').fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_listener_failure_keeps_original_and_preview_usable(setup, monkeypatch):
|
||||||
|
fleet = setup[0]
|
||||||
|
old = current(setup)
|
||||||
|
preview = candidate(fleet, monkeypatch)
|
||||||
|
def fail(_):
|
||||||
|
raise OSError('synthetic')
|
||||||
|
monkeypatch.setattr(fleet, 'listen', fail)
|
||||||
|
with pytest.raises(PairingError, match='частный канал'):
|
||||||
|
attach(fleet, old, preview)
|
||||||
|
assert fleet.find(old['id']) == old
|
||||||
|
monkeypatch.setattr(fleet, 'listen', lambda _:None)
|
||||||
|
assert attach(fleet, old, preview)['id'] == old['id']
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_endpoint_scoped_local_revision_and_payload(setup, monkeypatch):
|
||||||
|
fleet = setup[0]
|
||||||
|
old = current(setup)
|
||||||
|
preview = candidate(fleet, monkeypatch)
|
||||||
|
app = FastAPI(); app.state.fleet = fleet; app.include_router(router)
|
||||||
|
body = {'preview_id':preview['preview_id'],'expected_revision':old['revision']}
|
||||||
|
with TestClient(app, base_url='http://127.0.0.1:8000', client=('127.0.0.1',55555)) as client:
|
||||||
|
path = '/api/v1/fleet/'+old['id']+'/computer'
|
||||||
|
assert client.post(path, json=body, headers={'Origin':'https://attacker.test'}).status_code == 403
|
||||||
|
assert client.post(path, json={**body,'name':'injected'}).status_code == 422
|
||||||
|
assert client.post(path, json={**body,'expected_revision':0}).status_code == 422
|
||||||
|
response = client.post(path, json=body)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()['id'] == old['id']
|
||||||
|
assert old['binding']['binding_id'] not in response.text
|
||||||
@@ -144,3 +144,17 @@ def test_contour_router_returns_404_for_unknown_contour(tmp_path: Path) -> None:
|
|||||||
with pytest.raises(HTTPException) as error:
|
with pytest.raises(HTTPException) as error:
|
||||||
install("missing")
|
install("missing")
|
||||||
assert error.value.status_code == 404
|
assert error.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_broker_apply_uses_explicit_installation_root(tmp_path, monkeypatch):
|
||||||
|
from k1link.web import compute_contour_api as api
|
||||||
|
root = tmp_path / 'prepared-stack'
|
||||||
|
monkeypatch.setenv('MISSIONCORE_TELEMETRY_PLANE_ROOT', str(root))
|
||||||
|
seen = []
|
||||||
|
def apply(target, installation):
|
||||||
|
seen.append(installation)
|
||||||
|
return {'ready': True}
|
||||||
|
monkeypatch.setattr(api, 'apply_broker_network', apply)
|
||||||
|
router = api.build_compute_contour_router(root_provider=lambda: tmp_path / 'system')
|
||||||
|
_endpoint(router, '/api/v1/system/contours/{contour_id}/network/broker', 'POST')('worker-006')
|
||||||
|
assert seen == [root]
|
||||||
|
|||||||
@@ -497,3 +497,19 @@ def test_profile_apply_fails_closed_on_wrong_node_and_keeps_old_profile(
|
|||||||
assert error.value.status_code == 409
|
assert error.value.status_code == 409
|
||||||
assert WorkerProfileStore(tmp_path / "system").read().revision == 0
|
assert WorkerProfileStore(tmp_path / "system").read().revision == 0
|
||||||
assert not WorkerProfileStore(tmp_path / "system").path.exists()
|
assert not WorkerProfileStore(tmp_path / "system").path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_receiver_failure_is_not_reported_as_worker_failure(monkeypatch):
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
from k1link.web import system_telemetry_api as api
|
||||||
|
|
||||||
|
def unavailable(*args, **kwargs):
|
||||||
|
raise urllib.error.URLError("receiver down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(api.urllib.request, "urlopen", unavailable)
|
||||||
|
result = api.run_worker_agent_probe(
|
||||||
|
api._profile_from_compute_contour(default_compute_contour())
|
||||||
|
)
|
||||||
|
assert result["error_code"] == "telemetry-receiver-unavailable"
|
||||||
|
assert result["reachable"] is False and result["raw"] is None
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import plistlib
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SCRIPTS = Path(__file__).parents[1] / "scripts"
|
||||||
|
sys.path.insert(0, str(SCRIPTS))
|
||||||
|
try:
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"telemetry_startup", SCRIPTS / "manage_telemetry_startup.py"
|
||||||
|
)
|
||||||
|
manager = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(manager)
|
||||||
|
finally:
|
||||||
|
sys.path.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
def setup(tmp_path, monkeypatch):
|
||||||
|
stack = tmp_path / "state"
|
||||||
|
repo = tmp_path / "release"
|
||||||
|
agents = tmp_path / "agents"
|
||||||
|
for p in (stack, repo, agents):
|
||||||
|
p.mkdir()
|
||||||
|
for name in (
|
||||||
|
"compose.yaml",
|
||||||
|
"runtime/agents.json",
|
||||||
|
"runtime/mosquitto/acl",
|
||||||
|
"runtime/mosquitto/passwords",
|
||||||
|
):
|
||||||
|
p = stack / name
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_text("fixture")
|
||||||
|
(stack / ".env").write_text("MISSIONCORE_MQTT_BIND_ADDRESS=192.168.1.5\nSECRET=preserve-me\n")
|
||||||
|
(repo / ".venv/bin").mkdir(parents=True)
|
||||||
|
(repo / ".venv/bin/python").write_text("fixture")
|
||||||
|
docker = tmp_path / "docker"
|
||||||
|
docker.write_text("fixture")
|
||||||
|
monkeypatch.setattr(manager, "DOCKER", str(docker))
|
||||||
|
(agents / (manager.CORE + ".plist")).write_bytes(
|
||||||
|
plistlib.dumps(
|
||||||
|
{
|
||||||
|
"Label": manager.CORE,
|
||||||
|
"WorkingDirectory": str(repo),
|
||||||
|
"EnvironmentVariables": {"MISSIONCORE_DATA_DIR": "/existing-data"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
manager,
|
||||||
|
"command",
|
||||||
|
lambda *args: subprocess.CompletedProcess(
|
||||||
|
[], 0, b"hostname worker.example.ts.net\nstricthostkeychecking true\n", b""
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return stack, repo, agents
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_preserves_credentials_and_pins_loopback_transport(tmp_path, monkeypatch):
|
||||||
|
stack, repo, agents = setup(tmp_path, monkeypatch)
|
||||||
|
before = (stack / ".env").read_bytes()
|
||||||
|
doc, files = manager.plan(stack, "worker-test", repo, agents)
|
||||||
|
assert (stack / ".env").read_bytes() == before
|
||||||
|
assert files[stack / ".env"] == b"MISSIONCORE_MQTT_BIND_ADDRESS=127.0.0.1\nSECRET=preserve-me\n"
|
||||||
|
assert "preserve-me" not in json.dumps(doc)
|
||||||
|
startup = plistlib.loads(files[agents / (manager.STARTUP + ".plist")])
|
||||||
|
assert startup["RunAtLoad"] and startup["StartInterval"] == 30
|
||||||
|
tunnel = plistlib.loads(files[agents / (manager.TUNNEL + ".plist")])
|
||||||
|
assert "127.0.0.1:1883:127.0.0.1:1883" in tunnel["ProgramArguments"]
|
||||||
|
assert "StrictHostKeyChecking=yes" in tunnel["ProgramArguments"]
|
||||||
|
assert tunnel["KeepAlive"] and tunnel["ThrottleInterval"] >= 5
|
||||||
|
core = plistlib.loads(files[agents / (manager.CORE + ".plist")])
|
||||||
|
assert core["EnvironmentVariables"]["MISSIONCORE_DATA_DIR"] == "/existing-data"
|
||||||
|
assert core["EnvironmentVariables"]["MISSIONCORE_TELEMETRY_PLANE_ROOT"] == str(stack)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_refuses_non_tailscale_and_changed_input(tmp_path, monkeypatch):
|
||||||
|
stack, repo, agents = setup(tmp_path, monkeypatch)
|
||||||
|
before, _ = manager.plan(stack, "worker-test", repo, agents)
|
||||||
|
with (stack / ".env").open("a") as f:
|
||||||
|
f.write("NEW=setting\n")
|
||||||
|
after, _ = manager.plan(stack, "worker-test", repo, agents)
|
||||||
|
assert before["sha256"] != after["sha256"]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
manager,
|
||||||
|
"command",
|
||||||
|
lambda *args: subprocess.CompletedProcess(
|
||||||
|
[], 0, b"hostname worker.local\nstricthostkeychecking true\n", b""
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="Tailscale"):
|
||||||
|
manager.plan(stack, "worker-test", repo, agents)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delayed_docker_retries_without_compose_or_workload_launch(monkeypatch, tmp_path):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(manager, "receiver_ready", lambda: False)
|
||||||
|
|
||||||
|
def run(args, timeout):
|
||||||
|
calls.append(args)
|
||||||
|
return subprocess.CompletedProcess(args, 1 if "info" in args else 0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(manager, "command", run)
|
||||||
|
assert manager.reconcile(tmp_path) == "waiting-for-docker"
|
||||||
|
assert len(calls) == 2 and calls[1][0] == "/usr/bin/open"
|
||||||
|
assert not any("compose" in c for c in calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ready_receiver_is_not_restarted(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(manager, "receiver_ready", lambda: True)
|
||||||
|
monkeypatch.setattr(manager, "broker_loopback", lambda: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
manager, "command", lambda *args: pytest.fail("Must not restart healthy services")
|
||||||
|
)
|
||||||
|
assert manager.reconcile(tmp_path) == "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_lan_binding_is_reconciled_even_when_receiver_healthy(monkeypatch, tmp_path):
|
||||||
|
calls = []
|
||||||
|
bindings = iter((False, True))
|
||||||
|
monkeypatch.setattr(manager, "receiver_ready", lambda: True)
|
||||||
|
monkeypatch.setattr(manager, "broker_loopback", lambda: next(bindings))
|
||||||
|
|
||||||
|
def run(args, timeout):
|
||||||
|
calls.append(args)
|
||||||
|
return subprocess.CompletedProcess(args, 0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(manager, "command", run)
|
||||||
|
assert manager.reconcile(tmp_path) == "ready"
|
||||||
|
assert calls[-1][-3:] == ["broker", "timescale", "normalizer"]
|
||||||
|
assert "--no-build" in calls[-1] and "--pull" in calls[-1] and "never" in calls[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_compose_reports_waiting_without_ready(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(manager, "receiver_ready", lambda: False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
manager,
|
||||||
|
"command",
|
||||||
|
lambda args, timeout: subprocess.CompletedProcess(args, int("up" in args)),
|
||||||
|
)
|
||||||
|
assert manager.reconcile(tmp_path) == "waiting-for-receiver"
|
||||||
@@ -1,49 +1,70 @@
|
|||||||
"""Immutable-source evaluated geometry export, owner-selected complete v020."""
|
"""Evaluated v020 geometry, semantic PBR groups and UVs; never saves the source."""
|
||||||
import bpy,json,hashlib,struct
|
import bpy, json, hashlib
|
||||||
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from mathutils import Vector,Matrix
|
from mathutils import Vector, Matrix
|
||||||
root=Path.cwd();source=Path(bpy.data.filepath)
|
root=Path.cwd();source=Path(bpy.data.filepath)
|
||||||
donor=root.parent/'NODEDC_ENGINE_INFRA/nodedc-source/public/3dassetnode/model.glb'
|
# Linear colors. Painted black is a dielectric coating, not bare silver.
|
||||||
with donor.open('rb') as f:
|
specs={
|
||||||
f.read(12);n,t=struct.unpack('<II',f.read(8));donor_json=json.loads(f.read(n))
|
'body':('Корпус и подвеска',(.025,.028,.031),.12,.53),
|
||||||
# Same physical PBR parameters as the drone's untextured metal/rubber/steel.
|
'rubber':('Резина гусениц и катков',(.014,.017,.019),0,.72),
|
||||||
material_rules={'metal':1,'rubber':2,'steel':6,'paint':3}
|
'track-steel':('Нержавеющие проставки',(.55,.58,.61),1,.17),
|
||||||
|
'box':('Бортовой ящик',(.034,.038,.042),.08,.40),
|
||||||
|
'molle':('MOLLE панель',(.085,.094,.105),.30,.44),
|
||||||
|
'motor':('Алюминий моторов',(.30,.32,.33),.68,.38),
|
||||||
|
'hardware':('Чёрный крепёж',(.018,.021,.024),.45,.38),
|
||||||
|
'polymer':('Пластик и кожухи',(.018,.022,.026),0,.44),
|
||||||
|
'drive':('Ведущие звёзды',(.22,.13,.038),0,.46),
|
||||||
|
}
|
||||||
materials={}
|
materials={}
|
||||||
for kind,index in material_rules.items():
|
for key,(label,color,metal,rough) in specs.items():
|
||||||
spec=donor_json['materials'][index]['pbrMetallicRoughness'];m=bpy.data.materials.new('NodeDC '+kind);m.use_nodes=True
|
m=bpy.data.materials.new('rover/'+key);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF')
|
||||||
bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=spec.get('baseColorFactor',[1,1,1,1]);bs.inputs['Metallic'].default_value=spec.get('metallicFactor',1);bs.inputs['Roughness'].default_value=spec.get('roughnessFactor',1);materials[kind]=m
|
bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;materials[key]=m
|
||||||
source_scene=bpy.context.scene;source_scene.frame_set(1);dg=bpy.context.evaluated_depsgraph_get()
|
|
||||||
instances=[]
|
def classify(name,obj):
|
||||||
|
n=name.lower();o=obj.lower()
|
||||||
|
if 'stamped clips' in n or 'inter-window clips' in o:return 'track-steel'
|
||||||
|
if 'molle' in n:return 'molle'
|
||||||
|
if 'motor | cast' in n:return 'motor'
|
||||||
|
if 'polyurethane' in n:return 'drive'
|
||||||
|
if any(k in n for k in ['rubber','gasket','elastomer','jacket','coated roller']):return 'rubber'
|
||||||
|
if any(k in n for k in ['case | textured','case | dark raised']):return 'box'
|
||||||
|
if any(k in n+' '+o for k in ['bolt','screw','washer','nut','lock steel','zinc','stainless','connector brass','dark steel','black oxide']):return 'hardware'
|
||||||
|
if any(k in n for k in ['polymer','asa','recess','cavities']):return 'polymer'
|
||||||
|
return 'body'
|
||||||
|
|
||||||
|
bpy.context.scene.frame_set(1);dg=bpy.context.evaluated_depsgraph_get();instances=[];assignment=defaultdict(set)
|
||||||
for inst in dg.object_instances:
|
for inst in dg.object_instances:
|
||||||
o=inst.object;src=o.original;cols=[c.name for c in src.users_collection]
|
o=inst.object;src=o.original;cols=[c.name for c in src.users_collection]
|
||||||
if o.type not in {'MESH','CURVE'} or src.hide_render:continue
|
if o.type not in {'MESH','CURVE'} or src.hide_render:continue
|
||||||
if any(c.hide_render for c in src.users_collection) or any(('STUDIO' in c or 'BOOLEAN' in c or 'DATUM' in c) for c in cols):continue
|
if any(c.hide_render for c in src.users_collection) or any(any(t in c for t in ['STUDIO','BOOLEAN','DATUM']) for c in cols):continue
|
||||||
# Exclude standalone instancing source collections; their placed instances survive.
|
|
||||||
if not inst.is_instance and not src.visible_get():continue
|
if not inst.is_instance and not src.visible_get():continue
|
||||||
mesh=bpy.data.meshes.new_from_object(o,preserve_all_data_layers=True,depsgraph=dg)
|
mesh=bpy.data.meshes.new_from_object(o,preserve_all_data_layers=True,depsgraph=dg)
|
||||||
names=[slot.material.name.lower() if slot.material else '' for slot in o.material_slots]
|
|
||||||
for idx in range(len(mesh.materials)):
|
for idx in range(len(mesh.materials)):
|
||||||
name=names[idx] if idx<len(names) else ''
|
name=mesh.materials[idx].name if mesh.materials[idx] else '';key=classify(name,src.name)
|
||||||
kind='rubber' if any(k in name for k in ['rubber','gasket','polymer','elastomer','jacket','asa']) else 'steel' if any(k in name for k in ['zinc','steel','brass','spring']) else 'paint' if any(k in name for k in ['paint','coated','black','charcoal']) else 'metal'
|
mesh.materials[idx]=materials[key];assignment[key].add(src.name)
|
||||||
mesh.materials[idx]=materials[kind]
|
if not mesh.materials:mesh.materials.append(materials['body']);assignment['body'].add(src.name)
|
||||||
if not mesh.materials:mesh.materials.append(materials['metal'])
|
instances.append((src.name,mesh,inst.matrix_world.copy()))
|
||||||
instances.append((src.name,mesh,inst.matrix_world.copy(),inst.is_instance))
|
scene=bpy.data.scenes.new('Mission Core rover export');bpy.context.window.scene=scene;objects=[]
|
||||||
scene=bpy.data.scenes.new('Mission Core rover export');bpy.context.window.scene=scene
|
for name,mesh,matrix in instances:
|
||||||
objects=[]
|
|
||||||
for name,mesh,matrix,_ in instances:
|
|
||||||
o=bpy.data.objects.new(name,mesh);scene.collection.objects.link(o);o.matrix_world=matrix;objects.append(o)
|
o=bpy.data.objects.new(name,mesh);scene.collection.objects.link(o);o.matrix_world=matrix;objects.append(o)
|
||||||
bpy.context.view_layer.update()
|
bpy.context.view_layer.update()
|
||||||
lo=Vector((min((o.matrix_world@Vector(v))[i] for o in objects for v in o.bound_box) for i in range(3)))
|
lo=Vector((min((o.matrix_world@Vector(v))[i] for o in objects for v in o.bound_box) for i in range(3)))
|
||||||
hi=Vector((max((o.matrix_world@Vector(v))[i] for o in objects for v in o.bound_box) for i in range(3)))
|
hi=Vector((max((o.matrix_world@Vector(v))[i] for o in objects for v in o.bound_box) for i in range(3)))
|
||||||
shift=Vector((-(lo.x+hi.x)/2,-(lo.y+hi.y)/2,-lo.z))
|
shift=Vector((-(lo.x+hi.x)/2,-(lo.y+hi.y)/2,-lo.z))
|
||||||
for o in objects:o.matrix_world=Matrix.Translation(shift)@o.matrix_world
|
for o in objects:o.matrix_world=Matrix.Translation(shift)@o.matrix_world
|
||||||
source_object_count=len(objects)
|
count=len(objects)
|
||||||
|
bpy.ops.object.select_all(action='SELECT');bpy.context.view_layer.objects.active=objects[0];bpy.ops.object.join()
|
||||||
|
# Separate by material keeps picking exact while avoiding 1,426 draw calls.
|
||||||
|
bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.select_all(action='SELECT');bpy.ops.mesh.separate(type='MATERIAL');bpy.ops.object.mode_set(mode='OBJECT')
|
||||||
|
for o in list(scene.objects):
|
||||||
|
bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o
|
||||||
|
key=o.data.materials[0].name.removeprefix('rover/');o.name=specs[key][0]
|
||||||
|
for uv in list(o.data.uv_layers):o.data.uv_layers.remove(uv)
|
||||||
|
bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.select_all(action='SELECT');bpy.ops.uv.cube_project(cube_size=.2);bpy.ops.object.mode_set(mode='OBJECT')
|
||||||
bpy.ops.object.select_all(action='SELECT')
|
bpy.ops.object.select_all(action='SELECT')
|
||||||
bpy.context.view_layer.objects.active=objects[0]
|
|
||||||
bpy.ops.object.join()
|
|
||||||
joined=bpy.context.object
|
|
||||||
for uv in list(joined.data.uv_layers):joined.data.uv_layers.remove(uv)
|
|
||||||
out=root/'apps/control-station/public/rover-scene/dcd006-v020.glb'
|
out=root/'apps/control-station/public/rover-scene/dcd006-v020.glb'
|
||||||
bpy.ops.export_scene.gltf(filepath=str(out),export_format='GLB',use_selection=True,use_active_scene=True,export_animations=False,export_cameras=False,export_lights=False,export_extras=False,export_yup=True,export_apply=False,export_texcoords=False,export_attributes=False,export_vertex_color='NONE')
|
bpy.ops.export_scene.gltf(filepath=str(out),export_format='GLB',use_selection=True,use_active_scene=True,export_animations=False,export_cameras=False,export_lights=False,export_extras=False,export_yup=True,export_apply=False,export_texcoords=True,export_attributes=False,export_vertex_color='NONE')
|
||||||
manifest={'source':source.name,'source_sha256':hashlib.sha256(source.read_bytes()).hexdigest(),'model':'DCD-006 v020 complete rover','source_object_count':source_object_count,'render_mesh_count':1,'original_bounds':[list(lo),list(hi)],'dimensions_m':list(hi-lo),'translation_m':list(shift),'glb_bytes':out.stat().st_size,'glb_sha256':hashlib.sha256(out.read_bytes()).hexdigest(),'material_source_sha256':hashlib.sha256(donor.read_bytes()).hexdigest(),'material_rules':material_rules,'source_saved':False}
|
manifest={'source':source.name,'source_sha256':hashlib.sha256(source.read_bytes()).hexdigest(),'model':'DCD-006 v020 complete rover','revision':'materials-v2','source_object_count':count,'render_mesh_count':len(scene.objects),'original_bounds':[list(lo),list(hi)],'dimensions_m':list(hi-lo),'translation_m':list(shift),'glb_bytes':out.stat().st_size,'glb_sha256':hashlib.sha256(out.read_bytes()).hexdigest(),'source_saved':False,'uv':'cube projection, 0.2 m','materials':{k:{'name':v[0],'baseColorLinear':v[1],'metalness':v[2],'roughness':v[3],'sourceObjects':sorted(assignment[k])} for k,v in specs.items()}}
|
||||||
(out.parent/'dcd006-v020.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2)+'\n');print(json.dumps(manifest))
|
(out.parent/'dcd006-v020.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2)+'\n')
|
||||||
|
print('ROVER EXPORT',out.stat().st_size,'bytes;',len(scene.objects),'material groups')
|
||||||
|
|||||||
Reference in New Issue
Block a user