Accept systemd credential delivery and clarify manual K1 control

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 14:44:46 +03:00
parent 102e93796b
commit a93f1f1b8b
19 changed files with 402 additions and 31 deletions
+12 -4
View File
@@ -1,6 +1,6 @@
import {useCallback,useEffect,useState} 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 {perform,enrolledDevice,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
import {SensorDetail} from './SensorDetail';
import {sensorStatus} from './sensorStatus';
import {sensorContribution,type SensorUiContribution,wirelessContributions} from './extensions';
@@ -22,6 +22,14 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
return()=>{active=false;close?.();if(fallback)clearInterval(fallback);};
},[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);}}
async function completeEnrollment(sessionId:string,signal?:AbortSignal){
if(!enabled)throw new Error('БК недоступен. Обновите состояние устройства.');
const value=await transport.inventory();
if(signal?.aborted)return;
const target=enrolledDevice(value,sessionId);
if(!target)throw new Error('Связь с устройством пока не подтверждена. Проверьте его состояние.');
setInventory(value);setFresh(true);setSelected(target.id);setAdding(false);
}
const device=inventory?.items.find(v=>v.id===selected);
const connected=inventory?.items.filter(v=>v.online||sensorContribution(contributions,v)?.retainOffline)??[];
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
@@ -33,12 +41,12 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
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={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>}
const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?'Подготовка или команда выполняется':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?{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>}{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>}
{(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&&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&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}}/>}
{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>;
}
@@ -2,9 +2,10 @@ import {useState} from 'react';
import {Select,Window,WindowFooterActions} from '@nodedc/ui-react';
import {wirelessContributions,type SensorEnrollmentProps,type SensorUiContribution} from './extensions';
export function WirelessEnrollmentWindow({contributions,transport,onClose,onChange}:{
export function WirelessEnrollmentWindow({contributions,transport,onClose,onChange,onComplete}:{
contributions:readonly SensorUiContribution[];
transport:SensorEnrollmentProps['transport'];onClose:()=>void;onChange:()=>void;
onComplete:SensorEnrollmentProps['onComplete'];
}) {
const [selected,setSelected]=useState('');
const supported=wirelessContributions(contributions);
@@ -21,5 +22,5 @@ export function WirelessEnrollmentWindow({contributions,transport,onClose,onChan
</div>
</Window>
);
return Enrollment?<Enrollment key={selected} transport={transport} onClose={onClose} onChange={onChange} renderWindow={renderWindow}/>:renderWindow({content:null});
return Enrollment?<Enrollment key={selected} transport={transport} onClose={onClose} onChange={onChange} onComplete={onComplete} renderWindow={renderWindow}/>:renderWindow({content:null});
}
+7 -1
View File
@@ -1,7 +1,7 @@
export interface SensorProfile { id: string; sensor: number; stream: string; index: number; format: string; fps: number; width?: number; height?: number }
export interface SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean }
export interface Sensor {
kind?:string; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;acquisition_id:string|null};
kind?:string; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;can_verify?:boolean;network_applied?:boolean;reason_code?:string|null;acquisition_id:string|null};
live_settings?: Record<string,unknown>;
id: string; name: string; model: string; prepared: boolean; configured?: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
@@ -13,6 +13,12 @@ export interface SensorInventory {
fresh?: boolean; items: Sensor[]; operations: {operation_id:string;device_id:string;action_id:string;requested_at:string;state:string;error?:string}[];
preparation?: {state:string;started_at:number;steps:{id:string;label:string;state:string;message?:string}[]};
}
export function enrolledDevice(inventory:SensorInventory,sessionId:string):Sensor|null {
if(inventory.fresh===false||!sessionId)return null;
const matches=inventory.items.filter(item=>item.snapshot.context.session_id===sessionId);
const item=matches.length===1?matches[0]:null;
return item?.online&&item.verified&&item.prepared?item:null;
}
export interface SensorCommand {
api_version: 'missioncore.nodedc/plugin-sdk/v0alpha2'; kind:'OperationRequest'; operation_id:string;
session: {session_id:string;device_id:string}; action_id:string; requested_at:string; deadline_at:string;
+3
View File
@@ -11,6 +11,7 @@ export interface SensorDetailProps {
}
export interface SensorEnrollmentProps {
transport:EnrollmentTransport; onClose:()=>void; onChange:()=>void;
onComplete:(sessionId:string,signal?:AbortSignal)=>Promise<void>;
renderWindow:(view:{content:ReactNode;actions?:ReactNode;busy?:boolean})=>ReactNode;
}
export interface SensorUiContribution {
@@ -19,6 +20,8 @@ export interface SensorUiContribution {
icon:IconName;
retainOffline:boolean;
supportsPreparation:boolean;
supportsRenaming?:boolean;
status?:(device:Sensor,fresh:boolean)=>{label:string;tone:'neutral'|'success'|'warning'|'danger'};
wirelessEnrollment?:{label:string;View:ComponentType<SensorEnrollmentProps>};
}