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:
556
frontend/src/features/cluster/ClusterPage.tsx
Normal file
556
frontend/src/features/cluster/ClusterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
frontend/src/hooks/use-cluster.ts
Normal file
130
frontend/src/hooks/use-cluster.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Имя пользователя",
|
||||
|
||||
@@ -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> },
|
||||
|
||||
Reference in New Issue
Block a user