feat(cluster): import existing Proxmox cluster into the panel
Add detect/import flow for nodes already clustered on the Proxmox side (outside the panel). Matches nodelist entries from cluster/config/join against known panel servers by IP/hostname and links their panel records to the cluster_name group, without running any pvecm operation.
This commit is contained in:
@@ -626,6 +626,198 @@ async def join_cluster(
|
||||
}
|
||||
|
||||
|
||||
# ==================== GET /api/cluster/{server_id}/import-info ====================
|
||||
|
||||
@router.get("/api/cluster/{server_id}/import-info")
|
||||
async def get_cluster_import_info(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("cluster:manage")),
|
||||
):
|
||||
"""
|
||||
Обнаружить кластер, сформированный на стороне Proxmox в обход панели
|
||||
(напр. через pvecm/веб-интерфейс Proxmox), и сопоставить его ноды с
|
||||
серверами, уже добавленными в панель — по IP/hostname из nodelist.
|
||||
Ничего не меняет, только читает.
|
||||
"""
|
||||
server = _get_server_or_404(db, server_id)
|
||||
if server.cluster_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Сервер '{server.name}' уже привязан к кластеру '{server.cluster_name}' в панели"
|
||||
)
|
||||
|
||||
client = _get_proxmox_client(server)
|
||||
|
||||
is_cluster = await _run_in_executor(client.is_cluster)
|
||||
if not is_cluster:
|
||||
return {
|
||||
"server_id": server_id,
|
||||
"is_cluster": False,
|
||||
"cluster_name": None,
|
||||
"nodes": [],
|
||||
"message": "Нода не состоит в кластере Proxmox — нечего импортировать.",
|
||||
}
|
||||
|
||||
join_info_result = await _run_in_executor(client.get_cluster_join_info)
|
||||
if not join_info_result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Не удалось получить информацию о кластере: {join_info_result.get('error')}"
|
||||
)
|
||||
|
||||
data = join_info_result.get("data", {})
|
||||
cluster_name = data.get("totem", {}).get("cluster_name")
|
||||
nodelist = data.get("nodelist", [])
|
||||
|
||||
all_servers = db.query(ProxmoxServer).all()
|
||||
|
||||
def find_match(entry: dict):
|
||||
addr_candidates = {entry.get("pve_addr"), entry.get("ring0_addr")}
|
||||
addr_candidates.discard(None)
|
||||
for s in all_servers:
|
||||
if s.ip_address in addr_candidates:
|
||||
return s
|
||||
name = entry.get("name")
|
||||
if name:
|
||||
for s in all_servers:
|
||||
if s.hostname == name:
|
||||
return s
|
||||
return None
|
||||
|
||||
nodes = []
|
||||
for entry in nodelist:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
match = find_match(entry)
|
||||
nodes.append({
|
||||
"name": entry.get("name"),
|
||||
"address": entry.get("pve_addr") or entry.get("ring0_addr"),
|
||||
"matched_server_id": match.id if match else None,
|
||||
"matched_server_name": match.name if match else None,
|
||||
"already_linked": bool(match and match.cluster_name == cluster_name),
|
||||
"linked_elsewhere": bool(match and match.cluster_name and match.cluster_name != cluster_name),
|
||||
})
|
||||
|
||||
return {
|
||||
"server_id": server_id,
|
||||
"is_cluster": True,
|
||||
"cluster_name": cluster_name,
|
||||
"nodes": nodes,
|
||||
}
|
||||
|
||||
|
||||
# ==================== POST /api/cluster/import ====================
|
||||
|
||||
@router.post("/api/cluster/import")
|
||||
async def import_cluster(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("cluster:manage")),
|
||||
):
|
||||
"""
|
||||
Привязать существующие панельные записи серверов к реальному
|
||||
Proxmox-кластеру, обнаруженному на server_id (см. import-info).
|
||||
Только правит cluster_name в БД панели — никаких pvecm-операций не
|
||||
выполняется, реальный кластер не затрагивается.
|
||||
|
||||
Body:
|
||||
server_id (int): нода-якорь, на которой обнаружен кластер
|
||||
server_ids (list[int]): панельные ID серверов для привязки к кластеру
|
||||
(каждый должен совпадать по IP или hostname с нодой из nodelist
|
||||
кластера — иначе привязка для него отклоняется)
|
||||
"""
|
||||
server_id = body.get("server_id")
|
||||
server_ids = body.get("server_ids") or []
|
||||
|
||||
if not server_id or not server_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Необходимо указать server_id и server_ids"
|
||||
)
|
||||
|
||||
anchor = _get_server_or_404(db, server_id)
|
||||
client = _get_proxmox_client(anchor)
|
||||
|
||||
if not await _run_in_executor(client.is_cluster):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Сервер '{anchor.name}' не состоит в кластере Proxmox"
|
||||
)
|
||||
|
||||
join_info_result = await _run_in_executor(client.get_cluster_join_info)
|
||||
if not join_info_result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Не удалось получить информацию о кластере: {join_info_result.get('error')}"
|
||||
)
|
||||
|
||||
data = join_info_result.get("data", {})
|
||||
cluster_name = data.get("totem", {}).get("cluster_name")
|
||||
if not cluster_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Не удалось определить имя кластера"
|
||||
)
|
||||
nodelist = data.get("nodelist", [])
|
||||
|
||||
addr_by_node: set = set()
|
||||
name_by_node: set = set()
|
||||
for entry in nodelist:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
for addr in (entry.get("pve_addr"), entry.get("ring0_addr")):
|
||||
if addr:
|
||||
addr_by_node.add(addr)
|
||||
if entry.get("name"):
|
||||
name_by_node.add(entry.get("name"))
|
||||
|
||||
applied = []
|
||||
errors = []
|
||||
|
||||
for sid in server_ids:
|
||||
s = db.query(ProxmoxServer).filter(ProxmoxServer.id == sid).first()
|
||||
if not s:
|
||||
errors.append({"server_id": sid, "error": "Сервер не найден"})
|
||||
continue
|
||||
if s.cluster_name == cluster_name:
|
||||
applied.append(sid) # уже привязан — идемпотентно
|
||||
continue
|
||||
if s.cluster_name and s.cluster_name != cluster_name:
|
||||
errors.append({
|
||||
"server_id": sid,
|
||||
"error": f"'{s.name}' уже привязан к другому кластеру '{s.cluster_name}'",
|
||||
})
|
||||
continue
|
||||
if s.ip_address not in addr_by_node and s.hostname not in name_by_node:
|
||||
errors.append({
|
||||
"server_id": sid,
|
||||
"error": f"IP/hostname '{s.name}' не найден среди нод кластера '{cluster_name}' — привязка отклонена",
|
||||
})
|
||||
continue
|
||||
s.cluster_name = cluster_name
|
||||
applied.append(sid)
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"User {current_user.username} imported existing cluster '{cluster_name}' "
|
||||
f"for panel servers {applied} (anchor='{anchor.name}', errors={len(errors)})"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": len(errors) == 0,
|
||||
"cluster_name": cluster_name,
|
||||
"applied": applied,
|
||||
"errors": errors,
|
||||
"message": (
|
||||
f"Кластер '{cluster_name}' импортирован для {len(applied)} серверов."
|
||||
if not errors
|
||||
else f"Импортировано {len(applied)}, ошибок: {len(errors)}."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ==================== POST /api/cluster/{server_id}/eject-node ====================
|
||||
|
||||
@router.post("/api/cluster/{server_id}/eject-node")
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Network, Server, Plus, Loader2, LogOut, AlertTriangle, Info,
|
||||
ArrowRight, ArrowLeft, ShieldAlert, Boxes,
|
||||
ArrowRight, ArrowLeft, ShieldAlert, Boxes, Link2, ScanSearch, CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -21,9 +21,9 @@ import { ServerStatusBadge } from '@/components/shared/ServerStatusBadge';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import {
|
||||
useClusterTopology, useCreateCluster, usePreJoinCheck, usePrepareJoin,
|
||||
useJoinCluster, useEjectNode,
|
||||
useJoinCluster, useEjectNode, useClusterImportInfo, useImportCluster,
|
||||
} from '@/hooks/use-cluster';
|
||||
import type { ClusterServerEntry, PreJoinCheck } from '@/hooks/use-cluster';
|
||||
import type { ClusterServerEntry, PreJoinCheck, ImportClusterInfo } from '@/hooks/use-cluster';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// ==================== Create Cluster Dialog ====================
|
||||
@@ -384,6 +384,171 @@ function JoinWizard({
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Import Existing Cluster Dialog ====================
|
||||
|
||||
function ImportClusterDialog({
|
||||
open, onOpenChange, standalone,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
standalone: ClusterServerEntry[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const importInfo = useClusterImportInfo();
|
||||
const importCluster = useImportCluster();
|
||||
|
||||
const [serverId, setServerId] = useState('');
|
||||
const [info, setInfo] = useState<ImportClusterInfo | null>(null);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
|
||||
const reset = () => { setServerId(''); setInfo(null); setSelected(new Set()); };
|
||||
const close = (v: boolean) => { if (!v) reset(); onOpenChange(v); };
|
||||
|
||||
const runDetect = () => {
|
||||
importInfo.mutate(Number(serverId), {
|
||||
onSuccess: (data) => {
|
||||
setInfo(data);
|
||||
if (!data.is_cluster) {
|
||||
toast.info(data.message || t('cluster.no_real_cluster', 'This node is not part of a Proxmox cluster.'));
|
||||
return;
|
||||
}
|
||||
// Pre-select every node that already matches a panel server and isn't linked elsewhere.
|
||||
const ids = data.nodes
|
||||
.filter((n) => n.matched_server_id !== null && !n.linked_elsewhere)
|
||||
.map((n) => n.matched_server_id as number);
|
||||
setSelected(new Set(ids));
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
const toggle = (id: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const runImport = () => {
|
||||
importCluster.mutate(
|
||||
{ server_id: Number(serverId), server_ids: Array.from(selected) },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
if (res.errors.length > 0) {
|
||||
toast.warning(res.message);
|
||||
} else {
|
||||
toast.success(res.message);
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
}
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={close}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cluster.import_title', 'Import existing cluster')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('cluster.import_hint',
|
||||
'For nodes already clustered on the Proxmox side (outside the panel). This only links matching panel entries by IP/hostname — it does not run any pvecm operation.')}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Label>{t('cluster.select_node', 'Standalone server')}</Label>
|
||||
<Select
|
||||
value={serverId}
|
||||
onValueChange={(v) => { if (v !== null) { setServerId(v); setInfo(null); setSelected(new Set()); } }}
|
||||
>
|
||||
<SelectTrigger className="mt-1"><SelectValue placeholder={t('cluster.select_node', 'Select server')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{standalone.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>{s.name} ({s.ip_address})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{!info && (
|
||||
<Button variant="outline" className="w-full" onClick={runDetect} disabled={!serverId || importInfo.isPending}>
|
||||
{importInfo.isPending
|
||||
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
: <ScanSearch className="mr-2 h-4 w-4" />}
|
||||
{t('cluster.detect_btn', 'Detect cluster')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{info && !info.is_cluster && (
|
||||
<div className="flex items-start gap-2 rounded-md bg-muted p-3 text-sm">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{info.message || t('cluster.no_real_cluster', 'This node is not part of a Proxmox cluster.')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info && info.is_cluster && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 rounded-md bg-success/10 p-3 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-success" />
|
||||
<span>
|
||||
{t('cluster.detected_as', 'Detected cluster')}: <b>{info.cluster_name}</b> · {info.nodes.length} {t('cluster.nodes', 'nodes')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y rounded-md border">
|
||||
{info.nodes.map((n) => (
|
||||
<div key={n.name ?? n.address} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{n.name} <span className="text-muted-foreground font-mono">({n.address})</span></p>
|
||||
{n.matched_server_id === null && (
|
||||
<p className="text-xs text-muted-foreground">{t('cluster.not_in_panel', 'Not added to the panel yet — add it as a server first.')}</p>
|
||||
)}
|
||||
{n.linked_elsewhere && (
|
||||
<p className="text-xs text-destructive">{t('cluster.linked_elsewhere', 'Panel entry is linked to a different cluster.')}</p>
|
||||
)}
|
||||
{n.already_linked && (
|
||||
<p className="text-xs text-muted-foreground">{t('cluster.already_linked', 'Already linked in the panel.')}</p>
|
||||
)}
|
||||
</div>
|
||||
{n.matched_server_id !== null && !n.already_linked && !n.linked_elsewhere && (
|
||||
<Checkbox
|
||||
checked={selected.has(n.matched_server_id)}
|
||||
onChange={() => toggle(n.matched_server_id as number)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{importCluster.data?.errors && importCluster.data.errors.length > 0 && (
|
||||
<div className="space-y-1 rounded-md bg-destructive/10 p-3 text-xs text-destructive">
|
||||
{importCluster.data.errors.map((e) => <p key={e.server_id}>{e.error}</p>)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<DialogClose render={<Button variant="outline" />}>{t('common.cancel', 'Cancel')}</DialogClose>
|
||||
{info?.is_cluster && (
|
||||
<Button onClick={runImport} disabled={selected.size === 0 || importCluster.isPending}>
|
||||
{importCluster.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('cluster.import_btn', 'Import')} ({selected.size})
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Eject Dialog ====================
|
||||
|
||||
function EjectDialog({
|
||||
@@ -467,6 +632,7 @@ export default function ClusterPage() {
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [joinOpen, setJoinOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [ejectState, setEjectState] = useState<{ via: ClusterServerEntry; node: ClusterServerEntry } | null>(null);
|
||||
|
||||
const standalone = topology?.standalone ?? [];
|
||||
@@ -483,6 +649,9 @@ export default function ClusterPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{t('nav.cluster', 'Cluster')}</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)} disabled={standalone.length === 0}>
|
||||
<Link2 className="mr-1 h-4 w-4" />{t('cluster.import_btn', 'Import')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setJoinOpen(true)} disabled={!hasClusters || standalone.length === 0}>
|
||||
<Plus className="mr-1 h-4 w-4" />{t('cluster.join_node', 'Join node')}
|
||||
</Button>
|
||||
@@ -582,6 +751,7 @@ export default function ClusterPage() {
|
||||
|
||||
<CreateClusterDialog open={createOpen} onOpenChange={setCreateOpen} standalone={standalone} />
|
||||
<JoinWizard open={joinOpen} onOpenChange={setJoinOpen} standalone={standalone} clusterMembers={clusterMembers} />
|
||||
<ImportClusterDialog open={importOpen} onOpenChange={setImportOpen} standalone={standalone} />
|
||||
<EjectDialog
|
||||
open={ejectState !== null}
|
||||
onOpenChange={(v) => { if (!v) setEjectState(null); }}
|
||||
|
||||
@@ -64,6 +64,31 @@ export interface ClusterActionResult {
|
||||
confirmed?: boolean;
|
||||
}
|
||||
|
||||
export interface ImportClusterNode {
|
||||
name: string | null;
|
||||
address: string | null;
|
||||
matched_server_id: number | null;
|
||||
matched_server_name: string | null;
|
||||
already_linked: boolean;
|
||||
linked_elsewhere: boolean;
|
||||
}
|
||||
|
||||
export interface ImportClusterInfo {
|
||||
server_id: number;
|
||||
is_cluster: boolean;
|
||||
cluster_name: string | null;
|
||||
nodes: ImportClusterNode[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ImportClusterResult {
|
||||
success: boolean;
|
||||
cluster_name: string;
|
||||
applied: number[];
|
||||
errors: { server_id: number; error: string }[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ==================== Queries ====================
|
||||
|
||||
export function useClusterTopology() {
|
||||
@@ -119,6 +144,24 @@ export function useJoinCluster() {
|
||||
});
|
||||
}
|
||||
|
||||
/** On-demand detection of a real Proxmox cluster formed outside the panel, matched against known servers. */
|
||||
export function useClusterImportInfo() {
|
||||
return useMutation({
|
||||
mutationFn: (serverId: number) =>
|
||||
apiClient.get<ImportClusterInfo>(`/proxmox/api/cluster/${serverId}/import-info`),
|
||||
});
|
||||
}
|
||||
|
||||
/** Link existing panel server entries to a cluster detected via useClusterImportInfo (DB-only, no Proxmox changes). */
|
||||
export function useImportCluster() {
|
||||
const invalidate = useInvalidateCluster();
|
||||
return useMutation({
|
||||
mutationFn: (body: { server_id: number; server_ids: number[] }) =>
|
||||
apiClient.post<ImportClusterResult>('/proxmox/api/cluster/import', body),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEjectNode() {
|
||||
const invalidate = useInvalidateCluster();
|
||||
return useMutation({
|
||||
|
||||
@@ -195,7 +195,16 @@
|
||||
"nodes": "nodes",
|
||||
"standalone": "Standalone servers",
|
||||
"no_standalone": "No standalone servers.",
|
||||
"two_node_quorum": "A 2-node cluster loses quorum if one node fails. 3+ nodes or a QDevice is recommended for production."
|
||||
"two_node_quorum": "A 2-node cluster loses quorum if one node fails. 3+ nodes or a QDevice is recommended for production.",
|
||||
"import_btn": "Import",
|
||||
"import_title": "Import existing cluster",
|
||||
"import_hint": "For nodes already clustered on the Proxmox side (outside the panel). This only links matching panel entries by IP/hostname — it does not run any pvecm operation.",
|
||||
"detect_btn": "Detect cluster",
|
||||
"no_real_cluster": "This node is not part of a Proxmox cluster.",
|
||||
"detected_as": "Detected cluster",
|
||||
"not_in_panel": "Not added to the panel yet — add it as a server first.",
|
||||
"linked_elsewhere": "Panel entry is linked to a different cluster.",
|
||||
"already_linked": "Already linked in the panel."
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign in to your account",
|
||||
|
||||
@@ -195,7 +195,16 @@
|
||||
"nodes": "нод(ы)",
|
||||
"standalone": "Standalone-серверы",
|
||||
"no_standalone": "Нет standalone-серверов.",
|
||||
"two_node_quorum": "Кластер из 2 нод теряет кворум при падении одной. Для production рекомендуется 3+ ноды или QDevice."
|
||||
"two_node_quorum": "Кластер из 2 нод теряет кворум при падении одной. Для production рекомендуется 3+ ноды или QDevice.",
|
||||
"import_btn": "Импорт",
|
||||
"import_title": "Импорт существующего кластера",
|
||||
"import_hint": "Для нод, уже объединённых в кластер на стороне Proxmox (в обход панели). Только привязывает совпадающие записи в панели по IP/hostname — никаких pvecm-операций не выполняется.",
|
||||
"detect_btn": "Обнаружить кластер",
|
||||
"no_real_cluster": "Эта нода не состоит в кластере Proxmox.",
|
||||
"detected_as": "Обнаружен кластер",
|
||||
"not_in_panel": "Ещё не добавлена в панель — сначала добавьте её как сервер.",
|
||||
"linked_elsewhere": "Запись в панели привязана к другому кластеру.",
|
||||
"already_linked": "Уже привязана в панели."
|
||||
},
|
||||
"login": {
|
||||
"title": "Вход в аккаунт",
|
||||
|
||||
Reference in New Issue
Block a user