feat(cluster): режим eject "только панель" для нод, не входящих в кластер
При провале join нода остаётся стейл-записью с cluster_name в панели, хотя
в Proxmox она standalone. Обычный eject пытается pvecm delnode через SSH и
падает ("SSH connection failed"), т.к. нода не член кластера и/или узел
недоступен по SSH.
- eject-эндпоинт: новый параметр panel_only — отвязывает панельную запись
(cluster_name=None) без обращения к Proxmox/SSH.
- UI: чекбокс "Только отвязать запись в панели" в диалоге исключения,
блокирует поле пароля при выборе.
- Локали ru/en.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -677,10 +677,14 @@ def eject_cluster_node(
|
||||
node_name (str): hostname ноды для удаления
|
||||
rootpw (str, optional): root-пароль для SSH fallback
|
||||
clear_panel_entry (bool, optional): Очистить cluster_name у панельной записи ноды (default: true)
|
||||
panel_only (bool, optional): Не трогать Proxmox, только отвязать запись в
|
||||
панели. Для случаев, когда нода на самом деле не в кластере (напр.
|
||||
прошлый join не завершился) или недоступна для delnode/SSH.
|
||||
"""
|
||||
node_name = body.get("node_name", "").strip()
|
||||
rootpw = body.get("rootpw")
|
||||
clear_panel_entry = body.get("clear_panel_entry", True)
|
||||
panel_only = bool(body.get("panel_only", False))
|
||||
|
||||
if not node_name:
|
||||
raise HTTPException(
|
||||
@@ -691,6 +695,35 @@ def eject_cluster_node(
|
||||
# server_id — это нода, с которой выполняем команду (остающаяся в кластере)
|
||||
server = _get_server_or_404(db, server_id)
|
||||
|
||||
# --- Режим "только панель": не обращаемся к Proxmox, чистим запись в БД ---
|
||||
if panel_only:
|
||||
ejected_server = db.query(ProxmoxServer).filter(
|
||||
ProxmoxServer.hostname == node_name,
|
||||
ProxmoxServer.cluster_name == server.cluster_name,
|
||||
).first()
|
||||
if not ejected_server:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Панельная запись ноды '{node_name}' в кластере "
|
||||
f"'{server.cluster_name}' не найдена."
|
||||
)
|
||||
ejected_server.cluster_name = None
|
||||
db.commit()
|
||||
logger.info(
|
||||
f"User {current_user.username} detached panel entry '{ejected_server.name}' "
|
||||
f"from cluster '{server.cluster_name}' (panel-only, no Proxmox op)"
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"ejected_node": node_name,
|
||||
"cluster_name": server.cluster_name,
|
||||
"method": "panel",
|
||||
"message": (
|
||||
f"Запись ноды '{node_name}' отвязана от кластера в панели. "
|
||||
"Proxmox не затрагивался."
|
||||
),
|
||||
}
|
||||
|
||||
# Build client — prefer password for SSH fallback availability
|
||||
if rootpw or server.password:
|
||||
client = _get_password_client(server, rootpw)
|
||||
|
||||
@@ -395,20 +395,27 @@ function EjectDialog({
|
||||
const { t } = useTranslation();
|
||||
const ejectNode = useEjectNode();
|
||||
const [rootpw, setRootpw] = useState('');
|
||||
const [panelOnly, setPanelOnly] = useState(false);
|
||||
|
||||
const handleEject = () => {
|
||||
if (!viaServer || !node) return;
|
||||
ejectNode.mutate(
|
||||
{ serverId: viaServer.id, node_name: node.hostname, rootpw: rootpw.trim() || undefined, clear_panel_entry: true },
|
||||
{
|
||||
onSuccess: (res) => { toast.success(res.message); setRootpw(''); onOpenChange(false); },
|
||||
serverId: viaServer.id,
|
||||
node_name: node.hostname,
|
||||
rootpw: rootpw.trim() || undefined,
|
||||
clear_panel_entry: true,
|
||||
panel_only: panelOnly || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (res) => { toast.success(res.message); setRootpw(''); setPanelOnly(false); onOpenChange(false); },
|
||||
onError: (err) => toast.error(err.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) setRootpw(''); onOpenChange(v); }}>
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) { setRootpw(''); setPanelOnly(false); } onOpenChange(v); }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader><DialogTitle>{t('cluster.eject_title', 'Eject node from cluster')}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
@@ -425,8 +432,18 @@ function EjectDialog({
|
||||
</p>
|
||||
<div>
|
||||
<Label>{t('cluster.rootpw_ssh', 'root password (for SSH fallback, optional)')}</Label>
|
||||
<Input type="password" value={rootpw} onChange={(e) => setRootpw(e.target.value)} className="mt-1" />
|
||||
<Input type="password" value={rootpw} onChange={(e) => setRootpw(e.target.value)} className="mt-1" disabled={panelOnly} />
|
||||
</div>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input type="checkbox" checked={panelOnly} onChange={(e) => setPanelOnly(e.target.checked)} className="mt-0.5 h-4 w-4" />
|
||||
<span>
|
||||
{t('cluster.eject_panel_only', 'Only detach the panel entry (do not touch Proxmox)')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('cluster.eject_panel_only_hint',
|
||||
'Use this when the node is not actually in the cluster (e.g. a previous join failed) or is unreachable for pvecm delnode.')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<DialogClose render={<Button variant="outline" />}>{t('common.cancel', 'Cancel')}</DialogClose>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function useJoinCluster() {
|
||||
export function useEjectNode() {
|
||||
const invalidate = useInvalidateCluster();
|
||||
return useMutation({
|
||||
mutationFn: ({ serverId, ...body }: { serverId: number; node_name: string; rootpw?: string; clear_panel_entry?: boolean }) =>
|
||||
mutationFn: ({ serverId, ...body }: { serverId: number; node_name: string; rootpw?: string; clear_panel_entry?: boolean; panel_only?: boolean }) =>
|
||||
apiClient.post<{ success: boolean; ejected_node: string; message: string }>(
|
||||
`/proxmox/api/cluster/${serverId}/eject-node`,
|
||||
body,
|
||||
|
||||
@@ -183,6 +183,8 @@
|
||||
"eject_confirm": "Eject",
|
||||
"eject_via": "via",
|
||||
"rootpw_ssh": "root password (for SSH fallback, optional)",
|
||||
"eject_panel_only": "Only detach the panel entry (do not touch Proxmox)",
|
||||
"eject_panel_only_hint": "Use this when the node is not actually in the cluster (e.g. a previous join failed) or is unreachable for pvecm delnode.",
|
||||
"nodes": "nodes",
|
||||
"standalone": "Standalone servers",
|
||||
"no_standalone": "No standalone servers.",
|
||||
|
||||
@@ -183,6 +183,8 @@
|
||||
"eject_confirm": "Исключить",
|
||||
"eject_via": "через",
|
||||
"rootpw_ssh": "Пароль root (для SSH-fallback, опционально)",
|
||||
"eject_panel_only": "Только отвязать запись в панели (не трогать Proxmox)",
|
||||
"eject_panel_only_hint": "Используйте, если нода на самом деле не в кластере (напр. прошлый join не завершился) или недоступна для pvecm delnode.",
|
||||
"nodes": "нод(ы)",
|
||||
"standalone": "Standalone-серверы",
|
||||
"no_standalone": "Нет standalone-серверов.",
|
||||
|
||||
Reference in New Issue
Block a user