feat(fleet-ui): organize apparatus equipment beneath its computer

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 21:38:25 +03:00
parent d2ec9e95b1
commit e2b0d839eb
13 changed files with 225 additions and 102 deletions
-4
View File
@@ -657,11 +657,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,
@@ -873,7 +870,6 @@ export default function App() {
<WorkspaceRenderer <WorkspaceRenderer
launchProfile={launchProfile} launchProfile={launchProfile}
definition={activeDefinition} definition={activeDefinition}
fleetCreateRequest={fleetCreateRequest}
headerToolsHost={workspaceHeaderToolsHost} headerToolsHost={workspaceHeaderToolsHost}
state={activeRuntimeState} state={activeRuntimeState}
backendStatus={runtime.backendStatus} backendStatus={runtime.backendStatus}
@@ -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,
@@ -431,7 +431,7 @@ 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} />;
case "catalog": case "catalog":
return <CatalogWorkspace {...props} />; return <CatalogWorkspace {...props} />;
case "contour-health": case "contour-health":
@@ -37,7 +37,6 @@ export interface LaboratoryViewAction {
export interface WorkspaceRendererProps { export interface WorkspaceRendererProps {
launchProfile?: WorkspaceLaunchProfile; launchProfile?: WorkspaceLaunchProfile;
fleetCreateRequest?: number;
headerToolsHost?: HTMLElement | null; headerToolsHost?: HTMLElement | null;
definition: WorkspaceDefinition; definition: WorkspaceDefinition;
state: MissionRuntimeState | null; state: MissionRuntimeState | null;
@@ -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,onSettings,onMonitor,onAdd,open,onToggle}:{vehicle:Vehicle;enabled: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} statusPlacement="leading"
status={<StatusBadge variant="indicator" tone={enabled?'success':vehicle.enrollment==='pending'?'warning':'neutral'} aria-label={label} title={label}/>}
actions={<><IconButton label={open?'Свернуть устройства БК':'Показать устройства БК'} aria-expanded={open} onClick={onToggle}><Icon name={open?'chevron-down':'chevron-right'}/></IconButton>{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></>}/>;
}
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>;
}
@@ -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,29 @@
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 {BoardComputerRow} from './BoardComputer';
export function VehicleEquipment({vehicle,enabled,layout,content,onAddComputer,onSettings,onMonitor}:{vehicle:Vehicle;enabled:boolean;layout:BoardLayoutStore;content:SensorBoardContent;onAddComputer:()=>void;onSettings:()=>void;onMonitor:()=>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:content.settings},
{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} 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>;
}
@@ -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}:{vehicle:Vehicle;enabled:boolean;onAddComputer:()=>void;onSettings:()=>void;onMonitor:()=>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}/>}/>;
} }
@@ -1,76 +1,39 @@
import {useState} from 'react';
import {createPortal} from 'react-dom';
import {LoadingRegion,Button,ConfirmationModal,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
import {WorkspaceBackButton} from '../../components/WorkspaceBackButton'; 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}:{headerToolsHost?: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 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 [error, setError] = useState(""); :detail&&monitorOpen?<BoardMonitorView vehicle={detail.id} name={detail.name} back={()=>setMonitorOpen(false)} headerToolsHost={headerToolsHost}/>
const [selected, setSelected] = useState<string | null>(null); :<div className="fleet-workspace">
const [monitorOpen,setMonitorOpen]=useState(false); {detail?<>
const [observationOpen,setObservationOpen]=useState(false);
const [revoking, setRevoking] = useState<Vehicle | null>(null);
const lastCreateRequest = useRef(createRequest);
useEffect(() => { if (createRequest !== lastCreateRequest.current) { lastCreateRequest.current = createRequest; setAdding(true); setError(""); } }, [createRequest]);
function close() { if (pending) return; setAdding(false); setPreview(null); setCode(""); setName(""); setError(""); }
async function inspect(event: React.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 (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)} headerToolsHost={headerToolsHost}/>;
return <div className="fleet-workspace">
{fleet.error && <p role="alert">{fleet.error}</p>}
{!adding && error && <p role="alert">{error}</p>}
{detail ? <>
<WorkspaceBackButton label="К списку аппаратов" onClick={()=>setSelected(null)} host={headerToolsHost}/> <WorkspaceBackButton label="К списку аппаратов" onClick={()=>setSelected(null)} host={headerToolsHost}/>
<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>}> <SettingsCard title={detail.name} description={platformLabel(detail.platform)} actions={<Button onClick={()=>setObservationOpen(true)}><Icon name="eye"/>Центр наблюдения и управления</Button>}/>
{detail.notice && <p role="status">{detail.notice}</p>} <VehicleSensors vehicle={detail} enabled={enabled} onAddComputer={()=>setAdding(true)} onSettings={()=>setSettingsOpen(true)} onMonitor={()=>setMonitorOpen(true)}/>
<dl className="fleet-facts"><div><dt>Бортовой компьютер</dt><dd>{detail.node_id}</dd></div> </>:!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>}
<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 <>{headerAdd}{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('')}/>
</>;
} }
@@ -10,4 +10,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; } }
@@ -0,0 +1,69 @@
# 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.
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.
## 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.
+18 -15
View File
@@ -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}
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?<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); 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> <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});
} }