init
This commit is contained in:
1 parent
c7fccf99c7
commit
8471f368eb
33 files changed
+444
-2095
No files matched your search
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ChevronRight, Check, AlertTriangle, Shield, Database,
|
||||
FileText, Sparkles, RotateCcw, Loader2, Info,
|
||||
ChevronDown, ChevronUp, Table2, Columns3, FolderOpen,
|
||||
} from 'lucide-react';
|
||||
import { ticketApi } from '@/api/ticket';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
@@ -39,6 +40,12 @@ interface StepConfig {
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
interface TableMeta {
|
||||
name: string;
|
||||
rows?: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -79,6 +86,24 @@ const PRIORITY_OPTIONS = [
|
||||
{ value: 'high', label: '高' },
|
||||
];
|
||||
|
||||
// 按使用频率排序
|
||||
const DB_TYPE_OPTIONS = [
|
||||
{ value: '', label: '请选择数据库类型' },
|
||||
{ value: 'mysql', label: 'MySQL' },
|
||||
{ value: 'redis', label: 'Redis' },
|
||||
{ value: 'postgres', label: 'PostgreSQL' },
|
||||
{ value: 'mongodb', label: 'MongoDB' },
|
||||
];
|
||||
|
||||
const DB_TYPE_LABELS: Record<string, string> = {
|
||||
mysql: 'MySQL',
|
||||
redis: 'Redis',
|
||||
postgres: 'PostgreSQL',
|
||||
postgresql: 'PostgreSQL',
|
||||
mongodb: 'MongoDB',
|
||||
sqlite: 'SQLite',
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helper: generate rollback SQL suggestion */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -86,7 +111,6 @@ const PRIORITY_OPTIONS = [
|
||||
function generateRollbackSuggestion(sql: string): string {
|
||||
const upper = sql.toUpperCase().trim();
|
||||
|
||||
// UPDATE -> generate reverse UPDATE
|
||||
if (upper.startsWith('UPDATE')) {
|
||||
const tableMatch = sql.match(/UPDATE\s+(\w+)/i);
|
||||
const table = tableMatch ? tableMatch[1] : 'table';
|
||||
@@ -95,19 +119,16 @@ function generateRollbackSuggestion(sql: string): string {
|
||||
return `-- 建议回滚语句(请根据实际业务调整):\n-- UPDATE ${table} SET ... WHERE ${where || '...'};`;
|
||||
}
|
||||
|
||||
// DELETE -> generate INSERT from backup
|
||||
if (upper.startsWith('DELETE')) {
|
||||
const tableMatch = sql.match(/FROM\s+(\w+)/i) || sql.match(/DELETE\s+(\w+)/i);
|
||||
const table = tableMatch ? tableMatch[1] : 'table';
|
||||
return `-- 建议回滚语句:\n-- INSERT INTO ${table} SELECT * FROM ${table}_backup WHERE ...;\n-- 或从 Binlog 恢复`;
|
||||
}
|
||||
|
||||
// ALTER -> generate reverse ALTER
|
||||
if (upper.startsWith('ALTER')) {
|
||||
return `-- DDL 回滚说明:\n-- 1. ALTER TABLE 操作无法直接回滚\n-- 2. 建议先使用 pt-online-schema-change 或 gh-ost 进行在线变更\n-- 3. 执行前请确保有完整备份`;
|
||||
}
|
||||
|
||||
// INSERT -> generate DELETE
|
||||
if (upper.startsWith('INSERT')) {
|
||||
const tableMatch = sql.match(/INTO\s+(\w+)/i);
|
||||
const table = tableMatch ? tableMatch[1] : 'table';
|
||||
@@ -130,6 +151,7 @@ export default function TicketCreatePage() {
|
||||
// -- Form fields --
|
||||
const [title, setTitle] = useState('');
|
||||
const [priority, setPriority] = useState('medium');
|
||||
const [dbType, setDbType] = useState('');
|
||||
const [instanceId, setInstanceId] = useState('');
|
||||
const [database, setDatabase] = useState('');
|
||||
const [sqlContent, setSqlContent] = useState('');
|
||||
@@ -148,6 +170,14 @@ export default function TicketCreatePage() {
|
||||
// -- Audit --
|
||||
const [auditResult, setAuditResult] = useState<AuditResult | null>(null);
|
||||
|
||||
// -- Schema browser --
|
||||
const [showSchema, setShowSchema] = useState(false);
|
||||
const [tables, setTables] = useState<TableMeta[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [expandedTable, setExpandedTable] = useState<string | null>(null);
|
||||
const [columns, setColumns] = useState<Record<string, string[]>>({});
|
||||
const [loadingColumns, setLoadingColumns] = useState<string | null>(null);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Fetch instances on mount */
|
||||
/* ---------------------------------------------------------------- */
|
||||
@@ -163,6 +193,39 @@ export default function TicketCreatePage() {
|
||||
.finally(() => setLoadingInstances(false));
|
||||
}, []);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Filtered instances by db type */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const filteredInstances = useMemo(() => {
|
||||
if (!dbType) return instances;
|
||||
return instances.filter(i => {
|
||||
if (dbType === 'postgres') return i.db_type === 'postgres' || i.db_type === 'postgresql';
|
||||
return i.db_type === dbType;
|
||||
});
|
||||
}, [instances, dbType]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Reset instance when dbType changes */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
useEffect(() => {
|
||||
// If current instance doesn't match new dbType, reset it
|
||||
if (instanceId) {
|
||||
const current = instances.find(i => i.id === Number(instanceId));
|
||||
if (current && dbType) {
|
||||
const match = dbType === 'postgres'
|
||||
? (current.db_type === 'postgres' || current.db_type === 'postgresql')
|
||||
: current.db_type === dbType;
|
||||
if (!match) {
|
||||
setInstanceId('');
|
||||
setDatabase('');
|
||||
setDatabases([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [dbType, instances, instanceId]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Fetch databases when instance changes */
|
||||
/* ---------------------------------------------------------------- */
|
||||
@@ -173,6 +236,7 @@ export default function TicketCreatePage() {
|
||||
setDatabase('');
|
||||
return;
|
||||
}
|
||||
const inst = instances.find(i => i.id === Number(instanceId));
|
||||
setLoadingDatabases(true);
|
||||
instanceApi.listDatabases(Number(instanceId))
|
||||
.then((res: unknown) => {
|
||||
@@ -182,14 +246,80 @@ export default function TicketCreatePage() {
|
||||
setDatabases(Array.isArray(data) ? data as string[] : []);
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
setDatabase(String(data[0]));
|
||||
} else if (inst?.db_type === 'redis') {
|
||||
// Redis 默认 db0
|
||||
setDatabase('db0');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback: no auto database list
|
||||
setDatabases([]);
|
||||
// Redis fallback
|
||||
if (inst?.db_type === 'redis') {
|
||||
setDatabase('db0');
|
||||
}
|
||||
})
|
||||
.finally(() => setLoadingDatabases(false));
|
||||
}, [instanceId]);
|
||||
}, [instanceId, instances]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Schema browser: load tables */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSchema || !instanceId || !database) {
|
||||
setTables([]);
|
||||
return;
|
||||
}
|
||||
setLoadingTables(true);
|
||||
instanceApi.listTables(Number(instanceId), database)
|
||||
.then((res: unknown) => {
|
||||
const data = (res as { tables?: TableMeta[] })?.tables
|
||||
|| (res as TableMeta[])
|
||||
|| [];
|
||||
setTables(Array.isArray(data) ? data as TableMeta[] : []);
|
||||
})
|
||||
.catch(() => setTables([]))
|
||||
.finally(() => setLoadingTables(false));
|
||||
}, [showSchema, instanceId, database]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Schema browser: load columns for a table */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const handleExpandTable = useCallback(async (tableName: string) => {
|
||||
if (expandedTable === tableName) {
|
||||
setExpandedTable(null);
|
||||
return;
|
||||
}
|
||||
setExpandedTable(tableName);
|
||||
|
||||
if (columns[tableName]) return; // already loaded
|
||||
|
||||
setLoadingColumns(tableName);
|
||||
try {
|
||||
const res = await instanceApi.listTables(Number(instanceId), `${database}/${tableName}`) as unknown;
|
||||
const data = (res as { tables?: { columns?: { name: string }[] }[] })?.tables;
|
||||
if (Array.isArray(data) && data[0]?.columns) {
|
||||
const cols = data[0].columns.map((c: { name: string }) => c.name);
|
||||
setColumns(prev => ({ ...prev, [tableName]: cols }));
|
||||
}
|
||||
} catch {
|
||||
// ignore — columns not available
|
||||
} finally {
|
||||
setLoadingColumns(null);
|
||||
}
|
||||
}, [expandedTable, columns, instanceId, database]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Insert table/column name into SQL editor */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const insertToEditor = useCallback((text: string) => {
|
||||
setSqlContent(prev => {
|
||||
const separator = prev.trim() ? (prev.trimEnd().endsWith('\n') ? '' : ' ') : '';
|
||||
return prev + separator + text + ' ';
|
||||
});
|
||||
}, []);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* SQL change callback */
|
||||
@@ -197,7 +327,6 @@ export default function TicketCreatePage() {
|
||||
|
||||
const handleSqlChange = useCallback((val: string) => {
|
||||
setSqlContent(val);
|
||||
// Auto-generate rollback suggestion when SQL changes
|
||||
if (val.trim().length > 10) {
|
||||
setRollbackSql(generateRollbackSuggestion(val));
|
||||
}
|
||||
@@ -255,7 +384,7 @@ export default function TicketCreatePage() {
|
||||
const inst = instances.find((i) => i.id === Number(instanceId));
|
||||
await ticketApi.create({
|
||||
title: title.trim(),
|
||||
db_type: inst?.db_type || 'mysql',
|
||||
db_type: inst?.db_type || dbType || 'mysql',
|
||||
instance_id: Number(instanceId),
|
||||
target_database: database,
|
||||
sql_content: sqlContent,
|
||||
@@ -270,7 +399,7 @@ export default function TicketCreatePage() {
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, navigate]);
|
||||
}, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, dbType, navigate]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Derived values */
|
||||
@@ -336,6 +465,106 @@ export default function TicketCreatePage() {
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Schema Browser Panel */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const renderSchemaBrowser = () => {
|
||||
if (!instanceId || !database) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg">
|
||||
{/* Toggle button */}
|
||||
<button
|
||||
onClick={() => setShowSchema(!showSchema)}
|
||||
className="flex items-center gap-2 w-full px-4 py-2.5 text-sm font-medium text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
浏览库表结构
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">— 点击表名/字段插入到 SQL</span>
|
||||
{showSchema
|
||||
? <ChevronUp className="w-4 h-4 ml-auto" />
|
||||
: <ChevronDown className="w-4 h-4 ml-auto" />
|
||||
}
|
||||
</button>
|
||||
|
||||
{/* Schema content */}
|
||||
{showSchema && (
|
||||
<div className="px-4 pb-3 max-h-[300px] overflow-y-auto">
|
||||
{loadingTables ? (
|
||||
<div className="flex items-center gap-2 py-3 text-xs text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
加载表列表...
|
||||
</div>
|
||||
) : tables.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] py-2">无可用表(或实例未连接)</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{tables.map(t => (
|
||||
<div key={t.name}>
|
||||
{/* Table row */}
|
||||
<button
|
||||
onClick={() => handleExpandTable(t.name)}
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-[var(--dg-bg-hover)] transition-colors group"
|
||||
>
|
||||
{expandedTable === t.name
|
||||
? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
: <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
}
|
||||
<Table2 className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span
|
||||
className="font-mono text-[var(--dg-text-primary)] hover:text-blue-400 transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); insertToEditor(t.name); }}
|
||||
title="点击插入表名"
|
||||
>
|
||||
{t.name}
|
||||
</span>
|
||||
{t.rows !== undefined && (
|
||||
<span className="text-xs text-[var(--dg-text-muted)] ml-auto">
|
||||
{t.rows.toLocaleString()} 行
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Columns */}
|
||||
{expandedTable === t.name && (
|
||||
<div className="ml-5 pl-3">
|
||||
{loadingColumns === t.name ? (
|
||||
<div className="flex items-center gap-2 py-1 text-xs text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
加载字段...
|
||||
</div>
|
||||
) : columns[t.name] ? (
|
||||
<div className="space-y-0.5">
|
||||
{columns[t.name].map(col => (
|
||||
<button
|
||||
key={col}
|
||||
onClick={() => insertToEditor(col)}
|
||||
className="flex items-center gap-2 w-full px-2 py-1 text-xs rounded hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
title="点击插入字段名"
|
||||
>
|
||||
<Columns3 className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400 transition-colors">
|
||||
{col}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] py-1">字段信息不可用</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ========== Step 1: Fill info ========== */
|
||||
const renderStep1 = () => (
|
||||
<div className="space-y-5 bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
@@ -352,7 +581,7 @@ export default function TicketCreatePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority + Instance */}
|
||||
{/* Priority + DB Type */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">优先级</label>
|
||||
@@ -369,32 +598,65 @@ export default function TicketCreatePage() {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
选择实例 <span className="text-red-400">*</span>
|
||||
数据库类型 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={instanceId}
|
||||
onChange={(e) => setInstanceId(e.target.value)}
|
||||
disabled={loadingInstances}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50"
|
||||
value={dbType}
|
||||
onChange={(e) => setDbType(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option value="">
|
||||
{loadingInstances ? '加载中...' : '请选择实例'}
|
||||
</option>
|
||||
{instances.map((inst) => (
|
||||
<option key={inst.id} value={String(inst.id)}>
|
||||
{inst.name} ({inst.host}:{inst.port}) [{inst.db_type.toUpperCase()}]
|
||||
</option>
|
||||
{DB_TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instance (filtered by dbType) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
选择实例 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={instanceId}
|
||||
onChange={(e) => setInstanceId(e.target.value)}
|
||||
disabled={loadingInstances || filteredInstances.length === 0}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{loadingInstances
|
||||
? '加载中...'
|
||||
: dbType && filteredInstances.length === 0
|
||||
? `无 ${(DB_TYPE_LABELS[dbType]) || dbType} 实例`
|
||||
: dbType
|
||||
? `请选择 ${(DB_TYPE_LABELS[dbType]) || dbType} 实例`
|
||||
: '请先选择数据库类型'
|
||||
}
|
||||
</option>
|
||||
{filteredInstances.map((inst) => (
|
||||
<option key={inst.id} value={String(inst.id)}>
|
||||
{inst.name} ({inst.host}:{inst.port})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Database */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
数据库 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
{databases.length > 0 ? (
|
||||
{selectedInstance?.db_type === 'redis' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={database}
|
||||
onChange={(e) => setDatabase(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
placeholder="db0"
|
||||
/>
|
||||
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">Redis</span>
|
||||
</div>
|
||||
) : databases.length > 0 ? (
|
||||
<select
|
||||
value={database}
|
||||
onChange={(e) => setDatabase(e.target.value)}
|
||||
@@ -410,22 +672,19 @@ export default function TicketCreatePage() {
|
||||
)}
|
||||
</select>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={database}
|
||||
onChange={(e) => setDatabase(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
placeholder="目标数据库名"
|
||||
/>
|
||||
{selectedInstance && (
|
||||
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">
|
||||
{selectedInstance.db_type.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
value={database}
|
||||
onChange={(e) => setDatabase(e.target.value)}
|
||||
disabled={!instanceId}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50"
|
||||
placeholder={instanceId ? '请输入数据库名' : '请先选择实例'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Schema Browser */}
|
||||
{renderSchemaBrowser()}
|
||||
|
||||
{/* SQL Editor */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -456,7 +715,7 @@ export default function TicketCreatePage() {
|
||||
/>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1.5 flex items-center gap-1">
|
||||
<Info className="w-3 h-3" />
|
||||
支持 Ctrl+Shift+F 格式化代码
|
||||
提交前必须通过 SQL 审核(语法检查 + 审核规则)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -500,12 +759,12 @@ export default function TicketCreatePage() {
|
||||
{auditing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
审核中...
|
||||
验证审核中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="w-4 h-4" />
|
||||
审核并继续
|
||||
验证并审核
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -649,7 +908,7 @@ export default function TicketCreatePage() {
|
||||
|
||||
{/* Rollback preview */}
|
||||
{rollbackSql && (
|
||||
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-purple-500/20">
|
||||
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<RotateCcw className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">回滚 SQL</span>
|
||||
|
||||
Reference in new issue
Block a user