chore: dashboard api
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
export const DASHBOARD_API = '/dashboard';
|
||||
|
||||
export async function queryDashboardData() {
|
||||
return request(DASHBOARD_API);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// import div from '@/components/content-wrapper';
|
||||
import PageTools from '@/components/page-tools';
|
||||
import ProgressBar from '@/components/progress-bar';
|
||||
import { Col, Row, Table } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import { useContext } from 'react';
|
||||
import { DashboardContext } from '../config/dashboard-context';
|
||||
|
||||
const modelColumns = [
|
||||
{
|
||||
@@ -9,28 +12,30 @@ const modelColumns = [
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: 'Allocated GPUs',
|
||||
dataIndex: 'allocated',
|
||||
key: 'allocated',
|
||||
render: (text: any, record: any) => <span>{record.gpu.allocated}</span>
|
||||
title: 'GPU Utilization',
|
||||
dataIndex: 'gpu_utilization',
|
||||
key: 'gpu_utilization',
|
||||
render: (text: any, record: any) => (
|
||||
<ProgressBar percent={_.round(text, 2)}></ProgressBar>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'GPU Utilization',
|
||||
dataIndex: 'utilization',
|
||||
key: 'utilization',
|
||||
render: (text: any, record: any) => <span>{record.gpu.utilization}</span>
|
||||
title: 'VRAM Utilization',
|
||||
dataIndex: 'gpu_memory_utilization',
|
||||
key: 'gpu_memory_utilization',
|
||||
render: (text: any, record: any) => (
|
||||
<ProgressBar percent={_.round(text, 2)}></ProgressBar>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Running Instances',
|
||||
dataIndex: 'running',
|
||||
key: 'running',
|
||||
render: (text: any, record: any) => <span>{record.instances.running}</span>
|
||||
dataIndex: 'instance_count',
|
||||
key: 'instance_count'
|
||||
},
|
||||
{
|
||||
title: 'Pending Instances',
|
||||
dataIndex: 'pending',
|
||||
key: 'pending',
|
||||
render: (text: any, record: any) => <span>{record.instances.pending}</span>
|
||||
title: 'Tokens',
|
||||
dataIndex: 'token_count',
|
||||
key: 'token_count'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -59,39 +64,6 @@ const projectColumns = [
|
||||
}
|
||||
];
|
||||
|
||||
const modelData = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'qwen2',
|
||||
gpu: { allocated: 4, utilization: '50%' },
|
||||
instances: { running: 1, pending: 0 }
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'llama3:70b',
|
||||
gpu: { allocated: 3, utilization: '70%' },
|
||||
instances: { running: 1, pending: 0 }
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'llama3',
|
||||
gpu: { allocated: 5, utilization: '20%' },
|
||||
instances: { running: 1, pending: 0 }
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'gemma',
|
||||
gpu: { allocated: 1, utilization: '25%' },
|
||||
instances: { running: 1, pending: 0 }
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'phi3',
|
||||
gpu: { allocated: 2, utilization: '46%' },
|
||||
instances: { running: 1, pending: 0 }
|
||||
}
|
||||
];
|
||||
|
||||
const projectData = [
|
||||
{
|
||||
id: 1,
|
||||
@@ -130,6 +102,7 @@ const projectData = [
|
||||
}
|
||||
];
|
||||
const ActiveTable = () => {
|
||||
const data = useContext(DashboardContext).active_models || [];
|
||||
return (
|
||||
<Row gutter={[20, 0]}>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
@@ -147,7 +120,7 @@ const ActiveTable = () => {
|
||||
<div>
|
||||
<Table
|
||||
columns={modelColumns}
|
||||
dataSource={modelData}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Card, Col, Row, Space } from 'antd';
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import React, { useContext } from 'react';
|
||||
import { overviewConfigs } from '../config';
|
||||
import { DashboardContext } from '../config/dashboard-context';
|
||||
import '../styles/index.less';
|
||||
import styles from './over-view.less';
|
||||
|
||||
@@ -23,23 +25,8 @@ const renderCardItem = (data: {
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
const Overview: React.FC = (props) => {
|
||||
// const { data = {} } = props;
|
||||
const data = {
|
||||
workers: 8,
|
||||
models: {
|
||||
healthy: 10,
|
||||
warning: 2,
|
||||
error: 1
|
||||
},
|
||||
gpus: 30,
|
||||
allocatedGpus: 12,
|
||||
instances: {
|
||||
healthy: 32,
|
||||
warning: 3,
|
||||
error: 2
|
||||
}
|
||||
};
|
||||
const Overview: React.FC = () => {
|
||||
const data = useContext(DashboardContext).resource_counts || {};
|
||||
|
||||
const renderValue = (
|
||||
value:
|
||||
@@ -75,7 +62,7 @@ const Overview: React.FC = (props) => {
|
||||
>
|
||||
{renderCardItem({
|
||||
label: config.label,
|
||||
value: renderValue(data[config.key] || 0),
|
||||
value: renderValue(_.get(data, config.key, 0)),
|
||||
bgColor: config.backgroundColor
|
||||
})}
|
||||
</Col>
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
import LineChart from '@/components/charts/line-chart';
|
||||
import { generateFluctuatingData } from '@/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import _ from 'lodash';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { DashboardContext } from '../config/dashboard-context';
|
||||
|
||||
const mockData = {
|
||||
GPU: generateFluctuatingData({
|
||||
total: 17,
|
||||
max: 80,
|
||||
min: 20
|
||||
}),
|
||||
CPU: generateFluctuatingData({
|
||||
total: 17,
|
||||
max: 70,
|
||||
min: 10
|
||||
}),
|
||||
Memory: generateFluctuatingData({
|
||||
total: 17,
|
||||
max: 60,
|
||||
min: 20
|
||||
}),
|
||||
VRAM: generateFluctuatingData({
|
||||
total: 17,
|
||||
max: 90,
|
||||
min: 15
|
||||
})
|
||||
const TypeKeyMap = {
|
||||
cpu: 'CPU',
|
||||
memory: 'Memory',
|
||||
gpu: 'GPU',
|
||||
gpu_memory: 'VRAM'
|
||||
};
|
||||
|
||||
const UtilizationOvertime: React.FC = () => {
|
||||
const timeList = [
|
||||
'08:00:00',
|
||||
'09:00:00',
|
||||
'10:00:00',
|
||||
'11:00:00',
|
||||
'12:00:00',
|
||||
'13:00:00',
|
||||
'14:00:00',
|
||||
'15:00:00',
|
||||
'16:00:00',
|
||||
'17:00:00',
|
||||
'18:00:00',
|
||||
'19:00:00',
|
||||
'20:00:00',
|
||||
'21:00:00',
|
||||
'22:00:00',
|
||||
'23:00:00',
|
||||
'24:00:00'
|
||||
];
|
||||
const typeList = ['GPU', 'CPU', 'Memory', 'VRAM'];
|
||||
const generateData = () => {
|
||||
const data = [];
|
||||
for (let i = 0; i < timeList.length; i++) {
|
||||
for (let j = 0; j < typeList.length; j++) {
|
||||
data.push({
|
||||
time: timeList[i],
|
||||
type: typeList[j],
|
||||
value: _.get(mockData, typeList[j])[i]
|
||||
});
|
||||
}
|
||||
const data = useContext(DashboardContext)?.system_load?.history || {};
|
||||
const [result, setResult] = useState<
|
||||
{ time: string; value: number; type: string }[]
|
||||
>([]);
|
||||
|
||||
const typeList = ['gpu', 'cpu', 'memory', 'gpu_memory'];
|
||||
const sliderConfig = {
|
||||
y: false,
|
||||
x: {
|
||||
style: {
|
||||
selectionFill: 'rgb(84, 204, 152)',
|
||||
selectionFillOpacity: 0.1,
|
||||
handleIconFill: 'rgb(84, 204, 152)',
|
||||
handleIconFillOpacity: 0.15,
|
||||
handleIconStrokeOpacity: 0,
|
||||
sparklineType: 'line',
|
||||
sparkline: true
|
||||
},
|
||||
sparkline: true
|
||||
}
|
||||
return data;
|
||||
};
|
||||
const data = generateData();
|
||||
|
||||
const labelFormatter = (value: any) => {
|
||||
return `${value}%`;
|
||||
};
|
||||
const generateData = () => {
|
||||
const list: { value: number; time: string; type: string }[] = [];
|
||||
_.each(typeList, (type: any) => {
|
||||
const dataList = _.map(_.get(data, type, []), (item: any) => {
|
||||
return {
|
||||
value: _.round(item.value, 2) || 0,
|
||||
time: dayjs(item.timestamp * 1000).format('HH:mm:ss'),
|
||||
type: _.get(TypeKeyMap, type, '')
|
||||
};
|
||||
});
|
||||
list.push(...dataList);
|
||||
});
|
||||
setResult(list);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
generateData();
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LineChart data={data} />
|
||||
<LineChart
|
||||
data={result}
|
||||
labelFormatter={labelFormatter}
|
||||
slider={sliderConfig}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,9 @@ import PageTools from '@/components/page-tools';
|
||||
import breakpoints from '@/config/breakpoints';
|
||||
import useWindowResize from '@/hooks/use-window-resize';
|
||||
import { Col, DatePicker, Row } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import _ from 'lodash';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { DashboardContext } from '../config/dashboard-context';
|
||||
import ResourceUtilization from './resource-utilization';
|
||||
|
||||
const SystemLoad = () => {
|
||||
@@ -13,7 +15,7 @@ const SystemLoad = () => {
|
||||
'linear-gradient(90deg, rgba(255, 214, 102,.8) 0%, rgba(255, 214, 102,0.5) 50%, rgba(255, 214, 102,.8) 100%)',
|
||||
'linear-gradient(90deg, rgba(255, 120, 117,.8) 0%, rgba(255, 120, 117,0.5) 50%, rgba(255, 120, 117,.8) 100%)'
|
||||
];
|
||||
|
||||
const data = useContext(DashboardContext)?.system_load?.current || {};
|
||||
const { size } = useWindowResize();
|
||||
const [paddingRight, setPaddingRight] = useState<string>('20px');
|
||||
const [smallChartHeight, setSmallChartHeight] = useState<number>(190);
|
||||
@@ -64,7 +66,7 @@ const SystemLoad = () => {
|
||||
<Col span={12} style={{ height: smallChartHeight }}>
|
||||
<LiquidChart
|
||||
title="GPU Compute Utilization"
|
||||
percent={0.2}
|
||||
percent={_.round(data.gpu?.utilization_rate || 0, 2) / 100}
|
||||
thresholds={thresholds}
|
||||
rangColor={colors}
|
||||
></LiquidChart>
|
||||
@@ -72,7 +74,9 @@ const SystemLoad = () => {
|
||||
<Col span={12} style={{ height: smallChartHeight }}>
|
||||
<LiquidChart
|
||||
title="GPU Memory Utilization"
|
||||
percent={0.3}
|
||||
percent={
|
||||
_.round(data.gpu_memory?.utilization_rate || 0, 2) / 100
|
||||
}
|
||||
thresholds={thresholds}
|
||||
rangColor={colors}
|
||||
></LiquidChart>
|
||||
@@ -80,7 +84,7 @@ const SystemLoad = () => {
|
||||
<Col span={12} style={{ height: smallChartHeight }}>
|
||||
<LiquidChart
|
||||
title="CPU Compute Utilization"
|
||||
percent={0.8}
|
||||
percent={_.round(data.cpu?.utilization_rate || 0, 2) / 100}
|
||||
thresholds={thresholds}
|
||||
rangColor={colors}
|
||||
></LiquidChart>
|
||||
@@ -88,7 +92,9 @@ const SystemLoad = () => {
|
||||
<Col span={12} style={{ height: smallChartHeight }}>
|
||||
<LiquidChart
|
||||
title="CPU Memory Utilization"
|
||||
percent={0.7}
|
||||
percent={
|
||||
_.round(data.memory?.utilization_rate || 0, 2) / 100
|
||||
}
|
||||
thresholds={thresholds}
|
||||
rangColor={colors}
|
||||
></LiquidChart>
|
||||
|
||||
@@ -6,7 +6,10 @@ import breakpoints from '@/config/breakpoints';
|
||||
import useWindowResize from '@/hooks/use-window-resize';
|
||||
import { generateRandomArray } from '@/utils';
|
||||
import { Col, DatePicker, Row } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import _ from 'lodash';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { DashboardContext } from '../config/dashboard-context';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const times = [
|
||||
@@ -92,9 +95,82 @@ const tokenUsage = TokensData.map((val, i) => {
|
||||
const Usage = () => {
|
||||
const { size } = useWindowResize();
|
||||
const [paddingRight, setPaddingRight] = useState<string>('20px');
|
||||
const [requestData, setRequestData] = useState<
|
||||
{ time: string; value: number }[]
|
||||
>([]);
|
||||
const [tokenData, setTokenData] = useState<{ time: string; value: number }[]>(
|
||||
[]
|
||||
);
|
||||
const [userData, setUserData] = useState<{ name: string; value: number }[]>(
|
||||
[]
|
||||
);
|
||||
const data = useContext(DashboardContext)?.model_usage || {};
|
||||
|
||||
const handleSelectDate = (dateString: string) => {};
|
||||
|
||||
const generateData = () => {
|
||||
const requestList: { time: string; value: number }[] = [];
|
||||
const tokenList: {
|
||||
time: string;
|
||||
value: number;
|
||||
name: string;
|
||||
color: string;
|
||||
}[] = [];
|
||||
const userList: {
|
||||
name: string;
|
||||
value: number;
|
||||
type: string;
|
||||
color: string;
|
||||
}[] = [];
|
||||
|
||||
_.each(data.api_request_history, (item: any) => {
|
||||
requestList.push({
|
||||
time: dayjs(item.timestamp * 1000).format('YYYY-MM-DD'),
|
||||
value: item.value
|
||||
});
|
||||
});
|
||||
|
||||
_.each(data.completion_token_history, (item: any) => {
|
||||
tokenList.push({
|
||||
time: dayjs(item.timestamp * 1000).format('YYYY-MM-DD'),
|
||||
name: 'completion_token',
|
||||
color:
|
||||
'linear-gradient(90deg,rgba(84, 204, 152,0.8) 0%,rgb(0, 168, 143,.7) 100%)',
|
||||
value: item.value
|
||||
});
|
||||
});
|
||||
_.each(data.prompt_token_history, (item: any) => {
|
||||
tokenList.push({
|
||||
time: dayjs(item.timestamp * 1000).format('YYYY-MM-DD'),
|
||||
name: 'prompt_token',
|
||||
color:
|
||||
'linear-gradient(90deg,rgba(0, 170, 173, 0.8) 0%,rgba(0, 109, 193, 0.7) 100%)',
|
||||
value: item.value
|
||||
});
|
||||
});
|
||||
|
||||
_.each(data.top_users, (item: any) => {
|
||||
userList.push({
|
||||
name: item.username,
|
||||
type: 'completion_token',
|
||||
color:
|
||||
'linear-gradient(90deg,rgba(84, 204, 152,0.8) 0%,rgb(0, 168, 143,.7) 100%)',
|
||||
value: item.completion_token_count
|
||||
});
|
||||
userList.push({
|
||||
name: item.username,
|
||||
type: 'prompt_token',
|
||||
color:
|
||||
'linear-gradient(90deg,rgba(0, 170, 173, 0.8) 0%,rgba(0, 109, 193, 0.7) 100%)',
|
||||
value: item.prompt_token_count
|
||||
});
|
||||
});
|
||||
|
||||
setRequestData(requestList);
|
||||
setTokenData(tokenList);
|
||||
setUserData(userList);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (size.width < breakpoints.xl) {
|
||||
setPaddingRight('0');
|
||||
@@ -103,6 +179,10 @@ const Usage = () => {
|
||||
}
|
||||
}, [size.width]);
|
||||
|
||||
useEffect(() => {
|
||||
generateData();
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTools
|
||||
@@ -126,7 +206,7 @@ const Usage = () => {
|
||||
<Col span={12}>
|
||||
<ColumnBar
|
||||
title="API Request"
|
||||
data={dataList}
|
||||
data={requestData}
|
||||
xField="time"
|
||||
yField="value"
|
||||
height={360}
|
||||
@@ -135,8 +215,12 @@ const Usage = () => {
|
||||
<Col span={12}>
|
||||
<ColumnBar
|
||||
title="Tokens"
|
||||
data={tokenUsage}
|
||||
data={tokenData}
|
||||
group={false}
|
||||
colorField="name"
|
||||
stack={true}
|
||||
xField="time"
|
||||
legend={false}
|
||||
yField="value"
|
||||
height={360}
|
||||
></ColumnBar>
|
||||
@@ -148,8 +232,11 @@ const Usage = () => {
|
||||
<CardWrapper>
|
||||
<HBar
|
||||
title="Top Users"
|
||||
data={userDataList}
|
||||
xField="time"
|
||||
data={userData}
|
||||
colorField="type"
|
||||
stack={true}
|
||||
legend={false}
|
||||
xField="name"
|
||||
yField="value"
|
||||
height={360}
|
||||
></HBar>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createContext } from 'react';
|
||||
import { DashboardProps } from './types';
|
||||
|
||||
export const DashboardContext = createContext<DashboardProps>(
|
||||
{} as DashboardProps
|
||||
);
|
||||
|
||||
export default DashboardContext;
|
||||
@@ -1,12 +1,12 @@
|
||||
export const overviewConfigs = [
|
||||
{
|
||||
key: 'workers',
|
||||
key: 'workworker_count',
|
||||
label: 'Workers',
|
||||
// backgroundColor: 'linear-gradient(135deg, #ffffff, rgb(232 249 240 / 60%))'
|
||||
backgroundColor: 'var(--color-white-1)'
|
||||
},
|
||||
{
|
||||
key: 'gpus',
|
||||
key: 'gpu_count',
|
||||
label: 'Total GPUs',
|
||||
backgroundColor: 'var(--color-white-1)'
|
||||
// backgroundColor: 'linear-gradient(135deg, #ffffff, rgb(232 249 240 / 60%))'
|
||||
@@ -18,13 +18,13 @@ export const overviewConfigs = [
|
||||
// backgroundColor: 'linear-gradient(135deg, #ffffff, rgb(232 249 240 / 60%))'
|
||||
},
|
||||
{
|
||||
key: 'models',
|
||||
key: 'model_count',
|
||||
label: 'Models',
|
||||
backgroundColor: 'var(--color-white-1)'
|
||||
// backgroundColor: 'linear-gradient(135deg, #ffffff, rgb(232 249 240 / 60%))'
|
||||
},
|
||||
{
|
||||
key: 'instances',
|
||||
key: 'model_instance_count',
|
||||
label: 'Instances',
|
||||
backgroundColor: 'var(--color-white-1)'
|
||||
// backgroundColor: 'linear-gradient(135deg, #ffffff, rgb(232 249 240 / 60%))'
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface DashboardProps {
|
||||
resource_counts: {
|
||||
worker_count: number;
|
||||
gpu_count: number;
|
||||
model_count: number;
|
||||
model_instance_count: number;
|
||||
};
|
||||
system_load: {
|
||||
current: {
|
||||
cpu: {
|
||||
total: number;
|
||||
used: number;
|
||||
utilization_rate: number;
|
||||
};
|
||||
memory: {
|
||||
total: number;
|
||||
used: number;
|
||||
utilization_rate: number;
|
||||
};
|
||||
gpu: {
|
||||
total: number;
|
||||
used: number;
|
||||
utilization_rate: number;
|
||||
};
|
||||
gpu_memory: {
|
||||
total: number;
|
||||
used: number;
|
||||
utilization_rate: number;
|
||||
};
|
||||
};
|
||||
history: {
|
||||
cpu: {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
}[];
|
||||
memory: {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
}[];
|
||||
gpu: {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
}[];
|
||||
gpu_memory: {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
model_usage: {
|
||||
api_request_history: any[];
|
||||
completion_token_history: any[];
|
||||
prompt_token_history: any[];
|
||||
top_users: {
|
||||
user_id: number;
|
||||
username: string;
|
||||
prompt_token_count: number;
|
||||
completion_token_count: number;
|
||||
}[];
|
||||
};
|
||||
active_models: any[];
|
||||
}
|
||||
@@ -1,16 +1,40 @@
|
||||
import { Spin } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { queryDashboardData } from './apis';
|
||||
import ActiveTable from './components/active-table';
|
||||
import Overview from './components/over-view';
|
||||
import SystemLoad from './components/system-load';
|
||||
import Usage from './components/usage';
|
||||
import DashboardContext from './config/dashboard-context';
|
||||
import { DashboardProps } from './config/types';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const [data, setData] = useState<DashboardProps>({} as DashboardProps);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const getDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await queryDashboardData();
|
||||
setData(res);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
setData({} as DashboardProps);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
getDashboardData();
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<Overview></Overview>
|
||||
<SystemLoad></SystemLoad>
|
||||
<Usage></Usage>
|
||||
<ActiveTable></ActiveTable>
|
||||
</>
|
||||
<Spin spinning={loading}>
|
||||
<DashboardContext.Provider value={{ ...data }}>
|
||||
<Overview></Overview>
|
||||
<SystemLoad></SystemLoad>
|
||||
<Usage></Usage>
|
||||
<ActiveTable></ActiveTable>
|
||||
</DashboardContext.Provider>
|
||||
</Spin>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user