feat(cluster): режим eject "только панель" для нод, не входящих в кластер
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 56s
Deploy to Production / Trigger production update (push) Successful in 7s

При провале 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:
markmorado
2026-07-09 03:36:55 +05:00
parent c88cdb4e26
commit 3424823793
5 changed files with 59 additions and 5 deletions

View File

@@ -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)

View File

@@ -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>

View File

@@ -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,

View File

@@ -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.",

View File

@@ -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-серверов.",