refactor(fleet-ui): consolidate apparatus header and computer status
This commit is contained in:
@@ -160,6 +160,7 @@ export default function App() {
|
|||||||
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
|
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
|
||||||
const [sourceUrl, setSourceUrl] = useState("");
|
const [sourceUrl, setSourceUrl] = useState("");
|
||||||
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
|
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [workspaceHeaderTitleHost, setWorkspaceHeaderTitleHost] = useState<HTMLSpanElement | null>(null);
|
||||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||||
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||||
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
||||||
@@ -786,7 +787,9 @@ export default function App() {
|
|||||||
key={activeDefinition.id}
|
key={activeDefinition.id}
|
||||||
eyebrow={activeDefinition.eyebrow}
|
eyebrow={activeDefinition.eyebrow}
|
||||||
title={
|
title={
|
||||||
activeDefinition.kind === "recordings"
|
activeDefinition.kind === "vehicles"
|
||||||
|
? <>{activeDefinition.title}<span className="fleet-header-title" ref={setWorkspaceHeaderTitleHost}/></>
|
||||||
|
: activeDefinition.kind === "recordings"
|
||||||
&& replayPresented
|
&& replayPresented
|
||||||
&& recordedReplayLabel
|
&& recordedReplayLabel
|
||||||
? `${activeDefinition.title}: ${recordedReplayLabel}`
|
? `${activeDefinition.title}: ${recordedReplayLabel}`
|
||||||
@@ -832,7 +835,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
) : activeDefinition.kind === "missions" ? (
|
) : activeDefinition.kind === "missions" ? (
|
||||||
<div ref={setWorkspaceHeaderToolsHost} />
|
<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>
|
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||||
) : activeDefinition.kind === "lab-archive" ? (
|
) : activeDefinition.kind === "lab-archive" ? (
|
||||||
laboratoryAnnotation.control
|
laboratoryAnnotation.control
|
||||||
@@ -871,6 +874,7 @@ export default function App() {
|
|||||||
launchProfile={launchProfile}
|
launchProfile={launchProfile}
|
||||||
definition={activeDefinition}
|
definition={activeDefinition}
|
||||||
headerToolsHost={workspaceHeaderToolsHost}
|
headerToolsHost={workspaceHeaderToolsHost}
|
||||||
|
headerTitleHost={workspaceHeaderTitleHost}
|
||||||
state={activeRuntimeState}
|
state={activeRuntimeState}
|
||||||
backendStatus={runtime.backendStatus}
|
backendStatus={runtime.backendStatus}
|
||||||
sourceUrl={effectiveSourceUrl}
|
sourceUrl={effectiveSourceUrl}
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
|||||||
case "missions":
|
case "missions":
|
||||||
return <MissionPlannerWorkspace openView={props.navigation.openView} headerToolsHost={props.headerToolsHost} />;
|
return <MissionPlannerWorkspace openView={props.navigation.openView} headerToolsHost={props.headerToolsHost} />;
|
||||||
case "vehicles":
|
case "vehicles":
|
||||||
return <VehiclesWorkspace headerToolsHost={props.headerToolsHost} />;
|
return <VehiclesWorkspace headerToolsHost={props.headerToolsHost} headerTitleHost={props.headerTitleHost} />;
|
||||||
case "catalog":
|
case "catalog":
|
||||||
return <CatalogWorkspace {...props} />;
|
return <CatalogWorkspace {...props} />;
|
||||||
case "contour-health":
|
case "contour-health":
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface LaboratoryViewAction {
|
|||||||
export interface WorkspaceRendererProps {
|
export interface WorkspaceRendererProps {
|
||||||
launchProfile?: WorkspaceLaunchProfile;
|
launchProfile?: WorkspaceLaunchProfile;
|
||||||
headerToolsHost?: HTMLElement | null;
|
headerToolsHost?: HTMLElement | null;
|
||||||
|
headerTitleHost?: HTMLElement | null;
|
||||||
definition: WorkspaceDefinition;
|
definition: WorkspaceDefinition;
|
||||||
state: MissionRuntimeState | null;
|
state: MissionRuntimeState | null;
|
||||||
backendStatus: BackendStatus;
|
backendStatus: BackendStatus;
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import {Button,Icon,IconButton,ResourceRow,StatusBadge,Window} from '@nodedc/ui-
|
|||||||
import type {Vehicle} from '../../core/fleet/useFleet';
|
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 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);
|
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}/>}
|
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}){
|
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}>
|
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:'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">
|
{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 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>}
|
{computerOpen&&<div className="fleet-computer-devices">{content.devices}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>},
|
</div>},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {createPortal} from 'react-dom';
|
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 {WorkspaceBackButton} from '../../components/WorkspaceBackButton';
|
||||||
import {fleetRequest,useFleet,type Vehicle} from '../../core/fleet/useFleet';
|
import {fleetRequest,useFleet,type Vehicle} from '../../core/fleet/useFleet';
|
||||||
import {BoardObservationCenter} from './observation/BoardObservationCenter';
|
import {BoardObservationCenter} from './observation/BoardObservationCenter';
|
||||||
@@ -10,7 +10,7 @@ import {BoardComputerSettings,boardStatus} from './BoardComputer';
|
|||||||
import {NodeInvitationForm,platformLabel} from './NodeInvitationForm';
|
import {NodeInvitationForm,platformLabel} from './NodeInvitationForm';
|
||||||
import './fleet.css';
|
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 fleet=useFleet();
|
||||||
const [selected,setSelected]=useState<string|null>(null);
|
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 [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 complete=(item:Vehicle)=>{setSelected(item.id);setAdding(false);refresh();};
|
||||||
const addButton=<IconButton label="Добавить аппарат" onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>;
|
const addButton=<IconButton label="Добавить аппарат" onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>;
|
||||||
const headerAdd=!detail&&(headerToolsHost?createPortal(addButton,headerToolsHost):addButton);
|
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}/>
|
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}/>
|
:detail&&monitorOpen?<BoardMonitorView vehicle={detail.id} name={detail.name} back={()=>setMonitorOpen(false)} headerToolsHost={headerToolsHost}/>
|
||||||
:<div className="fleet-workspace">
|
:<div className="fleet-workspace">
|
||||||
{detail?<>
|
{detail?<>
|
||||||
<WorkspaceBackButton label="К списку аппаратов" onClick={()=>setSelected(null)} host={headerToolsHost}/>
|
<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)}/>
|
<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>}
|
</>:!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>;
|
</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>}/>}
|
{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);}}/>}
|
{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;}}}/>
|
<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, .fleet-form { display: flex; flex-direction: column; gap: var(--nodedc-space-4); }
|
||||||
.fleet-workspace { padding: 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 { 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 > 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; }
|
.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]);
|
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 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;
|
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}`}>
|
const navigationActions=<><IconButton label="Конфигуратор" onClick={configure}><Icon name="sliders"/></IconButton><IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton></>;
|
||||||
<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>
|
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>
|
<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));}}>
|
<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>
|
<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
|
does not erase the nested preference. Scene and observation layout identities
|
||||||
remain keyed by the apparatus.
|
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
|
Offline Core reads only its cached fleet inventory, never queries the absent
|
||||||
hardware or shows an endless discovery spinner. Cached devices stay visible;
|
hardware or shows an endless discovery spinner. Cached devices stay visible;
|
||||||
their current connection is unconfirmed, and mutations stay disabled.
|
their current connection is unconfirmed, and mutations stay disabled.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type {RerunHostFactory} from './rerunHost';
|
|||||||
import './sensors.css';
|
import './sensors.css';
|
||||||
import {BoardSections,type BoardSectionsProps} from './BoardSections';
|
import {BoardSections,type BoardSectionsProps} from './BoardSections';
|
||||||
import {WirelessEnrollmentWindow} from './WirelessEnrollmentWindow';
|
import {WirelessEnrollmentWindow} from './WirelessEnrollmentWindow';
|
||||||
export 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}){
|
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[],board,renderBoard,readOfflineInventory=false}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[];board?:BoardSectionsProps;renderBoard?:(view:SensorBoardContent)=>ReactNode;readOfflineInventory?:boolean}){
|
||||||
const [adding,setAdding]=useState(false);
|
const [adding,setAdding]=useState(false);
|
||||||
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<ReadonlyMap<string,string>>(()=>new Map());const activeActions=useRef(new Map<string,string>());const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
|
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<ReadonlyMap<string,string>>(()=>new Map());const activeActions=useRef(new Map<string,string>());const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
|
||||||
@@ -55,12 +55,12 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
|||||||
const prep=devicePreparation(inventory!,item);
|
const prep=devicePreparation(inventory!,item);
|
||||||
const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?(operation?.action_id==='prepare'?'Подготовка устройства':'Выполняется команда'):status.label;
|
const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?(operation?.action_id==='prepare'?'Подготовка устройства':'Выполняется команда'):status.label;
|
||||||
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy&&pending==='prepare'?preparationProgress(prep,label):undefined} status={<StatusBadge variant={configured?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured?null:label}</StatusBadge>} actions={<><SensorRowActions device={item} actions={sensorContribution(contributions,item)?.rowActions?.(item)} enabled={enabled&&fresh} busy={busy} pending={pending} perform={(name,parameters)=>void action(item,name,parameters)}/>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.initializable===false||item.preparation_safe===false||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}{sensorContribution(contributions,item)?.detailLabel?<Button disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}>{sensorContribution(contributions,item)?.detailLabel}</Button>:<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton>}</>}/></li>;})}</ResourceList>
|
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy&&pending==='prepare'?preparationProgress(prep,label):undefined} status={<StatusBadge variant={configured?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured?null:label}</StatusBadge>} actions={<><SensorRowActions device={item} actions={sensorContribution(contributions,item)?.rowActions?.(item)} enabled={enabled&&fresh} busy={busy} pending={pending} perform={(name,parameters)=>void action(item,name,parameters)}/>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.initializable===false||item.preparation_safe===false||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}{sensorContribution(contributions,item)?.detailLabel?<Button disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}>{sensorContribution(contributions,item)?.detailLabel}</Button>:<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton>}</>}/></li>;})}</ResourceList>
|
||||||
{!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}/>
|
<SensorPreparations inventory={enabled&&fresh?inventory:null}/>
|
||||||
</>;
|
</>;
|
||||||
const boardSettings=contributions.filter(value=>value.BoardSettings&&contributions.filter(other=>other.kind===value.kind).length===1);
|
const boardSettings=contributions.filter(value=>value.BoardSettings&&contributions.filter(other=>other.kind===value.kind).length===1);
|
||||||
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>;
|
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>
|
<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}/>}
|
{adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow enabled={enabled} contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}} onComplete={completeEnrollment}/>}
|
||||||
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||||
|
|||||||
Reference in New Issue
Block a user