feat(frontend): cluster management page (create / join wizard / eject)

Surface the existing backend cluster API in the UI:
- New /cluster page with topology view (clusters + standalone servers),
  per-node online status, and a 2-node quorum warning.
- Create cluster dialog (node, name with Proxmox naming rules, optional
  corosync link0, optional root password).
- 3-step Join wizard: pick joining + target node → pre-join-check with
  backup-and-clear (prepare-join) for nodes that still hold guests →
  confirm + join with the node root password.
- Eject node dialog (with SSH-fallback root password).
- hooks/use-cluster.ts: typed useClusterTopology, useCreateCluster,
  usePreJoinCheck, usePrepareJoin, useJoinCluster, useEjectNode.
- Nav item + route + RU/EN i18n strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 07:16:48 +05:00
parent 0e85d56c99
commit 8659ba5a92
6 changed files with 773 additions and 0 deletions

View File

@@ -0,0 +1,556 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Network, Server, Plus, Loader2, LogOut, AlertTriangle, Info,
ArrowRight, ArrowLeft, ShieldAlert, Boxes,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { StatusDot } from '@/components/shared/status-dot';
import { formatBytes } from '@/lib/format';
import {
useClusterTopology, useCreateCluster, usePreJoinCheck, usePrepareJoin,
useJoinCluster, useEjectNode,
} from '@/hooks/use-cluster';
import type { ClusterServerEntry, PreJoinCheck } from '@/hooks/use-cluster';
import { toast } from 'sonner';
// ==================== Create Cluster Dialog ====================
function CreateClusterDialog({
open, onOpenChange, standalone,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
standalone: ClusterServerEntry[];
}) {
const { t } = useTranslation();
const createCluster = useCreateCluster();
const [serverId, setServerId] = useState<string>('');
const [clusterName, setClusterName] = useState('');
const [link0, setLink0] = useState('');
const [rootpw, setRootpw] = useState('');
const reset = () => { setServerId(''); setClusterName(''); setLink0(''); setRootpw(''); };
const nameValid = /^[a-zA-Z][a-zA-Z0-9-]{0,14}$/.test(clusterName);
const handleSubmit = () => {
createCluster.mutate(
{
server_id: Number(serverId),
cluster_name: clusterName.trim(),
link0_ip: link0.trim() || undefined,
rootpw: rootpw.trim() || undefined,
},
{
onSuccess: (res) => {
toast.success(res.message || t('cluster.create_started', 'Cluster creation started'));
reset();
onOpenChange(false);
},
onError: (err) => toast.error(err.message),
},
);
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) reset(); onOpenChange(v); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader><DialogTitle>{t('cluster.create_title', 'Create cluster')}</DialogTitle></DialogHeader>
<div className="space-y-4">
<div>
<Label>{t('cluster.select_node', 'Standalone server')}</Label>
<Select value={serverId} onValueChange={(v) => { if (v !== null) setServerId(v); }}>
<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>
<div>
<Label>{t('cluster.cluster_name', 'Cluster name')}</Label>
<Input
value={clusterName}
onChange={(e) => setClusterName(e.target.value)}
className="mt-1"
placeholder="my-cluster"
/>
{clusterName && !nameValid && (
<p className="mt-1 text-xs text-destructive">
{t('cluster.name_rule', 'Starts with a letter; letters, digits, hyphens; max 15 chars.')}
</p>
)}
</div>
<div>
<Label>{t('cluster.link0', 'Corosync link0 IP (optional)')}</Label>
<Input value={link0} onChange={(e) => setLink0(e.target.value)} className="mt-1" placeholder="10.10.10.1" />
<p className="mt-1 text-xs text-muted-foreground">
{t('cluster.link0_hint', 'Dedicated network for corosync is recommended in production.')}
</p>
</div>
<div>
<Label>{t('cluster.rootpw_optional', 'root@pam password (if not saved on server)')}</Label>
<Input type="password" value={rootpw} onChange={(e) => setRootpw(e.target.value)} className="mt-1" />
</div>
</div>
<DialogFooter className="gap-2">
<DialogClose render={<Button variant="outline" />}>{t('common.cancel', 'Cancel')}</DialogClose>
<Button onClick={handleSubmit} disabled={createCluster.isPending || !serverId || !nameValid}>
{createCluster.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('cluster.create_btn', 'Create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ==================== Join Wizard ====================
function JoinWizard({
open, onOpenChange, standalone, clusterMembers,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
standalone: ClusterServerEntry[];
clusterMembers: ClusterServerEntry[];
}) {
const { t } = useTranslation();
const preJoinCheck = usePreJoinCheck();
const prepareJoin = usePrepareJoin();
const joinCluster = useJoinCluster();
const [step, setStep] = useState<1 | 2 | 3>(1);
const [nodeServerId, setNodeServerId] = useState('');
const [clusterServerId, setClusterServerId] = useState('');
const [check, setCheck] = useState<PreJoinCheck | null>(null);
const [backupStorage, setBackupStorage] = useState('');
const [prepared, setPrepared] = useState(false);
const [rootpw, setRootpw] = useState('');
const [link0, setLink0] = useState('');
const [confirmed, setConfirmed] = useState(false);
const reset = () => {
setStep(1); setNodeServerId(''); setClusterServerId(''); setCheck(null);
setBackupStorage(''); setPrepared(false); setRootpw(''); setLink0(''); setConfirmed(false);
};
const close = (v: boolean) => { if (!v) reset(); onOpenChange(v); };
const runCheck = () => {
preJoinCheck.mutate(Number(nodeServerId), {
onSuccess: (data) => { setCheck(data); setPrepared(data.ready_for_join); setStep(2); },
onError: (err) => toast.error(err.message),
});
};
const runPrepare = () => {
prepareJoin.mutate(
{ node_server_id: Number(nodeServerId), backup_storage: backupStorage },
{
onSuccess: (res) => {
if (res.ready_for_join) {
toast.success(res.message);
setPrepared(true);
} else {
toast.error(res.message);
}
},
onError: (err) => toast.error(err.message),
},
);
};
const runJoin = () => {
joinCluster.mutate(
{
node_server_id: Number(nodeServerId),
cluster_server_id: Number(clusterServerId),
rootpw: rootpw.trim(),
link0: link0.trim() || undefined,
},
{
onSuccess: (res) => {
toast.success(res.message || t('cluster.join_started', 'Join started'));
reset();
onOpenChange(false);
},
onError: (err) => toast.error(err.message),
},
);
};
const joiningServer = standalone.find((s) => s.id === Number(nodeServerId));
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{t('cluster.join_title', 'Join node to cluster')} — {t('cluster.step', 'Step')} {step}/3
</DialogTitle>
</DialogHeader>
{/* Step 1: choose nodes */}
{step === 1 && (
<div className="space-y-4">
<div>
<Label>{t('cluster.joining_node', 'Node to join (standalone)')}</Label>
<Select value={nodeServerId} onValueChange={(v) => { if (v !== null) setNodeServerId(v); }}>
<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>
<div>
<Label>{t('cluster.target_cluster', 'Target cluster node')}</Label>
<Select value={clusterServerId} onValueChange={(v) => { if (v !== null) setClusterServerId(v); }}>
<SelectTrigger className="mt-1"><SelectValue placeholder={t('cluster.select_cluster', 'Select cluster node')} /></SelectTrigger>
<SelectContent>
{clusterMembers.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name} — {s.cluster_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter className="gap-2">
<DialogClose render={<Button variant="outline" />}>{t('common.cancel', 'Cancel')}</DialogClose>
<Button
onClick={runCheck}
disabled={!nodeServerId || !clusterServerId || nodeServerId === clusterServerId || preJoinCheck.isPending}
>
{preJoinCheck.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('common.next', 'Next')} <ArrowRight className="ml-1 h-4 w-4" />
</Button>
</DialogFooter>
</div>
)}
{/* Step 2: pre-join check / prepare */}
{step === 2 && check && (
<div className="space-y-4">
{check.guests_count === 0 ? (
<div className="flex items-start gap-2 rounded-md bg-green-500/10 p-3 text-sm">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-green-500" />
<span>{t('cluster.node_clean', 'Node has no VMs/containers — ready to join.')}</span>
</div>
) : (
<>
<div className="flex items-start gap-2 rounded-md bg-amber-500/10 p-3 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<span>
{t('cluster.guests_warning',
'Joining a cluster overwrites /etc/pve — all VMs/containers on this node will be lost. They must be backed up and deleted first.')}
</span>
</div>
<div className="max-h-40 overflow-y-auto rounded-md border">
{check.guests.map((g) => (
<div key={`${g.type}-${g.vmid}`} className="flex items-center justify-between border-b px-3 py-1.5 text-sm last:border-b-0">
<span className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px]">{g.type === 'qemu' ? 'VM' : 'LXC'}</Badge>
{g.name} <span className="text-muted-foreground">#{g.vmid}</span>
</span>
<StatusDot status={g.status} />
</div>
))}
</div>
<div>
<Label>{t('cluster.backup_storage', 'Backup storage')}</Label>
<Select value={backupStorage} onValueChange={(v) => { if (v !== null) setBackupStorage(v); }} disabled={prepared}>
<SelectTrigger className="mt-1"><SelectValue placeholder={t('cluster.select_storage', 'Select storage')} /></SelectTrigger>
<SelectContent>
{check.backup_storages.map((s) => (
<SelectItem key={s.storage} value={s.storage}>
{s.storage} ({s.type}{s.avail ? `, ${formatBytes(s.avail)} ${t('cluster.free', 'free')}` : ''})
</SelectItem>
))}
</SelectContent>
</Select>
{check.backup_storages.length === 0 && (
<p className="mt-1 text-xs text-destructive">
{t('cluster.no_backup_storage', 'No backup-capable storage found on this node.')}
</p>
)}
</div>
{!prepared && (
<Button
variant="destructive"
className="w-full"
onClick={runPrepare}
disabled={!backupStorage || prepareJoin.isPending}
>
{prepareJoin.isPending
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />{t('cluster.preparing', 'Backing up & deleting… (may take minutes)')}</>
: t('cluster.prepare_btn', 'Back up & clear node')}
</Button>
)}
{prepared && (
<div className="flex items-center gap-2 rounded-md bg-green-500/10 p-3 text-sm">
<Info className="h-4 w-4 text-green-500" />
{t('cluster.node_ready', 'Node cleared and ready to join.')}
</div>
)}
</>
)}
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setStep(1)}>
<ArrowLeft className="mr-1 h-4 w-4" />{t('common.back', 'Back')}
</Button>
<Button onClick={() => setStep(3)} disabled={!prepared}>
{t('common.next', 'Next')} <ArrowRight className="ml-1 h-4 w-4" />
</Button>
</DialogFooter>
</div>
)}
{/* Step 3: confirm + join */}
{step === 3 && (
<div className="space-y-4">
<div className="flex items-start gap-2 rounded-md bg-destructive/10 p-3 text-sm">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<span>
{t('cluster.join_final_warning',
'The node will be joined to the cluster and its Proxmox interface will restart for a moment. This cannot be undone.')}
</span>
</div>
<div>
<Label>{t('cluster.node_rootpw', 'root@pam password of the joining node')}</Label>
<Input type="password" value={rootpw} onChange={(e) => setRootpw(e.target.value)} className="mt-1" autoFocus />
</div>
<div>
<Label>{t('cluster.link0', 'Corosync link0 IP (optional)')}</Label>
<Input value={link0} onChange={(e) => setLink0(e.target.value)} className="mt-1" placeholder={joiningServer?.ip_address} />
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} className="h-4 w-4" />
{t('cluster.confirm_understand', 'I understand this is irreversible')}
</label>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setStep(2)}>
<ArrowLeft className="mr-1 h-4 w-4" />{t('common.back', 'Back')}
</Button>
<Button onClick={runJoin} disabled={!rootpw || !confirmed || joinCluster.isPending}>
{joinCluster.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('cluster.join_btn', 'Join cluster')}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}
// ==================== Eject Dialog ====================
function EjectDialog({
open, onOpenChange, viaServer, node,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
viaServer: ClusterServerEntry | null;
node: ClusterServerEntry | null;
}) {
const { t } = useTranslation();
const ejectNode = useEjectNode();
const [rootpw, setRootpw] = useState('');
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); },
onError: (err) => toast.error(err.message),
},
);
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) setRootpw(''); 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">
<div className="flex items-start gap-2 rounded-md bg-destructive/10 p-3 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<span>
{t('cluster.eject_warning',
'The node must be powered OFF before ejecting, and must not rejoin the same cluster network afterwards.')}
</span>
</div>
<p className="text-sm">
{t('cluster.eject_confirm', 'Eject')} <b>{node?.name}</b> ({node?.hostname}){' '}
{t('cluster.eject_via', 'via')} <b>{viaServer?.name}</b>?
</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" />
</div>
</div>
<DialogFooter className="gap-2">
<DialogClose render={<Button variant="outline" />}>{t('common.cancel', 'Cancel')}</DialogClose>
<Button variant="destructive" onClick={handleEject} disabled={ejectNode.isPending}>
{ejectNode.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('cluster.eject_btn', 'Eject')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ==================== Page ====================
export default function ClusterPage() {
const { t } = useTranslation();
const { data: topology, isLoading } = useClusterTopology();
const [createOpen, setCreateOpen] = useState(false);
const [joinOpen, setJoinOpen] = useState(false);
const [ejectState, setEjectState] = useState<{ via: ClusterServerEntry; node: ClusterServerEntry } | null>(null);
const standalone = topology?.standalone ?? [];
const clusters = topology?.clusters ?? {};
const clusterMembers = useMemo(() => Object.values(clusters).flat(), [clusters]);
const hasClusters = Object.keys(clusters).length > 0;
if (isLoading) {
return <div className="flex items-center justify-center py-20 text-muted-foreground">{t('common.loading', 'Loading…')}</div>;
}
return (
<div className="space-y-6">
<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={() => setJoinOpen(true)} disabled={!hasClusters || standalone.length === 0}>
<Plus className="mr-1 h-4 w-4" />{t('cluster.join_node', 'Join node')}
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)} disabled={standalone.length === 0}>
<Plus className="mr-1 h-4 w-4" />{t('cluster.create_btn', 'Create cluster')}
</Button>
</div>
</div>
{/* Clusters */}
{Object.entries(clusters).map(([name, members]) => (
<Card key={name}>
<CardContent className="p-5 space-y-3">
<div className="flex items-center gap-2">
<Network className="h-5 w-5 text-primary" />
<h2 className="font-semibold">{name}</h2>
<Badge variant="secondary">{members.length} {t('cluster.nodes', 'nodes')}</Badge>
</div>
{members.length === 2 && (
<div className="flex items-start gap-2 rounded-md bg-amber-500/10 p-2.5 text-xs">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<span>{t('cluster.two_node_quorum', 'A 2-node cluster loses quorum if one node fails. 3+ nodes or a QDevice is recommended for production.')}</span>
</div>
)}
<div className="divide-y rounded-md border">
{members.map((m) => (
<div key={m.id} className="flex items-center justify-between px-3 py-2.5">
<div className="flex items-center gap-3 min-w-0">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted shrink-0">
<Server className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{m.name}</p>
<p className="text-xs text-muted-foreground font-mono truncate">{m.ip_address}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<StatusDot status={m.is_online ? 'online' : 'offline'} />
{m.is_online ? t('common.online', 'Online') : t('common.offline', 'Offline')}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
title={t('cluster.eject_title', 'Eject node')}
onClick={() => setEjectState({ via: members.find((x) => x.id !== m.id) ?? m, node: m })}
disabled={members.length < 2}
>
<LogOut className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
))}
{/* Standalone servers */}
<Card>
<CardContent className="p-5 space-y-3">
<div className="flex items-center gap-2">
<Boxes className="h-5 w-5 text-muted-foreground" />
<h2 className="font-semibold">{t('cluster.standalone', 'Standalone servers')}</h2>
<Badge variant="outline">{standalone.length}</Badge>
</div>
{standalone.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('cluster.no_standalone', 'No standalone servers.')}</p>
) : (
<div className="divide-y rounded-md border">
{standalone.map((s) => (
<div key={s.id} className="flex items-center justify-between px-3 py-2.5">
<div className="flex items-center gap-3 min-w-0">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted shrink-0">
<Server className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{s.name}</p>
<p className="text-xs text-muted-foreground font-mono truncate">{s.ip_address}</p>
</div>
</div>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<StatusDot status={s.is_online ? 'online' : 'offline'} />
{s.is_online ? t('common.online', 'Online') : t('common.offline', 'Offline')}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
<CreateClusterDialog open={createOpen} onOpenChange={setCreateOpen} standalone={standalone} />
<JoinWizard open={joinOpen} onOpenChange={setJoinOpen} standalone={standalone} clusterMembers={clusterMembers} />
<EjectDialog
open={ejectState !== null}
onOpenChange={(v) => { if (!v) setEjectState(null); }}
viaServer={ejectState?.via ?? null}
node={ejectState?.node ?? null}
/>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { nodeKeys } from './use-nodes';
import { vmKeys } from './use-instances';
// ==================== Types ====================
export interface ClusterServerEntry {
id: number;
name: string;
hostname: string;
ip_address: string;
port: number;
is_online: boolean;
cluster_name: string | null;
}
export interface ClusterTopology {
clusters: Record<string, ClusterServerEntry[]>;
standalone: ClusterServerEntry[];
}
export interface PreJoinGuest {
vmid: number;
name: string;
status: string;
type: 'qemu' | 'lxc';
maxdisk: number;
mem: number;
}
export interface PreJoinStorage {
storage: string;
type: string;
avail: number;
total: number;
}
export interface PreJoinCheck {
server_id: number;
server_name: string;
node_name: string;
guests_count: number;
guests: PreJoinGuest[];
backup_storages: PreJoinStorage[];
ready_for_join: boolean;
warning: string | null;
}
export interface PrepareJoinResult {
backed_up: number[];
deleted: number[];
errors: { vmid: number; phase: string; error: string }[];
ready_for_join: boolean;
message: string;
}
export interface ClusterActionResult {
success: boolean;
cluster_name?: string;
upid?: string;
message: string;
}
// ==================== Queries ====================
export function useClusterTopology() {
return useQuery({
queryKey: nodeKeys.topology,
queryFn: () => apiClient.get<ClusterTopology>('/proxmox/api/cluster/topology'),
refetchInterval: 30000,
});
}
// ==================== Mutations ====================
function useInvalidateCluster() {
const qc = useQueryClient();
return () => {
qc.invalidateQueries({ queryKey: nodeKeys.topology });
qc.invalidateQueries({ queryKey: nodeKeys.servers });
qc.invalidateQueries({ queryKey: vmKeys.all });
};
}
export function useCreateCluster() {
const invalidate = useInvalidateCluster();
return useMutation({
mutationFn: (body: { server_id: number; cluster_name: string; link0_ip?: string; rootpw?: string }) =>
apiClient.post<ClusterActionResult>('/proxmox/api/cluster/create', body),
onSuccess: () => invalidate(),
});
}
/** On-demand pre-join inspection of a standalone node (lists guests + backup storages). */
export function usePreJoinCheck() {
return useMutation({
mutationFn: (serverId: number) =>
apiClient.get<PreJoinCheck>(`/proxmox/api/cluster/${serverId}/pre-join-check`),
});
}
/** Back up and delete all guests on a node so it can join a cluster. Long-running. */
export function usePrepareJoin() {
return useMutation({
mutationFn: (body: { node_server_id: number; backup_storage: string; compress?: string }) =>
apiClient.post<PrepareJoinResult>('/proxmox/api/cluster/prepare-join', body),
});
}
export function useJoinCluster() {
const invalidate = useInvalidateCluster();
return useMutation({
mutationFn: (body: { node_server_id: number; cluster_server_id: number; rootpw: string; link0?: string }) =>
apiClient.post<ClusterActionResult>('/proxmox/api/cluster/join', body),
onSuccess: () => invalidate(),
});
}
export function useEjectNode() {
const invalidate = useInvalidateCluster();
return useMutation({
mutationFn: ({ serverId, ...body }: { serverId: number; node_name: string; rootpw?: string; clear_panel_entry?: boolean }) =>
apiClient.post<{ success: boolean; ejected_node: string; message: string }>(
`/proxmox/api/cluster/${serverId}/eject-node`,
body,
),
onSuccess: () => invalidate(),
});
}

View File

@@ -59,6 +59,7 @@ const navGroups: NavGroup[] = [
{ label: 'Dashboard', icon: LayoutDashboard, path: '/dashboard' },
{ label: 'Instances', icon: Monitor, path: '/instances' },
{ label: 'Nodes', icon: Server, path: '/nodes' },
{ label: 'Cluster', icon: Network, path: '/cluster' },
{ label: 'Templates', icon: HardDrive, path: '/templates' },
{ label: 'Backups', icon: Archive, path: '/backups' },
{ label: 'Tasks', icon: ClipboardList, path: '/tasks' },

View File

@@ -6,6 +6,7 @@
"dashboard": "Dashboard",
"instances": "Instances",
"nodes": "Nodes",
"cluster": "Cluster",
"templates": "Templates",
"backups": "Backups",
"tasks": "Tasks",
@@ -16,6 +17,47 @@
"logs": "Logs",
"settings": "Settings"
},
"cluster": {
"create_title": "Create cluster",
"create_btn": "Create cluster",
"create_started": "Cluster creation started",
"select_node": "Select server",
"cluster_name": "Cluster name",
"name_rule": "Starts with a letter; letters, digits, hyphens; max 15 chars.",
"link0": "Corosync link0 IP (optional)",
"link0_hint": "A dedicated network for corosync is recommended in production.",
"rootpw_optional": "root@pam password (if not saved on server)",
"join_node": "Join node",
"join_title": "Join node to cluster",
"join_started": "Join started",
"join_btn": "Join cluster",
"step": "Step",
"joining_node": "Node to join (standalone)",
"target_cluster": "Target cluster node",
"select_cluster": "Select cluster node",
"node_clean": "Node has no VMs/containers — ready to join.",
"guests_warning": "Joining a cluster overwrites /etc/pve — all VMs/containers on this node will be lost. They must be backed up and deleted first.",
"backup_storage": "Backup storage",
"select_storage": "Select storage",
"free": "free",
"no_backup_storage": "No backup-capable storage found on this node.",
"prepare_btn": "Back up & clear node",
"preparing": "Backing up & deleting… (may take minutes)",
"node_ready": "Node cleared and ready to join.",
"join_final_warning": "The node will be joined to the cluster and its Proxmox interface will restart for a moment. This cannot be undone.",
"node_rootpw": "root@pam password of the joining node",
"confirm_understand": "I understand this is irreversible",
"eject_title": "Eject node from cluster",
"eject_btn": "Eject",
"eject_warning": "The node must be powered OFF before ejecting, and must not rejoin the same cluster network afterwards.",
"eject_confirm": "Eject",
"eject_via": "via",
"rootpw_ssh": "root password (for SSH fallback, optional)",
"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."
},
"login": {
"title": "Sign in to your account",
"username": "Username",

View File

@@ -6,6 +6,7 @@
"dashboard": "Дашборд",
"instances": "Инстансы",
"nodes": "Узлы",
"cluster": "Кластер",
"templates": "Шаблоны",
"backups": "Бэкапы",
"tasks": "Задачи",
@@ -16,6 +17,47 @@
"logs": "Логи",
"settings": "Настройки"
},
"cluster": {
"create_title": "Создать кластер",
"create_btn": "Создать кластер",
"create_started": "Создание кластера запущено",
"select_node": "Выберите сервер",
"cluster_name": "Имя кластера",
"name_rule": "Начинается с буквы; буквы, цифры, дефисы; макс 15 символов.",
"link0": "IP для corosync link0 (опционально)",
"link0_hint": "Для production рекомендуется выделенная сеть для corosync.",
"rootpw_optional": "Пароль root@pam (если не сохранён на сервере)",
"join_node": "Присоединить ноду",
"join_title": "Присоединение ноды к кластеру",
"join_started": "Присоединение запущено",
"join_btn": "Присоединить к кластеру",
"step": "Шаг",
"joining_node": "Присоединяемая нода (standalone)",
"target_cluster": "Целевая нода кластера",
"select_cluster": "Выберите ноду кластера",
"node_clean": "На ноде нет VM/контейнеров — готова к присоединению.",
"guests_warning": "Присоединение к кластеру перезапишет /etc/pve — все VM/контейнеры на этой ноде будут потеряны. Сначала их нужно забэкапить и удалить.",
"backup_storage": "Хранилище для бэкапа",
"select_storage": "Выберите хранилище",
"free": "свободно",
"no_backup_storage": "На ноде не найдено хранилище с поддержкой бэкапов.",
"prepare_btn": "Забэкапить и очистить ноду",
"preparing": "Бэкап и удаление… (может занять минуты)",
"node_ready": "Нода очищена и готова к присоединению.",
"join_final_warning": "Нода будет присоединена к кластеру, её интерфейс Proxmox на минуту перезапустится. Действие необратимо.",
"node_rootpw": "Пароль root@pam присоединяемой ноды",
"confirm_understand": "Я понимаю, что действие необратимо",
"eject_title": "Исключить ноду из кластера",
"eject_btn": "Исключить",
"eject_warning": "Перед исключением нода должна быть ВЫКЛЮЧЕНА и не должна снова подключаться к той же кластерной сети.",
"eject_confirm": "Исключить",
"eject_via": "через",
"rootpw_ssh": "Пароль root (для SSH-fallback, опционально)",
"nodes": "нод(ы)",
"standalone": "Standalone-серверы",
"no_standalone": "Нет standalone-серверов.",
"two_node_quorum": "Кластер из 2 нод теряет кворум при падении одной. Для production рекомендуется 3+ ноды или QDevice."
},
"login": {
"title": "Вход в аккаунт",
"username": "Имя пользователя",

View File

@@ -16,6 +16,7 @@ import ConsolePage from '@/features/console/ConsolePage';
const CreateInstanceWizard = lazy(() => import('./features/instances/CreateInstanceWizard.tsx'));
const NodesPage = lazy(() => import('./features/nodes/NodesPage.tsx'));
const NodeDetailPage = lazy(() => import('./features/nodes/NodeDetailPage.tsx'));
const ClusterPage = lazy(() => import('./features/cluster/ClusterPage.tsx'));
const TemplatesPage = lazy(() => import('./features/templates/TemplatesPage.tsx'));
const BackupsPage = lazy(() => import('./features/backups/BackupsPage.tsx'));
const TasksPage = lazy(() => import('./features/tasks/TasksPage.tsx'));
@@ -68,6 +69,7 @@ export const router = createBrowserRouter([
{ path: '/instances/:serverId/:vmid', element: <InstanceDetailPage /> },
{ path: '/nodes', element: <SuspenseWrap><NodesPage /></SuspenseWrap> },
{ path: '/nodes/:serverId', element: <SuspenseWrap><NodeDetailPage /></SuspenseWrap> },
{ path: '/cluster', element: <SuspenseWrap><ClusterPage /></SuspenseWrap> },
{ path: '/templates', element: <SuspenseWrap><TemplatesPage /></SuspenseWrap> },
{ path: '/backups', element: <SuspenseWrap><BackupsPage /></SuspenseWrap> },
{ path: '/tasks', element: <SuspenseWrap><TasksPage /></SuspenseWrap> },