feat(fleet-ui): organize apparatus equipment beneath its computer
This commit is contained in:
@@ -657,11 +657,8 @@ export default function App() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [layoutSaveNotice]);
|
||||
|
||||
const [fleetCreateRequest, setFleetCreateRequest] = useState(0);
|
||||
const sessionOverview = useSessionOverviewMode(activeDefinition?.kind === "recordings");
|
||||
const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []);
|
||||
const contentActions = useApplicationPanelActions({
|
||||
onAddVehicle,
|
||||
definition: activeDefinition,
|
||||
refreshRuntime: runtime.refresh,
|
||||
resetConnectionScenario: runtime.resetConnectionScenario,
|
||||
@@ -873,7 +870,6 @@ export default function App() {
|
||||
<WorkspaceRenderer
|
||||
launchProfile={launchProfile}
|
||||
definition={activeDefinition}
|
||||
fleetCreateRequest={fleetCreateRequest}
|
||||
headerToolsHost={workspaceHeaderToolsHost}
|
||||
state={activeRuntimeState}
|
||||
backendStatus={runtime.backendStatus}
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { WorkspaceDefinition } from "../productModel";
|
||||
|
||||
interface ApplicationPanelActionsOptions {
|
||||
definition: WorkspaceDefinition | null;
|
||||
onAddVehicle?: () => void;
|
||||
refreshRuntime: () => void;
|
||||
resetConnectionScenario?: () => Promise<boolean>;
|
||||
connectionScenarioResetting: boolean;
|
||||
@@ -45,7 +44,6 @@ export function deviceRuntimeUtilityAction({
|
||||
|
||||
export function useApplicationPanelActions({
|
||||
definition,
|
||||
onAddVehicle,
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
@@ -56,7 +54,6 @@ export function useApplicationPanelActions({
|
||||
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
|
||||
return useMemo(() => {
|
||||
const actions: ApplicationPanelUtilityAction[] = [];
|
||||
if (definition?.kind === "vehicles" && onAddVehicle) actions.push({ label: "Добавить аппарат", icon: "plus", onClick: onAddVehicle });
|
||||
if (definition?.kind === "device") {
|
||||
actions.push(deviceRuntimeUtilityAction({
|
||||
refreshRuntime,
|
||||
@@ -81,7 +78,6 @@ export function useApplicationPanelActions({
|
||||
return actions;
|
||||
}, [
|
||||
definition,
|
||||
onAddVehicle,
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
|
||||
@@ -431,7 +431,7 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
case "missions":
|
||||
return <MissionPlannerWorkspace openView={props.navigation.openView} headerToolsHost={props.headerToolsHost} />;
|
||||
case "vehicles":
|
||||
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} headerToolsHost={props.headerToolsHost} />;
|
||||
return <VehiclesWorkspace headerToolsHost={props.headerToolsHost} />;
|
||||
case "catalog":
|
||||
return <CatalogWorkspace {...props} />;
|
||||
case "contour-health":
|
||||
|
||||
@@ -37,7 +37,6 @@ export interface LaboratoryViewAction {
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
launchProfile?: WorkspaceLaunchProfile;
|
||||
fleetCreateRequest?: number;
|
||||
headerToolsHost?: HTMLElement | null;
|
||||
definition: WorkspaceDefinition;
|
||||
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 {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
|
||||
import {createIsolatedRerunHost} from '../../components/rerun/isolatedRerunHost';
|
||||
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
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 sensorContributions=registry.sensorContributions;
|
||||
const layout=useMemo(()=>boardLayout(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 {fleetRequest,useFleet,type Vehicle} from '../../core/fleet/useFleet';
|
||||
import {BoardObservationCenter} from './observation/BoardObservationCenter';
|
||||
import {BoardMonitorView} from './BoardMonitorView';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { LoadingRegion, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
|
||||
import { fleetRequest, useFleet, type FleetPreview, type Vehicle } from "../../core/fleet/useFleet";
|
||||
import "./fleet.css";
|
||||
import { VehicleSensors } from "./VehicleSensors";
|
||||
import {VehicleSensors} from './VehicleSensors';
|
||||
import {BoardComputerSettings,boardStatus} from './BoardComputer';
|
||||
import {NodeInvitationForm,platformLabel} from './NodeInvitationForm';
|
||||
import './fleet.css';
|
||||
|
||||
const platforms = [{ value: "ugv", label: "Наземный (UGV)" }, { value: "uav", label: "Воздушный (UAV)" }, { value: "stationary", label: "Стационарный" }, { value: "other", label: "Другой" }];
|
||||
const platformLabel = (value: string) => platforms.find(item => item.value === value)?.label ?? value;
|
||||
function statusLabel(item: Vehicle) { return item.enrollment === "pending" ? "Подтверждаем привязку" : item.enrollment === "revoked" ? "Доверие отозвано" : item.enrollment === "failed" ? "Привязка не завершена" : item.connectivity === "online" ? "В сети" : "Нет связи"; }
|
||||
|
||||
export function VehiclesWorkspace({ createRequest = 0, headerToolsHost }: { createRequest?: number; headerToolsHost?: HTMLElement|null }) {
|
||||
const fleet = useFleet();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [platform, setPlatform] = useState("ugv");
|
||||
const [preview, setPreview] = useState<FleetPreview | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [monitorOpen,setMonitorOpen]=useState(false);
|
||||
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}/>
|
||||
<VehicleSensors vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} description={detail.name} computer={<SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
|
||||
{detail.notice && <p role="status">{detail.notice}</p>}
|
||||
<dl className="fleet-facts"><div><dt>Бортовой компьютер</dt><dd>{detail.node_id}</dd></div>
|
||||
<div><dt>Последняя связь</dt><dd>{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}</dd></div>
|
||||
{detail.host && <><div><dt>Имя БК в системе</dt><dd>{detail.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{detail.host.os}</dd></div><div><dt>Архитектура</dt><dd>{detail.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{detail.host.cpus}</dd></div><div><dt>Память</dt><dd>{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}</dd></div></>}
|
||||
</dl>
|
||||
<div className="fleet-board-actions"><Button onClick={()=>setMonitorOpen(true)}><Icon name="activity"/>Мониторинг системы БК</Button>
|
||||
<Button onClick={()=>setObservationOpen(true)}><Icon name="eye"/>Центр наблюдения и управления</Button>
|
||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}</div>
|
||||
</SettingsCard>}/>
|
||||
</> : !fleet.items ? <LoadingRegion loading label="Получение аппаратов" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="sliders" /></IconButton><IconButton label={`Центр наблюдения и управления: ${item.name}`} onClick={() => {setSelected(item.id);setObservationOpen(true);}}><Icon name="eye" /></IconButton></>} /></li>)}</ResourceList>}
|
||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавление…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверка БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
||||
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
|
||||
<Select label="Способ подключения" value="node" options={[{ value: "node", label: "С бортовым компьютером" }]} onChange={() => undefined} disabled={pending} />
|
||||
<Select label="Класс аппарата" value={platform} options={platforms} onChange={setPlatform} disabled={pending} />
|
||||
<TextField label="Название аппарата" value={name} maxLength={80} onChange={event => setName(event.target.value)} disabled={pending} autoComplete="off" />
|
||||
{preview ? <SettingsCard title={preview.name} description="Идентичность БК проверена по приглашению"><dl className="fleet-facts"><div><dt>Идентификатор БК</dt><dd>{preview.node_id}</dd></div><div><dt>Система</dt><dd>{preview.host.os} · {preview.host.architecture}</dd></div><div><dt>Адрес БК</dt><dd>{preview.endpoint}</dd></div></dl><Button disabled={pending} onClick={() => setPreview(null)}>Другое приглашение</Button></SettingsCard> : <TextAreaField label="Код приглашения из Node" value={code} rows={5} maxLength={4096} spellCheck={false} autoComplete="off" disabled={pending} onChange={event => setCode(event.target.value)} />}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</form>
|
||||
</Window>
|
||||
<ConfirmationModal open={revoking !== null} title="Отозвать привязку БК?" description={`Аппарат «${revoking?.name ?? ""}» останется в реестре, а его БК потеряет доступ к Core. БК получит отзыв при следующем соединении.`} confirmLabel="Отозвать" cancelLabel="Отмена" danger onClose={() => setRevoking(null)} onConfirm={async () => { if (!revoking) return; try { await fleetRequest(`/${encodeURIComponent(revoking.id)}`, "DELETE"); await fleet.refresh(); setRevoking(null); } catch (error) { setError(error instanceof Error ? error.message : "Не удалось отозвать привязку."); throw error; } }} />
|
||||
export function VehiclesWorkspace({headerToolsHost}:{headerToolsHost?:HTMLElement|null}){
|
||||
const fleet=useFleet();
|
||||
const [selected,setSelected]=useState<string|null>(null);
|
||||
const [adding,setAdding]=useState(false),[monitorOpen,setMonitorOpen]=useState(false),[observationOpen,setObservationOpen]=useState(false),[settingsOpen,setSettingsOpen]=useState(false);
|
||||
const [revoking,setRevoking]=useState<Vehicle|null>(null),[error,setError]=useState('');
|
||||
const detail=fleet.items?.find(item=>item.id===selected);
|
||||
const enabled=!!detail&&!fleet.error&&detail.enrollment==='paired'&&detail.connectivity==='online';
|
||||
const refresh=()=>{void fleet.refresh().catch(()=>setError('Не удалось обновить аппарат. Повторите получение сведений.'));};
|
||||
const complete=(item:Vehicle)=>{setSelected(item.id);setAdding(false);refresh();};
|
||||
const addButton=<IconButton label="Добавить аппарат" onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>;
|
||||
const headerAdd=!detail&&(headerToolsHost?createPortal(addButton,headerToolsHost):addButton);
|
||||
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}/>
|
||||
:detail&&monitorOpen?<BoardMonitorView vehicle={detail.id} name={detail.name} back={()=>setMonitorOpen(false)} headerToolsHost={headerToolsHost}/>
|
||||
:<div className="fleet-workspace">
|
||||
{detail?<>
|
||||
<WorkspaceBackButton label="К списку аппаратов" onClick={()=>setSelected(null)} host={headerToolsHost}/>
|
||||
<SettingsCard title={detail.name} description={platformLabel(detail.platform)} actions={<Button onClick={()=>setObservationOpen(true)}><Icon name="eye"/>Центр наблюдения и управления</Button>}/>
|
||||
<VehicleSensors vehicle={detail} enabled={enabled} onAddComputer={()=>setAdding(true)} onSettings={()=>setSettingsOpen(true)} onMonitor={()=>setMonitorOpen(true)}/>
|
||||
</>:!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>;
|
||||
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 > * { 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; } }
|
||||
|
||||
@@ -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.
|
||||
@@ -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