feat(fleet-ui): organize apparatus equipment beneath its computer
This commit is contained in:
@@ -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 {perform,enrolledDevice,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
|
||||
import {SensorDetail} from './SensorDetail';
|
||||
@@ -11,20 +11,21 @@ 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=[],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}
|
||||
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 [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 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(()=>{
|
||||
if(!enabled){setFresh(false);return;}
|
||||
if(!enabled&&!readOfflineInventory){setFresh(false);return;}
|
||||
let active=true;let fallback:ReturnType<typeof setInterval>|undefined;
|
||||
const unavailable=()=>{if(!active)return;setFresh(false);if(!fallback)fallback=setInterval(()=>void refresh(),3000);};
|
||||
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();
|
||||
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>={}){
|
||||
if(activeActions.current.has(device.id))return;
|
||||
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);
|
||||
}
|
||||
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);
|
||||
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
|
||||
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
|
||||
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);
|
||||
{!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>}
|
||||
<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||
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 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;
|
||||
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}/>
|
||||
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>
|
||||
{!inventory&&enabled&&!error?<LoadingRegion loading label="Получение устройств БК"/>:!enabled?<SettingsCard title="БК недоступен" description="Показаны последние сохранённые устройства. Их состояние обновится после восстановления связи."/>: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);
|
||||
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,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>
|
||||
{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('')}/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
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';
|
||||
|
||||
export function WirelessEnrollmentWindow({contributions,transport,onClose,onChange,onComplete}:{
|
||||
export function WirelessEnrollmentWindow({contributions,transport,onClose,onChange,onComplete,enabled=true}:{
|
||||
contributions:readonly SensorUiContribution[];
|
||||
transport:SensorEnrollmentProps['transport'];onClose:()=>void;onChange:()=>void;
|
||||
onComplete:SensorEnrollmentProps['onComplete'];
|
||||
enabled?:boolean;
|
||||
}) {
|
||||
const [selected,setSelected]=useState('');
|
||||
const supported=wirelessContributions(contributions);
|
||||
const Enrollment=supported.find(value=>value.kind===selected)?.wirelessEnrollment?.View;
|
||||
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}>
|
||||
<div className="sensor-content">
|
||||
<Select label="Выбор поддерживаемого устройства" value={selected} disabled={busy}
|
||||
<Select label="Тип устройства" value={selected} disabled={busy}
|
||||
options={[{value:'',label:'Выберите поддерживаемое устройство',disabled:true},
|
||||
...supported.map(value=>({value:value.kind,label:value.wirelessEnrollment!.label}))]}
|
||||
onChange={setSelected}/>
|
||||
@@ -22,5 +23,6 @@ export function WirelessEnrollmentWindow({contributions,transport,onClose,onChan
|
||||
</div>
|
||||
</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});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user