feat(cluster): import existing Proxmox cluster into the panel
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Backend Tests / pytest (Python 3.12) (push) Has been cancelled

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:
markmorado
2026-07-24 06:35:34 +05:00
parent b804a9bec5
commit 9b493da7ad
5 changed files with 428 additions and 5 deletions

View File

@@ -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 ==================== # ==================== POST /api/cluster/{server_id}/eject-node ====================
@router.post("/api/cluster/{server_id}/eject-node") @router.post("/api/cluster/{server_id}/eject-node")

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Network, Server, Plus, Loader2, LogOut, AlertTriangle, Info, Network, Server, Plus, Loader2, LogOut, AlertTriangle, Info,
ArrowRight, ArrowLeft, ShieldAlert, Boxes, ArrowRight, ArrowLeft, ShieldAlert, Boxes, Link2, ScanSearch, CheckCircle2,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -21,9 +21,9 @@ import { ServerStatusBadge } from '@/components/shared/ServerStatusBadge';
import { formatBytes } from '@/lib/format'; import { formatBytes } from '@/lib/format';
import { import {
useClusterTopology, useCreateCluster, usePreJoinCheck, usePrepareJoin, useClusterTopology, useCreateCluster, usePreJoinCheck, usePrepareJoin,
useJoinCluster, useEjectNode, useJoinCluster, useEjectNode, useClusterImportInfo, useImportCluster,
} from '@/hooks/use-cluster'; } 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'; import { toast } from 'sonner';
// ==================== Create Cluster Dialog ==================== // ==================== 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 ==================== // ==================== Eject Dialog ====================
function EjectDialog({ function EjectDialog({
@@ -467,6 +632,7 @@ export default function ClusterPage() {
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [joinOpen, setJoinOpen] = useState(false); const [joinOpen, setJoinOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [ejectState, setEjectState] = useState<{ via: ClusterServerEntry; node: ClusterServerEntry } | null>(null); const [ejectState, setEjectState] = useState<{ via: ClusterServerEntry; node: ClusterServerEntry } | null>(null);
const standalone = topology?.standalone ?? []; const standalone = topology?.standalone ?? [];
@@ -483,6 +649,9 @@ export default function ClusterPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('nav.cluster', 'Cluster')}</h1> <h1 className="text-2xl font-bold">{t('nav.cluster', 'Cluster')}</h1>
<div className="flex gap-2"> <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}> <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')} <Plus className="mr-1 h-4 w-4" />{t('cluster.join_node', 'Join node')}
</Button> </Button>
@@ -582,6 +751,7 @@ export default function ClusterPage() {
<CreateClusterDialog open={createOpen} onOpenChange={setCreateOpen} standalone={standalone} /> <CreateClusterDialog open={createOpen} onOpenChange={setCreateOpen} standalone={standalone} />
<JoinWizard open={joinOpen} onOpenChange={setJoinOpen} standalone={standalone} clusterMembers={clusterMembers} /> <JoinWizard open={joinOpen} onOpenChange={setJoinOpen} standalone={standalone} clusterMembers={clusterMembers} />
<ImportClusterDialog open={importOpen} onOpenChange={setImportOpen} standalone={standalone} />
<EjectDialog <EjectDialog
open={ejectState !== null} open={ejectState !== null}
onOpenChange={(v) => { if (!v) setEjectState(null); }} onOpenChange={(v) => { if (!v) setEjectState(null); }}

View File

@@ -64,6 +64,31 @@ export interface ClusterActionResult {
confirmed?: boolean; 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 ==================== // ==================== Queries ====================
export function useClusterTopology() { 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() { export function useEjectNode() {
const invalidate = useInvalidateCluster(); const invalidate = useInvalidateCluster();
return useMutation({ return useMutation({

View File

@@ -195,7 +195,16 @@
"nodes": "nodes", "nodes": "nodes",
"standalone": "Standalone servers", "standalone": "Standalone servers",
"no_standalone": "No 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": { "login": {
"title": "Sign in to your account", "title": "Sign in to your account",

View File

@@ -195,7 +195,16 @@
"nodes": "нод(ы)", "nodes": "нод(ы)",
"standalone": "Standalone-серверы", "standalone": "Standalone-серверы",
"no_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": { "login": {
"title": "Вход в аккаунт", "title": "Вход в аккаунт",