feat(x4): add per-camera manual wake and hide power action when connected
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import {Button} from '@nodedc/ui-react';
|
||||
import type {Sensor} from './contracts';
|
||||
import type {SensorRowAction} from './extensions';
|
||||
|
||||
export function SensorRowActions({device,actions=[],enabled,busy,pending,perform}:{
|
||||
device:Sensor; actions?:readonly SensorRowAction[]; enabled:boolean; busy:boolean;
|
||||
pending?:string; perform:(action:string,parameters?:Record<string,unknown>)=>void;
|
||||
}) {
|
||||
return <>{actions.filter(value=>actions.filter(other=>other.actionId===value.actionId).length===1).map(value=>
|
||||
<Button key={value.actionId} aria-label={`${value.label}: ${device.name}`} title={value.description}
|
||||
disabled={!enabled||busy||value.disabled} loading={pending===value.actionId}
|
||||
onClick={()=>perform(value.actionId,value.parameters)}>{value.label}</Button>)}</>;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {useCallback,useEffect,useRef,useState} from 'react';
|
||||
import {LoadingRegion,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||
import {perform,enrolledDevice,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
|
||||
import {SensorDetail} from './SensorDetail';
|
||||
import {SensorRowActions} from './SensorRowActions';
|
||||
import {SensorPreparations} from './SensorPreparations';
|
||||
import {devicePreparation,preparationProgress} from './preparation';
|
||||
import {sensorStatus} from './sensorStatus';
|
||||
@@ -11,7 +12,7 @@ import './sensors.css';
|
||||
import {WirelessEnrollmentWindow} from './WirelessEnrollmentWindow';
|
||||
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[]}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[]}){
|
||||
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<ReadonlySet<string>>(()=>new Set());const activeActions=useRef(new Set<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);
|
||||
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]);
|
||||
useEffect(()=>{
|
||||
@@ -25,10 +26,10 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
||||
},[transport,enabled,refresh]);
|
||||
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){
|
||||
if(activeActions.current.has(device.id))return;
|
||||
activeActions.current.add(device.id);setBusy(new Set(activeActions.current));setError('');
|
||||
activeActions.current.set(device.id,action);setBusy(new Map(activeActions.current));setError('');
|
||||
try{await perform(transport,device,action,parameters);await refresh();setEditing(current=>current?.id===device.id?null:current);}
|
||||
catch(e){failure(e);}
|
||||
finally{activeActions.current.delete(device.id);setBusy(new Set(activeActions.current));}
|
||||
finally{activeActions.current.delete(device.id);setBusy(new Map(activeActions.current));}
|
||||
}
|
||||
async function completeEnrollment(sessionId:string,signal?:AbortSignal){
|
||||
if(!enabled)throw new Error('БК недоступен. Обновите состояние устройства.');
|
||||
@@ -46,14 +47,15 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
||||
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>:<>
|
||||
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&wirelessContributions(contributions).length>0&&<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?<LoadingRegion loading 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.has(item.id);
|
||||
const operation=inventory.operations?.find(v=>v.device_id===item.id&&['queued','running'].includes(v.state));const busy=!!operation||localBusy.has(item.id);
|
||||
const pending=localBusy.get(item.id)??operation?.action_id;
|
||||
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
||||
const prep=devicePreparation(inventory,item);
|
||||
const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?(operation?.action_id==='prepare'?'Подготовка устройства':'Выполняется команда'):status.label;
|
||||
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy?preparationProgress(prep,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.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>}<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>}<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
|
||||
<SensorPreparations inventory={inventory}/>
|
||||
</>}
|
||||
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={localBusy.has(editing?.id??'')} onClick={()=>setEditing(null)}>Отмена</Button><Button 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 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('')}/>
|
||||
</div>;
|
||||
|
||||
@@ -21,10 +21,16 @@ export interface SensorUiContribution {
|
||||
retainOffline:boolean;
|
||||
supportsPreparation:boolean;
|
||||
supportsRenaming?:boolean;
|
||||
rowActions?:(device:Sensor)=>readonly SensorRowAction[];
|
||||
status?:(device:Sensor,fresh:boolean)=>{label:string;tone:'neutral'|'success'|'warning'|'danger'};
|
||||
wirelessEnrollment?:{label:string;View:ComponentType<SensorEnrollmentProps>};
|
||||
}
|
||||
|
||||
export interface SensorRowAction {
|
||||
actionId:string; label:string; description?:string; disabled?:boolean;
|
||||
parameters?:Record<string,unknown>;
|
||||
}
|
||||
|
||||
export function wirelessContributions(contributions:readonly SensorUiContribution[]):SensorUiContribution[] {
|
||||
return contributions.filter(value=>value.wirelessEnrollment&&contributions.filter(other=>other.kind===value.kind).length===1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user