diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index 4dc1090..83fe32c 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -160,6 +160,7 @@ export default function App() { const [launchProfile, setLaunchProfile] = useState('direct'); const [sourceUrl, setSourceUrl] = useState(""); const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState(null); + const [workspaceHeaderTitleHost, setWorkspaceHeaderTitleHost] = useState(null); const [recordedReplay, setRecordedReplay] = useState(null); const [recordedReplayLabel, setRecordedReplayLabel] = useState(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} + : activeDefinition.kind === "recordings" && replayPresented && recordedReplayLabel ? `${activeDefinition.title}: ${recordedReplayLabel}` @@ -832,7 +835,7 @@ export default function App() { /> ) : activeDefinition.kind === "missions" ? (
- ) : activeDefinition.kind === "vehicles" ?
: activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? ( + ) : activeDefinition.kind === "vehicles" ?
: activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? ( Offline evaluation ) : 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} diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index 2aeadd5..7c7d476 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -431,7 +431,7 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) { case "missions": return ; case "vehicles": - return ; + return ; case "catalog": return ; case "contour-health": diff --git a/apps/control-station/src/workspaces/contracts.ts b/apps/control-station/src/workspaces/contracts.ts index e94a54f..dccc995 100644 --- a/apps/control-station/src/workspaces/contracts.ts +++ b/apps/control-station/src/workspaces/contracts.ts @@ -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; diff --git a/apps/control-station/src/workspaces/fleet/BoardComputer.tsx b/apps/control-station/src/workspaces/fleet/BoardComputer.tsx index 1539d93..2e0f544 100644 --- a/apps/control-station/src/workspaces/fleet/BoardComputer.tsx +++ b/apps/control-station/src/workspaces/fleet/BoardComputer.tsx @@ -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 } title="Бортовой компьютер" description={vehicle.host?.hostname||'Mission Core Node'} metadata={label} statusPlacement="leading" + return } title="Бортовой компьютер" description={vehicle.host?.hostname||'Mission Core Node'} metadata={`${label}${showingSavedDevices?' (показаны последние сохранённые устройства)':''}`} statusPlacement="leading" status={} - actions={<>{onAdd&&}}/>; + actions={<>{onAdd&&}}/>; } export function BoardComputerSettings({vehicle,enabled,onClose,onRevoke}:{vehicle:Vehicle;enabled:boolean;onClose:()=>void;onRevoke:()=>void}){ return diff --git a/apps/control-station/src/workspaces/fleet/VehicleEquipment.tsx b/apps/control-station/src/workspaces/fleet/VehicleEquipment.tsx index 5a891e8..7740f05 100644 --- a/apps/control-station/src/workspaces/fleet/VehicleEquipment.tsx +++ b/apps/control-station/src/workspaces/fleet/VehicleEquipment.tsx @@ -19,7 +19,7 @@ export function VehicleEquipment({vehicle,enabled,layout,content,onAddComputer,o {id:'settings',label:'Настройки аппарата',icon:,disabled:!state.ready,content:content.settings}, {id:'devices',label:'Оборудование',icon:,disabled:!state.ready,content:
Бортовой компьютер и подключённые к нему устройства
void content.refresh()}>
-
+
{computerOpen&&
{content.devices}
}
}, diff --git a/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx b/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx index 0291cb8..3575266 100644 --- a/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx +++ b/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx @@ -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(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=setAdding(true)}>; const headerAdd=!detail&&(headerToolsHost?createPortal(addButton,headerToolsHost):addButton); + const titleSuffix=detail?` — ${detail.name} / ${platformLabel(detail.platform)}`:''; + const observationButton=setObservationOpen(true)}>; + const headerObservation=detail&&!observationOpen&&!monitorOpen&&(headerToolsHost?createPortal(observationButton,headerToolsHost):observationButton); const content=detail&&observationOpen?{setObservationOpen(false);setSelected(null);}} configure={()=>setObservationOpen(false)} headerToolsHost={headerToolsHost}/> :detail&&monitorOpen?setMonitorOpen(false)} headerToolsHost={headerToolsHost}/> :
{detail?<> setSelected(null)} host={headerToolsHost}/> - setObservationOpen(true)}>Центр наблюдения и управления}/> setAdding(true)} onSettings={()=>setSettingsOpen(true)} onMonitor={()=>setMonitorOpen(true)}/> :!fleet.items?:fleet.items.length===0?:{fleet.items.map(item=>
  • } title={item.name} description={platformLabel(item.platform)} status={{fleet.error?'Нет свежих данных':`БК: ${boardStatus(item)}`}} actions={<>setSelected(item.id)}>{setSelected(item.id);setObservationOpen(true);}}>}/>
  • )}
    }
    ; - return <>{headerAdd}{content} + return <>{headerTitleHost&&createPortal(titleSuffix,headerTitleHost)}{headerAdd}{headerObservation}{content} {adding&&setAdding(false)} onComplete={complete} renderWindow={({content,actions,busy})=>{if(!busy)setAdding(false);}} footer={{actions}}>{content}}/>} {settingsOpen&&detail&&setSettingsOpen(false)} onRevoke={()=>{setSettingsOpen(false);setRevoking(detail);}}/>} 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;}}}/> diff --git a/apps/control-station/src/workspaces/fleet/fleet.css b/apps/control-station/src/workspaces/fleet/fleet.css index 2ff94a2..b0b21af 100644 --- a/apps/control-station/src/workspaces/fleet/fleet.css +++ b/apps/control-station/src/workspaces/fleet/fleet.css @@ -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; } diff --git a/apps/control-station/src/workspaces/fleet/observation/BoardObservationCenter.tsx b/apps/control-station/src/workspaces/fleet/observation/BoardObservationCenter.tsx index 7ee2f30..e509b22 100644 --- a/apps/control-station/src/workspaces/fleet/observation/BoardObservationCenter.tsx +++ b/apps/control-station/src/workspaces/fleet/observation/BoardObservationCenter.tsx @@ -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(,headerToolsHost)}
    {createPortal(
    -

    Центр наблюдения и управления

    {name}
    {(!headerToolsHost||expanded)&&}setExpanded(value=>!value)}>
    + const navigationActions=<>; + return <>{headerToolsHost&&!expanded&&createPortal(navigationActions,headerToolsHost)}
    {createPortal(
    +

    Центр наблюдения и управления

    {name}
    {(!headerToolsHost||expanded)&&navigationActions}setExpanded(value=>!value)}>
    {!inventory?'Получение источников':!enabled||inventory.fresh===false?'Нет свежих данных с БК':`Устройства: ${groups.length} · Окна: ${visible.length}`}{effectiveFull&&}
    {if(overId)setLayout(value=>moveObservation(value,ids,activeId,overId));}}>
    {!inventory?:visible.length?setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:

    Все окна скрыты.

    }
    diff --git a/docs/adr/0050_VEHICLE_EQUIPMENT_HIERARCHY.md b/docs/adr/0050_VEHICLE_EQUIPMENT_HIERARCHY.md index 687c6e6..268e326 100644 --- a/docs/adr/0050_VEHICLE_EQUIPMENT_HIERARCHY.md +++ b/docs/adr/0050_VEHICLE_EQUIPMENT_HIERARCHY.md @@ -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. diff --git a/packages/sensor-ui/src/SensorWorkspace.tsx b/packages/sensor-ui/src/SensorWorkspace.tsx index cc5ce15..b5aeaba 100644 --- a/packages/sensor-ui/src/SensorWorkspace.tsx +++ b/packages/sensor-ui/src/SensorWorkspace.tsx @@ -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;addDevice:()=>void;canAddDevice:boolean} +export interface SensorBoardContent {settings:ReactNode;devices:ReactNode;refresh:()=>Promise;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(null);const [selected,setSelected]=useState(null);const [editing,setEditing]=useState(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState>(()=>new Map());const activeActions=useRef(new Map());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
  • } title={item.name} description={item.model} metadata={{item.connection_label||`USB ${item.usb}`}} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy&&pending==='prepare'?preparationProgress(prep,label):undefined} status={{configured?null:label}} actions={<>void action(item,name,parameters)}/>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&void action(item,'prepare')}>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&{setEditing(item);setName(item.name);}}>}{sensorContribution(contributions,item)?.detailLabel?:setSelected(item.id)}>}}/>
  • ;})} - {!inventory&&enabled&&!error?:!enabled?:inventory&&connected.length===0?:null} + {!inventory&&enabled&&!error?:!enabled?(!renderBoard?:null):inventory&&connected.length===0?:null} ; const boardSettings=contributions.filter(value=>value.BoardSettings&&contributions.filter(other=>other.kind===value.kind).length===1); const settingsView=
    {boardSettings.map(contribution=>{const View=contribution.BoardSettings!;return ;})}{!boardSettings.length&&

    Для подключённых устройств общие настройки пока недоступны.

    }
    ; - return
    {device?Detail?setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:
    :(renderBoard?renderBoard({settings:settingsView,devices:inventoryView,refresh,addDevice:()=>setAdding(true),canAddDevice:!!transport.enrollment&&wirelessContributions(contributions).length>0}):board?:inventoryView)} + return
    {device?Detail?setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:
    :(renderBoard?renderBoard({settings:settingsView,devices:inventoryView,showingSavedDevices:connected.length>0&&(!enabled||!fresh),refresh,addDevice:()=>setAdding(true),canAddDevice:!!transport.enrollment&&wirelessContributions(contributions).length>0}):board?:inventoryView)} setEditing(null)} footer={}>
    setName(e.target.value)} disabled={localBusy.has(editing?.id??'')}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить}/>}
    {adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&setAdding(false)} onChange={()=>{void refresh();}} onComplete={completeEnrollment}/>} setError('')}/>