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);
}, [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; } }