Add D455 sensor host and shared Node/Core preparation surface
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ActivityIndicator,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './contracts';
|
||||
import {PointViewport} from './PointViewport';
|
||||
interface Telemetry {points?:number[];motion?:Record<string,{x:number;y:number;z:number}>;acquisition?:string;frame?:{observed_at:string}}
|
||||
export function LiveViewport({device,layer,transport,failure}:{device:Sensor;layer:string;transport:SensorTransport;failure:(e:unknown)=>void}){
|
||||
const video=useRef<HTMLVideoElement>(null);const frame=useRef<HTMLDivElement>(null);const [expanded,setExpanded]=useState(false);
|
||||
const [state,setState]=useState('Подключаем просмотр');const [telemetry,setTelemetry]=useState<Telemetry>({});
|
||||
useEffect(()=>{
|
||||
if(device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
|
||||
let cancelled=false;let peerID='';let timer:ReturnType<typeof setInterval>|undefined;let last=Date.now();
|
||||
const pc=new RTCPeerConnection({iceServers:[]});const channel=pc.createDataChannel('sensor',{ordered:false,maxRetransmits:0});
|
||||
if(layer!=='points'&&layer!=='motion')pc.addTransceiver('video',{direction:'recvonly'});
|
||||
pc.ontrack=event=>{if(video.current)video.current.srcObject=new MediaStream([event.track]);};
|
||||
pc.onconnectionstatechange=()=>{if(!cancelled)setState(pc.connectionState==='connected'?'Прямой эфир':pc.connectionState==='failed'?'Нет связи с камерой':'Подключаем просмотр');};
|
||||
channel.onmessage=event=>{try{const v=JSON.parse(event.data);last=Date.now();setTelemetry(v);}catch{ /* Ignore malformed preview, not control state. */ }};
|
||||
timer=setInterval(()=>{if(channel.readyState==='open')channel.send('keepalive');if(Date.now()-last>8000)setState('Нет свежих кадров');},2000);
|
||||
async function connect(){try{
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
if(pc.iceGatheringState!=='complete')await new Promise<void>((resolve,reject)=>{const t=setTimeout(()=>reject(new Error('Не удалось подготовить частный канал просмотра.')),10000);pc.addEventListener('icegatheringstatechange',()=>{if(pc.iceGatheringState==='complete'){clearTimeout(t);resolve();}});});
|
||||
if(cancelled)return;
|
||||
const answer=await perform<{peer_id:string;sdp:string;type:'answer'}>(transport,device,'offer',{sdp:pc.localDescription!.sdp,layer});peerID=answer.peer_id;
|
||||
if(cancelled){void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);return;}
|
||||
await pc.setRemoteDescription({type:'answer',sdp:answer.sdp});
|
||||
}catch(e){if(!cancelled){setState('Просмотр недоступен');failure(e);}}}
|
||||
void connect();return()=>{cancelled=true;if(timer)clearInterval(timer);pc.close();if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);};
|
||||
},[device.id,device.snapshot.context.session_id,device.snapshot.acquisition,layer,transport]);
|
||||
useEffect(()=>{const escape=(e:KeyboardEvent)=>{if(e.key==='Escape')setExpanded(false);};document.addEventListener('keydown',escape);return()=>document.removeEventListener('keydown',escape);},[]);
|
||||
const active=device.snapshot.acquisition==='streaming';
|
||||
return <div ref={frame} className={expanded?'sensor-viewer sensor-viewer-expanded':'sensor-viewer'}><SettingsCard title={layer==='points'?'Облако точек':layer==='motion'?'Движение':({color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2'}[layer]??layer)} actions={<><StatusBadge tone={active&&state==='Прямой эфир'?'success':'neutral'}>{state}</StatusBadge><IconButton label={expanded?'Восстановить размер просмотра':'Развернуть просмотр'} onClick={()=>setExpanded(!expanded)}><Icon name={expanded?'minimize':'expand'}/></IconButton></>}>
|
||||
{!active?<p>Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.</p>:<>
|
||||
{state==='Подключаем просмотр'&&<ActivityIndicator label="Открываем частный канал камеры"/>}
|
||||
{layer==='points'?<PointViewport points={telemetry.points??[]}/>:layer==='motion'?<dl className="sensor-facts">{Object.entries(telemetry.motion??{}).map(([kind,v])=><div key={kind}><dt>{kind==='accel'?'Ускорение · м/с²':'Угловая скорость · рад/с'}</dt><dd>X {v.x.toFixed(3)} · Y {v.y.toFixed(3)} · Z {v.z.toFixed(3)}</dd></div>)}</dl>:<video ref={video} className="sensor-media" autoPlay muted playsInline/>}
|
||||
<p className="sensor-note">{layer==='points'?'Предпросмотр до 2 000 точек · координаты камеры · метры':layer==='depth'?'Цветное представление глубины. Исходная глубина записывается без видеосжатия.':'Предпросмотр камеры. Исходная запись хранится на БК.'}</p>
|
||||
</>}
|
||||
</SettingsCard></div>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {useEffect,useRef} from 'react';
|
||||
|
||||
/** Camera optical coordinates, metres. A bounded live preview, never the raw cloud. */
|
||||
export function PointViewport({points}:{points:number[]}) {
|
||||
const ref=useRef<HTMLCanvasElement>(null);const orbit=useRef({yaw:0,pitch:0,scale:1});const data=useRef(points);data.current=points;
|
||||
useEffect(()=>{
|
||||
const canvas=ref.current!;const ctx=canvas.getContext('2d')!;let dragging=false;let x=0,y=0;
|
||||
const draw=()=>{const box=canvas.getBoundingClientRect();canvas.width=box.width*devicePixelRatio;canvas.height=box.height*devicePixelRatio;ctx.clearRect(0,0,canvas.width,canvas.height);
|
||||
const color=getComputedStyle(canvas).getPropertyValue('--nodedc-text-primary');ctx.fillStyle=color.trim()||'currentColor';
|
||||
const a=orbit.current.yaw,b=orbit.current.pitch;const s=Math.min(canvas.width,canvas.height)*0.7*orbit.current.scale;
|
||||
for(let i=0;i<data.current.length;i+=3){const [px,py,pz]=data.current.slice(i,i+3);const xx=px*Math.cos(a)+(pz-2)*Math.sin(a);const zz=-px*Math.sin(a)+(pz-2)*Math.cos(a);const yy=py*Math.cos(b)-zz*Math.sin(b);const z=py*Math.sin(b)+zz*Math.cos(b)+3;if(z<=0.05)continue;
|
||||
ctx.globalAlpha=Math.min(1,Math.max(.2,1-pz/15));ctx.fillRect(canvas.width/2+xx/z*s,canvas.height/2+yy/z*s,2*devicePixelRatio,2*devicePixelRatio);
|
||||
}ctx.globalAlpha=1;
|
||||
};
|
||||
const resize=new ResizeObserver(draw);resize.observe(canvas);const timer=setInterval(draw,250);
|
||||
const down=(e:PointerEvent)=>{dragging=true;x=e.clientX;y=e.clientY;canvas.setPointerCapture(e.pointerId);};
|
||||
const move=(e:PointerEvent)=>{if(!dragging)return;orbit.current.yaw+=(e.clientX-x)*.01;orbit.current.pitch+=(e.clientY-y)*.01;x=e.clientX;y=e.clientY;draw();};
|
||||
const up=()=>{dragging=false;};const wheel=(e:WheelEvent)=>{e.preventDefault();orbit.current.scale=Math.max(.2,Math.min(5,orbit.current.scale*Math.exp(-e.deltaY*.001)));draw();};
|
||||
canvas.addEventListener('pointerdown',down);canvas.addEventListener('pointermove',move);canvas.addEventListener('pointerup',up);canvas.addEventListener('wheel',wheel,{passive:false});
|
||||
draw();return()=>{resize.disconnect();clearInterval(timer);canvas.removeEventListener('pointerdown',down);canvas.removeEventListener('pointermove',move);canvas.removeEventListener('pointerup',up);canvas.removeEventListener('wheel',wheel);};
|
||||
},[]);
|
||||
return <canvas ref={ref} className="sensor-media" aria-label="Облако точек камеры в метрах. Перетаскивание вращает, прокрутка меняет масштаб."/>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {ActivityIndicator,Button,Select,SettingsCard,TextField} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './contracts';
|
||||
import {LiveViewport} from './LiveViewport';
|
||||
export function SensorDetail({device,transport,back,refresh,failure}:{device:Sensor;transport:SensorTransport;back:()=>void;refresh:()=>Promise<void>;failure:(e:unknown)=>void}){
|
||||
const [detail,setDetail]=useState<Sensor|null>(null);const [selected,setSelected]=useState<Record<string,string>>({});const [layer,setLayer]=useState('color');const [pending,setPending]=useState(false);const [option,setOption]=useState('');const [optionValue,setOptionValue]=useState('');
|
||||
async function load(){const value=await perform<Sensor>(transport,device,'details');setDetail(value);return value;}
|
||||
useEffect(()=>{let live=true;if(device.prepared)void load().then(value=>{if(live){const selection:Record<string,string>={};for(const p of value.profiles??[])if(value.defaults?.includes(p.id))selection[p.stream+':'+p.index]=p.id;setSelected(selection);}}).catch(failure);return()=>{live=false;};},[device.id,device.prepared,device.snapshot.context.session_id]);
|
||||
async function act(action:string,parameters:Record<string,unknown>={}){if(pending)return;setPending(true);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPending(false);}}
|
||||
const groups=[...new Set((detail?.profiles??[]).map(p=>p.stream+':'+p.index))];
|
||||
const videoNames:Record<string,string>={color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2',points:'Точки',motion:'Движение'};
|
||||
const available=[...new Set((detail?.profiles??[]).map(p=>p.stream==='infrared'?p.stream+p.index:p.stream==='gyro'||p.stream==='accel'?'motion':p.stream))];if(available.includes('depth'))available.push('points');
|
||||
const currentOption=detail?.options?.find(v=>v.id===option);const active=device.snapshot.acquisition==='streaming'||device.snapshot.acquisition==='starting';
|
||||
return <div className="sensor-content"><div><Button onClick={back}>К устройствам</Button></div>
|
||||
<SettingsCard title={device.name} description={`${device.model} · USB ${device.usb}${device.firmware?' · '+device.firmware:''}`}>
|
||||
{device.snapshot.message&&<p>{device.snapshot.message}</p>}
|
||||
<div className="sensor-actions"><Button disabled={pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:false})}>Начать просмотр</Button><Button disabled={pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:true})}>Начать запись</Button><Button disabled={pending||(!active&&device.snapshot.acquisition!=='failed')} onClick={()=>void act('stop')}>Остановить захват</Button>{pending&&<ActivityIndicator label="Выполняем команду камеры"/>}</div>
|
||||
{device.recording&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
|
||||
</SettingsCard>
|
||||
{detail&&<><Select label="Слой камеры" value={layer} options={available.map(value=>({value,label:videoNames[value]??value}))} onChange={setLayer}/><LiveViewport device={device} layer={layer} transport={transport} failure={failure}/>
|
||||
<SettingsCard title="Профили потоков" description="Применяются при следующем запуске захвата."><div className="sensor-fields">{groups.map(group=><Select key={group} label={group} value={selected[group]??''} options={[{value:'',label:'Выключен'},...(detail.profiles??[]).filter(p=>p.stream+':'+p.index===group&&['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)).map(p=>({value:p.id,label:`${p.width?p.width+' × '+p.height+' · ':''}${p.fps} Гц · ${p.format}`}))]} onChange={value=>setSelected(v=>({...v,[group]:value}))} disabled={pending||active}/>)}</div></SettingsCard>
|
||||
<SettingsCard title="Параметры камеры"><div className="sensor-fields"><Select searchable label="Параметр" value={option} options={(detail.options??[]).map(v=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<><TextField type="number" label={`${currentOption.min} … ${currentOption.max}${currentOption.read_only?' · только чтение':''}`} min={currentOption.min} max={currentOption.max} step={currentOption.step||'any'} value={optionValue} onChange={e=>setOptionValue(e.target.value)} disabled={pending||currentOption.read_only}/><Button disabled={pending||currentOption.read_only||optionValue===''} onClick={()=>void act('option',{id:option,value:Number(optionValue)})}>Применить параметр</Button></>}</div></SettingsCard>
|
||||
<SettingsCard title="Исходные записи">{detail.recordings?.length?detail.recordings.map(item=><div className="sensor-record" key={item.id}><span>{new Date(item.started_at).toLocaleString('ru-RU')}</span><span>{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}</span><span>{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}</span></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
|
||||
{!detail&&<p>{device.prepared?'Получаем возможности камеры…':'Подготовьте устройство в списке.'}</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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 {SensorDetail} from './SensorDetail';
|
||||
import './sensors.css';
|
||||
export function SensorWorkspace({transport,enabled=true}:{transport:SensorTransport;enabled?:boolean}){
|
||||
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 instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
|
||||
const refresh=useCallback(async()=>{if(!enabled){setFresh(false);return;}try{setInventory(await transport.inventory());setFresh(true);}catch(e){setFresh(false);failure(e);}},[transport,enabled,failure]);
|
||||
useEffect(()=>{void refresh();const timer=setInterval(()=>void refresh(),3000);return()=>clearInterval(timer);},[refresh]);
|
||||
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){if(localBusy)return;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);
|
||||
return <div className="sensor-workspace">{device?<SensorDetail device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure}/>:<>
|
||||
<div className="sensor-actions"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>void refresh()}><Icon name="refresh"/></IconButton></div>
|
||||
{!inventory?<ActivityIndicator label="Получаем устройства БК"/>:inventory.items.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите камеру к бортовому компьютеру."/>:<ResourceList aria-label="Устройства БК">{inventory.items.map(item=>{
|
||||
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
|
||||
const label=busy?'Подготовка или команда выполняется':!item.online?'Не подключено':item.snapshot.acquisition==='streaming'?item.recording?'Идёт запись':'Идёт захват':item.verified?'Проверено':item.prepared?'Драйвер установлен':'Нужно подготовить';
|
||||
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} status={<StatusBadge tone={item.verified&&item.online?'success':'neutral'}>{busy?<ActivityIndicator label={label}/>:item.verified&&item.online?<Icon name="check" label={label}/>:label}</StatusBadge>} actions={<><IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.snapshot.acquisition==='streaming'} 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>}
|
||||
{(localBusy||inventory?.operations?.some(v=>v.state==='running'))&&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>}/>)}</SettingsCard>}
|
||||
</>}
|
||||
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={!!localBusy} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={!!localBusy||!name.trim()} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={!!localBusy}/></Window>
|
||||
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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 {
|
||||
id: string; name: string; model: string; prepared: boolean; verified: boolean; online: boolean; usb: string; firmware?: string;
|
||||
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
|
||||
profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[];
|
||||
frames?: Record<string,number>; last_frame?: {observed_at:string}; recording?: {id: string};
|
||||
recordings?: {id:string;state:string;started_at:string;bytes?:number;sha256?:string}[];
|
||||
}
|
||||
export interface SensorInventory {
|
||||
items: Sensor[]; operations: {operation_id:string;device_id:string;action_id:string;state:string;error?:string}[];
|
||||
preparation?: {state:string;steps:{id:string;label:string;state:string;message?:string}[]};
|
||||
}
|
||||
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;
|
||||
idempotency_key:string; parameters:Record<string,unknown>;
|
||||
}
|
||||
export interface SensorOperation {state:string;error?:string;result?:unknown}
|
||||
export interface SensorTransport {
|
||||
inventory: () => Promise<SensorInventory>;
|
||||
submit: (value:SensorCommand) => Promise<SensorOperation>;
|
||||
operation: (id:string) => Promise<SensorOperation>;
|
||||
}
|
||||
export function command(device:Sensor,action:string,parameters:Record<string,unknown>={}):SensorCommand {
|
||||
const id='op_'+crypto.randomUUID().replaceAll('-','');const now=Date.now();
|
||||
return {api_version:'missioncore.nodedc/plugin-sdk/v0alpha2',kind:'OperationRequest',operation_id:id,idempotency_key:id,
|
||||
session:{session_id:device.snapshot.context.session_id,device_id:device.id},action_id:action,
|
||||
requested_at:new Date(now).toISOString(),deadline_at:new Date(now+(action==='prepare'?350000:60000)).toISOString(),parameters};
|
||||
}
|
||||
export async function perform<T>(transport:SensorTransport,device:Sensor,action:string,parameters:Record<string,unknown>={}):Promise<T>{
|
||||
const request=command(device,action,parameters);let value=await transport.submit(request);
|
||||
const deadline=Date.parse(request.deadline_at)+10000;
|
||||
while (value.state==='running'||value.state==='queued') {
|
||||
if(Date.now()>deadline) throw new Error('Подтверждение пока не получено. Обновите состояние устройства.');
|
||||
await new Promise(resolve=>setTimeout(resolve,1000));value=await transport.operation(request.operation_id);
|
||||
}
|
||||
if(value.state!=='complete') throw new Error(value.error||'Операция не завершена. Обновите состояние устройства.');
|
||||
return value.result as T;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.sensor-workspace, .sensor-content {display:grid;gap:var(--nodedc-space-4);min-width:0}
|
||||
.sensor-actions {display:flex;align-items:center;gap:var(--nodedc-space-3);flex-wrap:wrap}
|
||||
.sensor-fields {display:grid;gap:var(--nodedc-space-4);grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
|
||||
.sensor-workspace p,.sensor-note {font-size:var(--nodedc-font-size-sm);color:var(--nodedc-text-secondary);line-height:1.5}
|
||||
.sensor-facts {display:grid;gap:var(--nodedc-space-4);font-size:var(--nodedc-font-size-sm)}
|
||||
.sensor-facts dd {margin:0}
|
||||
.sensor-media {display:block;width:100%;height:360px;object-fit:contain;touch-action:none}
|
||||
.sensor-viewer-expanded {position:fixed;inset:var(--nodedc-space-4);z-index:200;background:var(--nodedc-surface-1);overflow:auto}
|
||||
.sensor-viewer-expanded .sensor-media {height:calc(100vh - 190px)}
|
||||
.sensor-record {display:flex;flex-wrap:wrap;gap:var(--nodedc-space-4);padding-block:var(--nodedc-space-3);font-size:var(--nodedc-font-size-sm)}
|
||||
Reference in New Issue
Block a user