refactor(fleet-ui): consolidate apparatus header and computer status

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 21:58:57 +03:00
parent e2b0d839eb
commit ee7576b7b4
10 changed files with 32 additions and 16 deletions
+6 -2
View File
@@ -160,6 +160,7 @@ export default function App() {
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
const [sourceUrl, setSourceUrl] = useState("");
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
const [workspaceHeaderTitleHost, setWorkspaceHeaderTitleHost] = useState<HTMLSpanElement | null>(null);
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
const [replayTransitioning, setReplayTransitioning] = useState(false);
@@ -786,7 +787,9 @@ export default function App() {
key={activeDefinition.id}
eyebrow={activeDefinition.eyebrow}
title={
activeDefinition.kind === "recordings"
activeDefinition.kind === "vehicles"
? <>{activeDefinition.title}<span className="fleet-header-title" ref={setWorkspaceHeaderTitleHost}/></>
: activeDefinition.kind === "recordings"
&& replayPresented
&& recordedReplayLabel
? `${activeDefinition.title}: ${recordedReplayLabel}`
@@ -832,7 +835,7 @@ export default function App() {
/>
) : activeDefinition.kind === "missions" ? (
<div ref={setWorkspaceHeaderToolsHost} />
) : activeDefinition.kind === "vehicles" ? <div ref={setWorkspaceHeaderToolsHost} /> : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
) : activeDefinition.kind === "vehicles" ? <div className="fleet-header-tools" ref={setWorkspaceHeaderToolsHost} /> : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? (
laboratoryAnnotation.control
@@ -871,6 +874,7 @@ export default function App() {
launchProfile={launchProfile}
definition={activeDefinition}
headerToolsHost={workspaceHeaderToolsHost}
headerTitleHost={workspaceHeaderTitleHost}
state={activeRuntimeState}
backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl}
@@ -431,7 +431,7 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
case "missions":
return <MissionPlannerWorkspace openView={props.navigation.openView} headerToolsHost={props.headerToolsHost} />;
case "vehicles":
return <VehiclesWorkspace headerToolsHost={props.headerToolsHost} />;
return <VehiclesWorkspace headerToolsHost={props.headerToolsHost} headerTitleHost={props.headerTitleHost} />;
case "catalog":
return <CatalogWorkspace {...props} />;
case "contour-health":
@@ -38,6 +38,7 @@ export interface LaboratoryViewAction {
export interface WorkspaceRendererProps {
launchProfile?: WorkspaceLaunchProfile;
headerToolsHost?: HTMLElement | null;
headerTitleHost?: HTMLElement | null;
definition: WorkspaceDefinition;
state: MissionRuntimeState | null;
backendStatus: BackendStatus;
@@ -2,11 +2,11 @@ import {Button,Icon,IconButton,ResourceRow,StatusBadge,Window} from '@nodedc/ui-
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}){
export function BoardComputerRow({vehicle,enabled,showingSavedDevices,onSettings,onMonitor,onAdd,open,onToggle}:{vehicle:Vehicle;enabled:boolean;showingSavedDevices: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"
return <ResourceRow icon={<Icon name="apps"/>} title="Бортовой компьютер" description={vehicle.host?.hostname||'Mission Core Node'} metadata={`${label}${showingSavedDevices?' (показаны последние сохранённые устройства)':''}`} 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></>}/>;
actions={<>{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><IconButton label={open?'Свернуть устройства БК':'Показать устройства БК'} aria-expanded={open} onClick={onToggle}><Icon name={open?'chevron-down':'chevron-right'}/></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}>
@@ -19,7 +19,7 @@ export function VehicleEquipment({vehicle,enabled,layout,content,onAddComputer,o
{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}/>
<div><BoardComputerRow vehicle={vehicle} enabled={enabled} showingSavedDevices={content.showingSavedDevices} 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>},
@@ -1,6 +1,6 @@
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 {LoadingRegion,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';
@@ -10,7 +10,7 @@ import {BoardComputerSettings,boardStatus} from './BoardComputer';
import {NodeInvitationForm,platformLabel} from './NodeInvitationForm';
import './fleet.css';
export function VehiclesWorkspace({headerToolsHost}:{headerToolsHost?:HTMLElement|null}){
export function VehiclesWorkspace({headerToolsHost,headerTitleHost}:{headerToolsHost?:HTMLElement|null;headerTitleHost?: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);
@@ -21,16 +21,18 @@ export function VehiclesWorkspace({headerToolsHost}:{headerToolsHost?:HTMLElemen
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 titleSuffix=detail?` — ${detail.name} / ${platformLabel(detail.platform)}`:'';
const observationButton=<IconButton label="Центр наблюдения и управления" onClick={()=>setObservationOpen(true)}><Icon name="eye"/></IconButton>;
const headerObservation=detail&&!observationOpen&&!monitorOpen&&(headerToolsHost?createPortal(observationButton,headerToolsHost):observationButton);
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}
return <>{headerTitleHost&&createPortal(titleSuffix,headerTitleHost)}{headerAdd}{headerObservation}{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;}}}/>
@@ -1,5 +1,7 @@
.fleet-workspace, .fleet-form { display: flex; flex-direction: column; gap: var(--nodedc-space-4); }
.fleet-workspace { padding: var(--nodedc-space-4); }
.fleet-header-tools { display:flex; align-items:center; gap:var(--nodedc-space-2); }
.nodedc-application-panel__titles h1 > .fleet-header-title { font:inherit; color:inherit; letter-spacing:inherit; text-transform:none; }
.fleet-facts { display: grid; gap: var(--nodedc-space-3); }
.fleet-facts > div { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr); gap: var(--nodedc-space-3); }
.fleet-facts dd { margin: 0; overflow-wrap: anywhere; }
@@ -48,8 +48,9 @@ export function BoardObservationCenter({vehicleID,name,enabled,back,configure,he
useEffect(()=>{const escape=(event:KeyboardEvent)=>{if(event.key!=='Escape'||layersOpen||document.querySelector('[role=dialog], [role=listbox]'))return;if(effectiveFull){setFull(null);event.stopImmediatePropagation();}else if(expanded){setExpanded(false);event.stopImmediatePropagation();}};window.addEventListener('keydown',escape,true);return()=>window.removeEventListener('keydown',escape,true);},[effectiveFull,expanded,layersOpen]);
const setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,catalogVisible:show?[...new Set([...(value.catalogVisible??[]),...(sources.find(source=>source.key===id)?.catalog?[id]:[])])]:(value.catalogVisible??[]).filter(key=>key!==id),hidden:show?value.hidden.filter(key=>key!==id):[...new Set([...value.hidden,id])]}));
const layerFor=(key:string,view:SensorObservationView)=>view.layers?.find(layer=>layer.value===layout.layers[key])?.value??view.layers?.[0]?.value??view.id;
return <>{headerToolsHost&&createPortal(<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>,headerToolsHost)}<div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения и управления · ${name}`}>
<header className="observation-toolbar"><div><h2>Центр наблюдения и управления</h2><span>{name}</span></div><div className="observation-actions">{(!headerToolsHost||expanded)&&<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>}<Button onClick={configure}><Icon name="sliders"/>Конфигуратор</Button><Button onClick={()=>{setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения и управления'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
const navigationActions=<><IconButton label="Конфигуратор" onClick={configure}><Icon name="sliders"/></IconButton><IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton></>;
return <>{headerToolsHost&&!expanded&&createPortal(navigationActions,headerToolsHost)}<div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения и управления · ${name}`}>
<header className="observation-toolbar"><div><h2>Центр наблюдения и управления</h2><span>{name}</span></div><div className="observation-actions">{(!headerToolsHost||expanded)&&navigationActions}<Button onClick={()=>{setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения и управления'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
<div className="observation-summary"><StatusBadge tone={enabled&&inventory&&inventory.fresh!==false?'success':'neutral'}>{!inventory?'Получение источников':!enabled||inventory.fresh===false?'Нет свежих данных с БК':`Устройства: ${groups.length} · Окна: ${visible.length}`}</StatusBadge>{effectiveFull&&<Button onClick={()=>setFull(null)}>Все окна</Button>}</div>
<DragDropRoot onDragEnd={({activeId,overId})=>{if(overId)setLayout(value=>moveObservation(value,ids,activeId,overId));}}>
<div className="observation-deck">{!inventory?<LoadingRegion loading label="Получение визуальных источников"/>:visible.length?<ObservationDeck ids={effectiveFull?[effectiveFull]:visible} mounts={mounts.current} layout={layout} onSplit={(key,value)=>setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:<div className="observation-empty"><p>Все окна скрыты.</p><Button onClick={()=>{setLayersOpen(true);}}>Доступные слои</Button></div>}</div>
@@ -29,6 +29,12 @@ 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.
Owner refinement later on 25.09: the apparatus name and class are appended to
the shared panel title. The separate summary card is removed; observation and
configuration are opposite header icon actions. Computer branch collapse is
the rightmost row action. When cached devices are shown, their explanation is
inline after the computer status rather than a separate offline card.
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.
+3 -3
View File
@@ -11,7 +11,7 @@ import type {RerunHostFactory} from './rerunHost';
import './sensors.css';
import {BoardSections,type BoardSectionsProps} from './BoardSections';
import {WirelessEnrollmentWindow} from './WirelessEnrollmentWindow';
export interface SensorBoardContent {settings:ReactNode;devices:ReactNode;refresh:()=>Promise<void>;addDevice:()=>void;canAddDevice:boolean}
export interface SensorBoardContent {settings:ReactNode;devices:ReactNode;refresh:()=>Promise<void>;addDevice:()=>void;canAddDevice:boolean;showingSavedDevices: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);
@@ -55,12 +55,12 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
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>
{!inventory&&enabled&&!error?<LoadingRegion loading label="Получение устройств БК"/>:!enabled?<SettingsCard title="БК недоступен" description="Показаны последние сохранённые устройства. Их состояние обновится после восстановления связи."/>:inventory&&connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:null}
{!inventory&&enabled&&!error?<LoadingRegion loading label="Получение устройств БК"/>:!enabled?(!renderBoard?<SettingsCard title="БК недоступен" description="Показаны последние сохранённые устройства. Их состояние обновится после восстановления связи."/>:null):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 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)}
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,showingSavedDevices:connected.length>0&&(!enabled||!fresh),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 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('')}/>