@@ -1,10 +1,13 @@
import { useCallback , useEffect , useState } from 'react' ;
import { useCallback , useEffect , useState , type ComponentType } from '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 { SensorDetail } from './SensorDetail' ;
import { sensorStatus } from './sensorStatus' ;
import { sensorContribution , type SensorUiContribution , type SensorEnrollmentProps } from './extensions' ;
import type { RerunHostFactory } from './rerunHost' ;
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 , createRerunHost , contributions = [ ] , EnrollmentView } : { transport :SensorTransport ; enabled? :boolean ; onDetailChange ? : ( open :boolean ) = > void ; createRerunHost? :RerunHostFactory ; contributions? :readonly SensorUiContribution [ ] ; EnrollmentView? :ComponentType < SensorEnrollmentProps > } ) {
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 < string | null > ( null ) ; const [ error , setError ] = useState ( '' ) ; const [ fresh , setFresh ] = useState ( false ) ;
const failure = useCallback ( ( e :unknown ) = > { setError ( e === null ? '' : e instanceof Error ? e . message : 'Не удалось выполнить действие устройства.' ) ; } , [ ] ) ;
const refresh = useCallback ( async ( ) = > { if ( ! enabled ) { setFresh ( false ) ; return ; } try { const value = await transport . inventory ( ) ; setInventory ( value ) ; setFresh ( value . fresh !== false ) ; } catch ( e ) { setFresh ( false ) ; failure ( e ) ; } } , [ transport , enabled , failure ] ) ;
@@ -19,20 +22,22 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange}:{transpo
} , [ transport , enabled , refresh ] ) ;
async function action ( device :Sensor , action :string , parameters :Record < string , unknown > = { } ) { if ( localBusy ) return ; setError ( '' ) ; setBusy ( device . id ) ; try { await perform ( transport , device , action , parameters ) ; await refresh ( ) ; setEditing ( null ) ; } catch ( e ) { failure ( e ) ; } finally { setBusy ( null ) ; } }
const device = inventory ? . items . find ( v = > v . id === selected ) ;
const connected = inventory ? . items . filter ( v = > v . online ) ? ? [ ] ;
const connected = inventory ? . items . filter ( v = > v . online || sensorContribution ( contributions , v ) ? . retainOffline ) ? ? [ ] ;
const editingCurrent = inventory ? . items . find ( v = > v . id === editing ? . id ) ;
useEffect ( ( ) = > { onDetailChange ? . ( ! ! device ) ; } , [ ! ! device , onDetailChange ] ) ;
return < div className = "sensor-workspace" > { device ? < SensorDetail enabled = { enabled && fresh } device = { device } transport = { transport } back = { ( ) = > setSelected ( null ) } refresh = { refresh } failure = { failure } / > : < >
< div className = "sensor-actions sensor-inventory-toolbar" > < StatusBadge tone = { fresh && enabled ? 'success' : 'neutral' } > { ! enabled ? 'БК недоступен' : fresh ? 'Сведения с БК' : 'Нет свежих сведений' } < / StatusBadge > < IconButton label = "Обновить устройства" disabled = { ! enabled } onClick = { ( ) = > void refresh ( ) } > < Icon name = "refresh "/ > < / IconButton > < / div >
{ ! inventory ? < ActivityIndicator label = "Получаем устройства БК" / > : connected . length === 0 ? < SettingsCard title = "Поддерживаемые устройства не обнаружены" description = "Подключите камеру к бортовому компьютеру." / > : < ResourceList aria-label = "Устройства БК" > { connected . map ( item = > {
const Detail = device ? ( sensorContribution ( contributions , device ) ? . Detail ? ? ( device.kind?null :SensorDetail ) ) : null ;
return < div className = "sensor-workspace" > { device ? Detail ? < Detail enabled = { enabled && fresh } device = { device } transport = { transport } back = { ( ) = > setSelected ( null ) } refresh = { refresh } failure = { failure } createRerunHost = { createRerunHost } / > : < div className = "sensor-content" > < Button onClick = { ( ) = > setSelected ( null ) } > К у с т р о й с т в а м < / Button > < SettingsCard title = "Просмотр устройства недоступен" description = "Интеграция этого устройства не установлена. "/ > < / div > : < >
< div className = "sensor-actions sensor-inventory-toolbar" > < StatusBadge tone = { fresh && enabled ? 'success' : 'neutral' } > { ! enabled ? 'БК недоступен' : fresh ? 'Сведения с БК' : 'Нет свежих сведений' } < / StatusBadge > < div className = "sensor-actions" > { transport . enrollment && EnrollmentView && < IconButton label = "Подключить устройство к БК" disabled = { ! enabled } onClick = { ( ) = > setAdding ( true ) } > < Icon name = "plus" / > < / IconButton > } < IconButton label = "Обновить устройства" disabled = { ! enabled } onClick = { ( ) = > { void refresh ( ) ; } } > < Icon name = "refresh" / > < / IconButton > < / div > < / div >
{ ! inventory ? < ActivityIndicator label = "Получаем устройства БК" / > : connected . length === 0 ? < SettingsCard title = "Поддерживаемые устройства не обнаружены" description = "Подключите устройство кабелем или добавьте его через плюс." / > : < ResourceList aria-label = "Устройства БК" > { connected . map ( item = > {
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 prep = operation ? . action_id === 'prepare' && inventory . preparation && ( inventory . preparation . started_at * 1000 >= Date . parse ( operation . requested_at ) - 1000 ) ? inventory.preparation :undefined ;
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 > } statusPlacement = { configured ? 'leading' : 'trailing' } 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 > }
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 ? { 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 && sensorContribution ( contributions , item ) ? . supportsPreparation !== false && < 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 > }
< / > }
< 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 && sensorContribution ( contributions , editing ) ? . supportsPreparation !== false && ( 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 >
{ adding && transport . enrollment && EnrollmentView && < EnrollmentView transport = { transport . enrollment } onClose = { ( ) = > setAdding ( false ) } onChange = { ( ) = > { void refresh ( ) ; } } / > }
< ToastStack items = { error ? [ { id : 'sensor-error' , tone : 'error' , title :error , durationMs :null } ] : [ ] } onDismiss = { ( ) = > setError ( '' ) } / >
< / div > ;
}