feat(fleet): preserve operator VESC integration before final driver merge
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import {useEffect,useSyncExternalStore,type ReactNode} from 'react';
|
||||
import {Button,Icon,Inspector,LoadingRegion} from '@nodedc/ui-react';
|
||||
import type {BoardLayoutStore} from './boardLayout';
|
||||
|
||||
export interface BoardSectionsProps {
|
||||
layout:BoardLayoutStore;
|
||||
computer:ReactNode;
|
||||
description?:string;
|
||||
}
|
||||
export function BoardSections({layout,computer,description,settings,devices}:BoardSectionsProps&{settings:ReactNode;devices:ReactNode}){
|
||||
const state=useSyncExternalStore(layout.subscribe,layout.getSnapshot);
|
||||
useEffect(()=>{void layout.load();},[layout]);
|
||||
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} onOpenSectionsChange={layout.change} sections={[
|
||||
{id:'computer',label:'Бортовой компьютер',description,icon:<Icon name="apps"/>,disabled:!state.ready,content:computer},
|
||||
{id:'settings',label:'Настройки борта',icon:<Icon name="sliders"/>,disabled:!state.ready,content:settings},
|
||||
{id:'devices',label:'Устройства аппарата',icon:<Icon name="camera"/>,disabled:!state.ready,content:devices},
|
||||
]}/>
|
||||
</LoadingRegion>
|
||||
</div>;
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import {sensorStatus} from './sensorStatus';
|
||||
import {sensorContribution,type SensorUiContribution,wirelessContributions} from './extensions';
|
||||
import type {RerunHostFactory} from './rerunHost';
|
||||
import './sensors.css';
|
||||
import {BoardSections,type BoardSectionsProps} from './BoardSections';
|
||||
import {WirelessEnrollmentWindow} from './WirelessEnrollmentWindow';
|
||||
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[]}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[]}){
|
||||
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}){
|
||||
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 failure=useCallback((e:unknown)=>{setError(e===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
|
||||
@@ -44,7 +45,7 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
||||
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
|
||||
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
|
||||
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
|
||||
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>:<>
|
||||
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>
|
||||
{!inventory?<LoadingRegion loading label="Получение устройств БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<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);
|
||||
@@ -52,9 +53,11 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
||||
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
||||
const prep=devicePreparation(inventory,item);
|
||||
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>}<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}/>
|
||||
</>}
|
||||
</>;
|
||||
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)}
|
||||
<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}/>}
|
||||
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
export const boardSections = ['computer', 'settings', 'devices'] as const;
|
||||
export type BoardSection = typeof boardSections[number];
|
||||
export interface BoardLayout {schema:'missioncore.board-layout/v1';revision:number;open_sections:BoardSection[]}
|
||||
export interface BoardLayoutTransport {
|
||||
read:()=>Promise<BoardLayout>;
|
||||
patch:(section:BoardSection,open:boolean)=>Promise<BoardLayout>;
|
||||
}
|
||||
export const defaultBoardLayout:BoardLayout={schema:'missioncore.board-layout/v1',revision:0,open_sections:[...boardSections]};
|
||||
type Change={section:BoardSection;open:boolean};
|
||||
type Snapshot={value:BoardLayout;ready:boolean;error:string|null;saving:boolean};
|
||||
function apply(value:BoardLayout,change:Change):BoardLayout {
|
||||
const open=new Set(value.open_sections);
|
||||
if(change.open)open.add(change.section);else open.delete(change.section);
|
||||
return {...value,open_sections:boardSections.filter(section=>open.has(section))};
|
||||
}
|
||||
function validate(value:BoardLayout):BoardLayout {
|
||||
if(value.schema!==defaultBoardLayout.schema||!Number.isSafeInteger(value.revision)||value.revision<0||
|
||||
!Array.isArray(value.open_sections)||new Set(value.open_sections).size!==value.open_sections.length||
|
||||
value.open_sections.some(id=>!boardSections.includes(id)))throw new Error('Не удалось прочитать раскладку блоков.');
|
||||
return value;
|
||||
}
|
||||
// The queue belongs to the application resource, not a mounted accordion.
|
||||
// Navigation cannot discard a pending save or let an older reply win a toggle.
|
||||
export function createBoardLayoutStore(transport:BoardLayoutTransport){
|
||||
let saved=defaultBoardLayout;
|
||||
let snapshot:Snapshot={value:saved,ready:false,error:null,saving:false};
|
||||
let pending:Change[]=[];
|
||||
let reading:Promise<void>|null=null;
|
||||
let writing=false;
|
||||
const listeners=new Set<()=>void>();
|
||||
const emit=(patch:Partial<Snapshot>={})=>{
|
||||
snapshot={...snapshot,...patch,value:pending.reduce(apply,saved),saving:writing||pending.length>0};
|
||||
listeners.forEach(listener=>listener());
|
||||
};
|
||||
const load=():Promise<void>=>{
|
||||
if(reading)return reading;
|
||||
if(writing)return Promise.resolve();
|
||||
reading=transport.read().then(value=>{saved=validate(value);emit({ready:true,error:null});})
|
||||
.catch(()=>emit({error:'Раскладка блоков не загружена. Повторите подключение.'}))
|
||||
.finally(()=>{reading=null;});
|
||||
return reading;
|
||||
};
|
||||
const flush=async()=>{
|
||||
if(writing)return;
|
||||
writing=true;emit({error:null});
|
||||
while(pending.length){
|
||||
const change=pending[0];
|
||||
try{saved=validate(await transport.patch(change.section,change.open));pending.shift();emit();}
|
||||
catch{pending=[];emit({error:'Не удалось сохранить раскладку блоков. Изменение отменено.'});break;}
|
||||
}
|
||||
writing=false;emit();
|
||||
};
|
||||
return {
|
||||
getSnapshot:()=>snapshot,
|
||||
subscribe:(listener:()=>void)=>{listeners.add(listener);return()=>{listeners.delete(listener);};},
|
||||
load,
|
||||
change:(open:string[])=>{
|
||||
if(!snapshot.ready||reading)return;
|
||||
for(const section of boardSections){
|
||||
if(open.includes(section)!==snapshot.value.open_sections.includes(section))pending.push({section,open:open.includes(section)});
|
||||
}
|
||||
emit();void flush();
|
||||
},
|
||||
};
|
||||
}
|
||||
export type BoardLayoutStore=ReturnType<typeof createBoardLayoutStore>;
|
||||
@@ -4,6 +4,7 @@ export interface Sensor {
|
||||
kind?:string; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;can_verify?:boolean;network_applied?:boolean;reason_code?:string|null;acquisition_id:string|null;acquisition_phase?:string};
|
||||
live_settings?: Record<string,unknown>;
|
||||
camera_status?: Record<string,unknown>;
|
||||
vesc_status?: Record<string,unknown>;
|
||||
id: string; name: string; model: string; initializable?:boolean; preparation_safe?:boolean; prepared: boolean; configured?: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
|
||||
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
|
||||
profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[];
|
||||
@@ -33,6 +34,10 @@ export interface SensorCommand {
|
||||
}
|
||||
export interface SensorOperation {state:string;error?:string;result?:unknown}
|
||||
export interface SensorTransport {
|
||||
configurationArchive?: {
|
||||
list:(deviceId:string,before?:string)=>Promise<{items:ConfigurationVersion[];next:string|null}>;
|
||||
read:(deviceId:string,versionId:string)=>Promise<Record<string,unknown>>;
|
||||
};
|
||||
localPreview?: {
|
||||
open: (command:SensorCommand, signal:AbortSignal) => Promise<Response>;
|
||||
read: (peer:string, after:number, signal:AbortSignal) => Promise<Response>;
|
||||
@@ -43,6 +48,7 @@ export interface SensorTransport {
|
||||
submit: (value:SensorCommand) => Promise<SensorOperation>;
|
||||
operation: (id:string) => Promise<SensorOperation>;
|
||||
}
|
||||
export interface ConfigurationVersion {id:string;sequence:number;device_id:string;observed_at:string;firmware:string;configs:Record<string,{sha256:string;bytes:number}>}
|
||||
export function command(device:Sensor,action:string,parameters:Record<string,unknown>={},timeoutMs=60000):SensorCommand {
|
||||
const id='op_'+crypto.randomUUID().replaceAll('-','');const now=Date.now();
|
||||
return {api_version:'missioncore.nodedc/plugin-sdk/v0alpha2',kind:'OperationRequest',operation_id:id,idempotency_key:id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {ComponentType,ReactNode} from 'react';
|
||||
import type {IconName} from '@nodedc/ui-react';
|
||||
import type {Sensor, SensorTransport} from './contracts';
|
||||
import type {Sensor, SensorInventory, SensorTransport} from './contracts';
|
||||
import type {EnrollmentTransport} from './enrollment';
|
||||
import type {RerunHostFactory} from './rerunHost';
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface SensorEnrollmentProps {
|
||||
renderWindow:(view:{content:ReactNode;actions?:ReactNode;busy?:boolean})=>ReactNode;
|
||||
}
|
||||
export interface SensorUiContribution {
|
||||
BoardSettings?:ComponentType<SensorBoardSettingsProps>;
|
||||
observation?:import('./observation').SensorObservationContribution;
|
||||
kind:string;
|
||||
Detail:ComponentType<SensorDetailProps>;
|
||||
@@ -22,11 +23,17 @@ export interface SensorUiContribution {
|
||||
retainOffline:boolean;
|
||||
supportsPreparation:boolean;
|
||||
supportsRenaming?:boolean;
|
||||
detailLabel?:string;
|
||||
rowActions?:(device:Sensor)=>readonly SensorRowAction[];
|
||||
status?:(device:Sensor,fresh:boolean)=>{label:string;tone:'neutral'|'success'|'warning'|'danger'};
|
||||
wirelessEnrollment?:{label:string;View:ComponentType<SensorEnrollmentProps>};
|
||||
}
|
||||
|
||||
export interface SensorBoardSettingsProps {
|
||||
inventory:SensorInventory|null;transport:SensorTransport;enabled:boolean;
|
||||
refresh:()=>Promise<void>;failure:(error:unknown)=>void;openDevice:(id:string)=>void;
|
||||
}
|
||||
|
||||
export interface SensorRowAction {
|
||||
actionId:string; label:string; description?:string; disabled?:boolean;
|
||||
parameters?:Record<string,unknown>;
|
||||
|
||||
Reference in New Issue
Block a user