fix(node): show camera connectivity and remove company header marks
This commit is contained in:
@@ -10,7 +10,6 @@ import {
|
|||||||
ControlRow,
|
ControlRow,
|
||||||
HeaderNavigation,
|
HeaderNavigation,
|
||||||
HeaderProfile,
|
HeaderProfile,
|
||||||
HeaderWorkspace,
|
|
||||||
Icon,
|
Icon,
|
||||||
Inspector,
|
Inspector,
|
||||||
RangeControl,
|
RangeControl,
|
||||||
@@ -648,8 +647,6 @@ export default function App() {
|
|||||||
brandHref="/"
|
brandHref="/"
|
||||||
brandLabel="NODEDC MISSION CORE"
|
brandLabel="NODEDC MISSION CORE"
|
||||||
center={
|
center={
|
||||||
<>
|
|
||||||
<HeaderWorkspace kind="mark" label="Mission Core" imageUrl="/nodedc-mark.svg" />
|
|
||||||
<HeaderNavigation
|
<HeaderNavigation
|
||||||
label="Архитектурные блоки пункта управления"
|
label="Архитектурные блоки пункта управления"
|
||||||
value={activeRoot ?? undefined}
|
value={activeRoot ?? undefined}
|
||||||
@@ -659,7 +656,6 @@ export default function App() {
|
|||||||
}))}
|
}))}
|
||||||
onChange={selectRoot}
|
onChange={selectRoot}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
<HeaderProfile>
|
<HeaderProfile>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {readFileSync} from 'node:fs';
|
||||||
|
import ts from 'typescript';
|
||||||
|
|
||||||
|
const source=readFileSync(new URL('../../../packages/sensor-ui/src/sensorStatus.ts',import.meta.url),'utf8');
|
||||||
|
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ESNext}}).outputText;
|
||||||
|
const {sensorStatus}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
||||||
|
const connected={online:true,configured:true,prepared:true,verified:false,snapshot:{acquisition:'idle',enrollment:'enrolled'}};
|
||||||
|
|
||||||
|
test('a configured, reconnected camera is connected before another frame verification',()=>{
|
||||||
|
assert.deepEqual(sensorStatus(connected,true),{label:'Подключено',tone:'success'});
|
||||||
|
});
|
||||||
|
test('stale board data cannot assert camera connectivity',()=>{
|
||||||
|
assert.equal(sensorStatus(connected,false).tone,'neutral');
|
||||||
|
assert.equal(sensorStatus({...connected,online:false},true).label,'Не подключено');
|
||||||
|
});
|
||||||
|
test('a capture failure or unavailable driver is not shown as healthy',()=>{
|
||||||
|
assert.equal(sensorStatus({...connected,snapshot:{acquisition:'failed'}},true).tone,'danger');
|
||||||
|
assert.equal(sensorStatus({...connected,prepared:false},true).tone,'warning');
|
||||||
|
});
|
||||||
|
test('an unconfigured camera still requires preparation',()=>{
|
||||||
|
assert.deepEqual(sensorStatus({...connected,configured:false},true),{label:'Требуется подготовка',tone:'neutral'});
|
||||||
|
});
|
||||||
@@ -13,7 +13,7 @@ import tarfile
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
VERSION = "0.6.9"
|
VERSION = "0.6.10"
|
||||||
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { ActivityIndicator, AdminNavigationPanel, AppHeader, ApplicationPanel, ApplicationShell, Button, HeaderNavigation, HeaderProfile, HeaderWorkspace, Icon, SettingsCard, ToastStack, UserProfileMenu, useApplicationWorkspace } from "@nodedc/ui-react";
|
import { ActivityIndicator, AdminNavigationPanel, AppHeader, ApplicationPanel, ApplicationShell, Button, HeaderNavigation, HeaderProfile, Icon, SettingsCard, ToastStack, UserProfileMenu, useApplicationWorkspace } from "@nodedc/ui-react";
|
||||||
import "@nodedc/tokens/tokens.css";
|
import "@nodedc/tokens/tokens.css";
|
||||||
import "@nodedc/tokens/themes.css";
|
import "@nodedc/tokens/themes.css";
|
||||||
import "@nodedc/ui-core/styles.css";
|
import "@nodedc/ui-core/styles.css";
|
||||||
@@ -42,7 +42,7 @@ function App() {
|
|||||||
: workspace.activeView === "ssh" ? <SystemAccess revision={value.host.collected_at} failure={failure} success={node.success} adding={adding} closeAdd={() => setAdding(false)} /> : null;
|
: workspace.activeView === "ssh" ? <SystemAccess revision={value.host.collected_at} failure={failure} success={node.success} adding={adding} closeAdd={() => setAdding(false)} /> : null;
|
||||||
return <>
|
return <>
|
||||||
<ApplicationShell data-nodedc-ui className="node-app" header={<AppHeader brandMonochrome brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />} brandLabel="Mission Core Node"
|
<ApplicationShell data-nodedc-ui className="node-app" header={<AppHeader brandMonochrome brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />} brandLabel="Mission Core Node"
|
||||||
center={<><HeaderWorkspace monochrome kind="mark" label={value?.name ?? "Mission Core Node"} imageUrl="/nodedc-mark.svg" /><HeaderNavigation label="Разделы бортового компьютера" value={root} items={roots.map(item => ({ value: item.id, label: item.label, disabled: !value || !item.first || environment.running }))} onChange={selectRoot} /></>}
|
center={<HeaderNavigation label="Разделы бортового компьютера" value={root} items={roots.map(item => ({ value: item.id, label: item.label, disabled: !value || !item.first || environment.running }))} onChange={selectRoot} />}
|
||||||
right={<HeaderProfile><UserProfileMenu displayName="Node" subtitle={value?.name ?? "Mission Core Node"} triggerLabel={null} actions={[{ id: "refresh", label: "Обновить сведения", icon: "refresh", onSelect: refreshAll }, { id: "theme", label: theme === "dark" ? "Светлая тема" : "Тёмная тема", icon: "eye", onSelect: () => { const next = theme === "dark" ? "light" : "dark"; setTheme(next); localStorage.setItem("node-theme", next); } }]} /></HeaderProfile>} />}
|
right={<HeaderProfile><UserProfileMenu displayName="Node" subtitle={value?.name ?? "Mission Core Node"} triggerLabel={null} actions={[{ id: "refresh", label: "Обновить сведения", icon: "refresh", onSelect: refreshAll }, { id: "theme", label: theme === "dark" ? "Светлая тема" : "Тёмная тема", icon: "eye", onSelect: () => { const next = theme === "dark" ? "light" : "dark"; setTheme(next); localStorage.setItem("node-theme", next); } }]} /></HeaderProfile>} />}
|
||||||
navigationOpen={!!value && workspace.navigationOpen} contentOpen={!!value && workspace.contentOpen} contentExpanded={workspace.contentExpanded}
|
navigationOpen={!!value && workspace.navigationOpen} contentOpen={!!value && workspace.contentOpen} contentExpanded={workspace.contentExpanded}
|
||||||
navigation={<AdminNavigationPanel eyebrow="MISSION CORE NODE" title={currentRoot.label} onClose={workspace.closeNavigation} closeLabel="Закрыть навигацию" navigationLabel="Разделы выбранной вкладки"
|
navigation={<AdminNavigationPanel eyebrow="MISSION CORE NODE" title={currentRoot.label} onClose={workspace.closeNavigation} closeLabel="Закрыть навигацию" navigationLabel="Разделы выбранной вкладки"
|
||||||
|
|||||||
@@ -204,3 +204,24 @@ SHA-256 2b7d213d2126edbb7e031c17634e2750956bbf7d4205549bbe1d8b155d9c4108.
|
|||||||
`docs/OPERATIONAL_TYPOGRAPHY.md` и registry; локального увеличения заголовков нет.
|
`docs/OPERATIONAL_TYPOGRAPHY.md` и registry; локального увеличения заголовков нет.
|
||||||
Завершённый этап настройки окружения показывает только Icon check, без плашки
|
Завершённый этап настройки окружения показывает только Icon check, без плашки
|
||||||
StatusBadge. Убрано дублирование сообщения об отключении в просмотре камеры.
|
StatusBadge. Убрано дублирование сообщения об отключении в просмотре камеры.
|
||||||
|
|
||||||
|
## Повторное подключение и индикация 0.6.10
|
||||||
|
|
||||||
|
После подключения владельцем камера автоматически вернулась одной строкой с
|
||||||
|
прежним именем, без кнопки первичной подготовки. Через Core UI запущен захват без
|
||||||
|
повторной установки драйвера: визуально подтверждены RGB и данные акселерометра
|
||||||
|
и гироскопа, затем выполнен STOP. Это проверка текущего USB-порта на текущем Mini;
|
||||||
|
между отключением и подключением устанавливалась 0.6.9 с перезапуском службы.
|
||||||
|
Непрерывный цикл unplug/replug без перезапуска и перенос в другой порт ещё не
|
||||||
|
квалифицированы, автоматическое обновление изменившихся IIO-путей не реализовано.
|
||||||
|
|
||||||
|
Серая лампа при доступной камере была ошибкой семантики UI: она зависела от
|
||||||
|
проверки кадров в текущем процессе. Общий `sensorStatus` теперь показывает
|
||||||
|
«Подключено» зелёным при свежих данных, наличии устройства и подготовленного
|
||||||
|
драйвера; ошибка захвата — отдельное состояние. Предыдущая проверка кадров не
|
||||||
|
приравнивается к доказанному текущему захвату. При недоступности БК зелёный статус
|
||||||
|
снимается. Применяется одна реализация для Node и Core.
|
||||||
|
|
||||||
|
По указанию владельца из обеих шапок удалён `HeaderWorkspace` со знаком перед
|
||||||
|
верхней навигацией: профиля компании в этих приложениях нет. Основной бренд
|
||||||
|
приложения и пользовательское меню сохраняют каноническую композицию.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {useCallback,useEffect,useState} from 'react';
|
|||||||
import {ActivityIndicator,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
|
import {ActivityIndicator,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||||
import {perform,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
|
import {perform,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
|
||||||
import {SensorDetail} from './SensorDetail';
|
import {SensorDetail} from './SensorDetail';
|
||||||
|
import {sensorStatus} from './sensorStatus';
|
||||||
import './sensors.css';
|
import './sensors.css';
|
||||||
export function SensorWorkspace({transport,enabled=true,onDetailChange}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void}){
|
export function SensorWorkspace({transport,enabled=true,onDetailChange}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void}){
|
||||||
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<string|null>(null);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<string|null>(null);const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
|
||||||
@@ -27,8 +28,8 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange}:{transpo
|
|||||||
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
|
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
|
||||||
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
||||||
const prep=operation?.action_id==='prepare'&&inventory.preparation&&(inventory.preparation.started_at*1000>=Date.parse(operation.requested_at)-1000)?inventory.preparation:undefined;
|
const prep=operation?.action_id==='prepare'&&inventory.preparation&&(inventory.preparation.started_at*1000>=Date.parse(operation.requested_at)-1000)?inventory.preparation:undefined;
|
||||||
const label=busy?'Подготовка или команда выполняется':!item.online?'Не подключено':item.snapshot.acquisition==='streaming'?item.recording?'Идёт запись':item.playback_id?'Просмотр записи':'Идёт захват':item.verified?'Проверено':configured?'Настроено, ожидает проверки камеры':'Требуется подготовка';
|
const status=sensorStatus(item,enabled&&fresh);const label=busy?'Подготовка или команда выполняется':status.label;
|
||||||
return <li key={item.id}><ResourceRow icon={<Icon name="camera"/>} title={item.name} description={item.model} metadata={<span>USB {item.usb}</span>} aria-busy={busy} progress={busy?{label, value:prep?.state==='running'?prep.steps.filter(s=>s.state==='complete').length/(prep.steps.length+1):prep?.state==='complete'?5/6:undefined,valueText:prep?.steps.find(s=>s.state==='running')?.label??'Проверка кадров камеры'}:undefined} status={<StatusBadge variant={configured&&item.online?'indicator':'badge'} tone={item.verified&&item.online?'success':'neutral'} aria-label={label} title={label}>{configured&&item.online?null:label}</StatusBadge>} actions={<>{!configured&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton><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="camera"/>} title={item.name} description={item.model} metadata={<span>USB {item.usb}</span>} aria-busy={busy} progress={busy?{label, value:prep?.state==='running'?prep.steps.filter(s=>s.state==='complete').length/(prep.steps.length+1):prep?.state==='complete'?5/6:undefined,valueText:prep?.steps.find(s=>s.state==='running')?.label??'Проверка кадров камеры'}:undefined} status={<StatusBadge variant={configured&&item.online?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured&&item.online?null:label}</StatusBadge>} actions={<>{!configured&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton><IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
|
||||||
{(inventory?.operations?.some(v=>v.state==='running'&&v.action_id==='prepare'&&!!inventory.preparation&&inventory.preparation.started_at*1000>=Date.parse(v.requested_at)-1000))&&inventory?.preparation&&<SettingsCard title="Подготовка устройства">{inventory.preparation.steps.map(step=><ResourceRow key={step.id} title={step.label} description={step.message} status={step.state==='complete'?<Icon name="check" label="Выполнено"/>:step.state==='running'?<ActivityIndicator label="Выполняется"/>:<StatusBadge>{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}</StatusBadge>}/>) }<ResourceRow title="Проверка кадров камеры" status={inventory.preparation.state==='complete'?<ActivityIndicator label="Проверяем потоки"/>:<StatusBadge>Ожидает</StatusBadge>}/></SettingsCard>}
|
{(inventory?.operations?.some(v=>v.state==='running'&&v.action_id==='prepare'&&!!inventory.preparation&&inventory.preparation.started_at*1000>=Date.parse(v.requested_at)-1000))&&inventory?.preparation&&<SettingsCard title="Подготовка устройства">{inventory.preparation.steps.map(step=><ResourceRow key={step.id} title={step.label} description={step.message} status={step.state==='complete'?<Icon name="check" label="Выполнено"/>:step.state==='running'?<ActivityIndicator label="Выполняется"/>:<StatusBadge>{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}</StatusBadge>}/>) }<ResourceRow title="Проверка кадров камеры" status={inventory.preparation.state==='complete'?<ActivityIndicator label="Проверяем потоки"/>:<StatusBadge>Ожидает</StatusBadge>}/></SettingsCard>}
|
||||||
</>}
|
</>}
|
||||||
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={!!localBusy} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={!!localBusy||!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}/>{editing&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={!!localBusy||!enabled||!fresh||!editingCurrent?.online||['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} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={!!localBusy||!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}/>{editing&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={!!localBusy||!enabled||!fresh||!editingCurrent?.online||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type {Sensor} from './contracts';
|
||||||
|
|
||||||
|
// The row lamp describes current connectivity, not a past frame-verification run.
|
||||||
|
export function sensorStatus(sensor:Sensor, fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} {
|
||||||
|
if(!fresh)return {label:'Нет свежих сведений с БК',tone:'neutral'};
|
||||||
|
if(!sensor.online)return {label:'Не подключено',tone:'neutral'};
|
||||||
|
if(sensor.snapshot.acquisition==='failed')return {label:'Ошибка захвата',tone:'danger'};
|
||||||
|
const configured=sensor.configured??sensor.snapshot.enrollment==='enrolled';
|
||||||
|
if(!configured)return {label:'Требуется подготовка',tone:'neutral'};
|
||||||
|
if(!sensor.prepared)return {label:'Драйвер недоступен',tone:'warning'};
|
||||||
|
if(sensor.snapshot.acquisition==='streaming')return {label:sensor.recording?'Идёт запись':sensor.playback_id?'Просмотр записи':'Идёт захват',tone:'success'};
|
||||||
|
return {label:'Подключено',tone:'success'};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user