feat(nodes): add High Availability management UI
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 13s

Surface the existing HA backend endpoints (cluster HA status, add/remove
resource) in a new HAResources panel on the node detail page. Lists
HA-managed guests with state/group/restart limits, supports adding a guest
to HA (state, group, max_restart/relocate, comment) and removing it.
Shows a cluster-only notice on standalone nodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:38:21 +05:00
parent 681aa2f303
commit 14aeba41d0
6 changed files with 359 additions and 0 deletions

View File

@@ -0,0 +1,241 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Trash2, ShieldCheck, RefreshCw } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useHAStatus, useAddToHA, useRemoveFromHA } from '@/hooks/use-ha';
import type { VirtualMachine } from '@/types';
import { toast } from 'sonner';
const HA_STATES = ['started', 'stopped', 'enabled', 'disabled', 'ignored'];
interface AddForm {
guest: string; // "vm:100"
state: string;
group: string;
max_restart: string;
max_relocate: string;
comment: string;
}
const emptyAddForm: AddForm = {
guest: '',
state: 'started',
group: '',
max_restart: '1',
max_relocate: '1',
comment: '',
};
export default function HAResources({ serverId, vms }: { serverId: number; vms: VirtualMachine[] }) {
const { t } = useTranslation();
const { data, isLoading, isFetching, refetch } = useHAStatus(serverId);
const addHA = useAddToHA(serverId);
const removeHA = useRemoveFromHA(serverId);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState<AddForm>(emptyAddForm);
const set = <K extends keyof AddForm>(k: K, v: AddForm[K]) => setForm(p => ({ ...p, [k]: v }));
const resources = data?.resources ?? [];
const groups = data?.groups ?? [];
// Guests not yet managed by HA, for the add dialog
const candidates = useMemo(() => {
const inHA = new Set(resources.map(r => r.sid));
return vms
.map(vm => ({ vm, sid: `${vm.type === 'qemu' ? 'vm' : 'ct'}:${vm.vmid}` }))
.filter(({ sid }) => !inHA.has(sid));
}, [vms, resources]);
const handleAdd = () => {
if (!form.guest) { toast.error(t('ha.select_guest')); return; }
const [vmType, vmidStr] = form.guest.split(':');
addHA.mutate(
{
vmType: vmType as 'vm' | 'ct',
vmid: Number(vmidStr),
state: form.state,
group: form.group || undefined,
max_restart: Number(form.max_restart) || 0,
max_relocate: Number(form.max_relocate) || 0,
comment: form.comment || undefined,
},
{
onSuccess: () => { toast.success(t('ha.added')); setDialogOpen(false); setForm(emptyAddForm); },
onError: (err: Error) => toast.error(err.message),
},
);
};
const handleRemove = (sid: string) => {
if (!confirm(`${t('ha.remove_confirm')} "${sid}"?`)) return;
const [vmType, vmidStr] = sid.split(':');
removeHA.mutate(
{ vmType: vmType as 'vm' | 'ct', vmid: Number(vmidStr) },
{
onSuccess: () => toast.success(t('ha.removed')),
onError: (err: Error) => toast.error(err.message),
},
);
};
const nameForSid = (sid: string) => {
const [, vmidStr] = sid.split(':');
const vm = vms.find(v => String(v.vmid) === vmidStr);
return vm?.name;
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="flex items-center gap-2 text-lg">
<ShieldCheck className="h-5 w-5" />
{t('ha.title')}
</CardTitle>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
</Button>
<Button
size="sm"
onClick={() => { setForm(emptyAddForm); setDialogOpen(true); }}
disabled={!data?.is_cluster || candidates.length === 0}
>
<Plus className="mr-1 h-4 w-4" />{t('ha.add')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
) : !data?.is_cluster ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t('ha.cluster_only')}</p>
) : (
<>
{resources.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t('ha.no_resources')}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('ha.resource')}</TableHead>
<TableHead>{t('ha.state')}</TableHead>
<TableHead>{t('ha.group')}</TableHead>
<TableHead>{t('ha.max_restart')}</TableHead>
<TableHead>{t('ha.max_relocate')}</TableHead>
<TableHead>{t('netif.comments')}</TableHead>
<TableHead className="text-right">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{resources.map(r => (
<TableRow key={r.sid}>
<TableCell className="font-mono font-medium">
{r.sid}
{nameForSid(r.sid) && (
<span className="ml-2 text-xs text-muted-foreground">{nameForSid(r.sid)}</span>
)}
</TableCell>
<TableCell>
<Badge variant={r.state === 'started' ? 'default' : 'secondary'}>{r.state || '—'}</Badge>
</TableCell>
<TableCell>{r.group || '—'}</TableCell>
<TableCell>{r.max_restart ?? '—'}</TableCell>
<TableCell>{r.max_relocate ?? '—'}</TableCell>
<TableCell className="max-w-[200px] truncate text-xs text-muted-foreground">{r.comment || '—'}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => handleRemove(r.sid)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{groups.length > 0 && (
<p className="text-xs text-muted-foreground">
{t('ha.groups')}: {groups.map(g => g.group).join(', ')}
</p>
)}
</>
)}
</CardContent>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader><DialogTitle>{t('ha.add')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div>
<Label>{t('ha.resource')}</Label>
<Select value={form.guest} onValueChange={v => { if (v) set('guest', v); }}>
<SelectTrigger className="mt-1"><SelectValue placeholder={t('ha.select_guest')} /></SelectTrigger>
<SelectContent>
{candidates.map(({ vm, sid }) => (
<SelectItem key={sid} value={sid}>
{sid} {vm.name ? `— ${vm.name}` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('ha.state')}</Label>
<Select value={form.state} onValueChange={v => { if (v) set('state', v); }}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{HA_STATES.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('ha.group')}</Label>
{groups.length > 0 ? (
<Select value={form.group || '__none'} onValueChange={v => set('group', v === '__none' ? '' : v)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">{t('ha.no_group')}</SelectItem>
{groups.map(g => <SelectItem key={g.group} value={g.group}>{g.group}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input value={form.group} onChange={e => set('group', e.target.value)} className="mt-1" placeholder={t('ha.no_group')} />
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('ha.max_restart')}</Label>
<Input type="number" min="0" value={form.max_restart} onChange={e => set('max_restart', e.target.value)} className="mt-1" />
</div>
<div>
<Label>{t('ha.max_relocate')}</Label>
<Input type="number" min="0" value={form.max_relocate} onChange={e => set('max_relocate', e.target.value)} className="mt-1" />
</div>
</div>
<div>
<Label>{t('netif.comments')}</Label>
<Input value={form.comment} onChange={e => set('comment', e.target.value)} className="mt-1" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>{t('common.cancel')}</Button>
<Button onClick={handleAdd} disabled={addHA.isPending || !form.guest}>{t('ha.add')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -9,6 +9,7 @@ import { useVirtualMachines } from '@/hooks/use-instances';
import { StatusDot } from '@/components/shared/status-dot';
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
import NodeNetworks from './NodeNetworks';
import HAResources from './HAResources';
export default function NodeDetailPage() {
const { t } = useTranslation();
@@ -66,6 +67,11 @@ export default function NodeDetailPage() {
<NodeNetworks serverId={sid} nodeNames={nodes.map(n => n.node)} />
)}
{/* High Availability */}
{nodes.length > 0 && (
<HAResources serverId={sid} vms={serverVMs} />
)}
{/* VMs on this server */}
<div className="space-y-3">
<h2 className="text-lg font-semibold">{t('nodes.instances_on_server')} ({serverVMs.length})</h2>

View File

@@ -0,0 +1,51 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { HAStatus } from '@/types';
export const haKeys = {
status: (serverId: number) => ['ha-status', serverId] as const,
};
export interface AddToHAArgs {
vmType: 'vm' | 'ct';
vmid: number;
state: string;
group?: string;
max_restart: number;
max_relocate: number;
comment?: string;
}
export function useHAStatus(serverId: number) {
return useQuery({
queryKey: haKeys.status(serverId),
queryFn: () => apiClient.get<HAStatus>(`/proxmox/api/${serverId}/ha/status`),
enabled: serverId > 0,
});
}
export function useAddToHA(serverId: number) {
const qc = useQueryClient();
return useMutation({
// The backend expects these as query params, not a JSON body.
mutationFn: ({ vmType, vmid, state, group, max_restart, max_relocate, comment }: AddToHAArgs) => {
const qs = new URLSearchParams();
qs.set('state', state);
qs.set('max_restart', String(max_restart));
qs.set('max_relocate', String(max_relocate));
if (group) qs.set('group', group);
if (comment) qs.set('comment', comment);
return apiClient.post(`/proxmox/api/${serverId}/ha/${vmType}/${vmid}/add?${qs}`, {});
},
onSuccess: () => qc.invalidateQueries({ queryKey: haKeys.status(serverId) }),
});
}
export function useRemoveFromHA(serverId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ vmType, vmid }: { vmType: 'vm' | 'ct'; vmid: number }) =>
apiClient.delete(`/proxmox/api/${serverId}/ha/${vmType}/${vmid}/remove`),
onSuccess: () => qc.invalidateQueries({ queryKey: haKeys.status(serverId) }),
});
}

View File

@@ -309,6 +309,23 @@
"applied": "Network configuration applied",
"reverted": "Pending changes reverted"
},
"ha": {
"title": "High Availability",
"add": "Add to HA",
"resource": "Resource",
"state": "State",
"group": "Group",
"no_group": "No group",
"groups": "HA groups",
"max_restart": "Max restart",
"max_relocate": "Max relocate",
"cluster_only": "HA is only available in cluster mode.",
"no_resources": "No resources are managed by HA.",
"select_guest": "Select a VM or container",
"remove_confirm": "Remove from HA",
"added": "Added to HA",
"removed": "Removed from HA"
},
"templates": {
"title": "OS Templates",
"search": "Search templates...",

View File

@@ -309,6 +309,23 @@
"applied": "Конфигурация сети применена",
"reverted": "Ожидающие изменения отменены"
},
"ha": {
"title": "Высокая доступность (HA)",
"add": "Добавить в HA",
"resource": "Ресурс",
"state": "Состояние",
"group": "Группа",
"no_group": "Без группы",
"groups": "HA-группы",
"max_restart": "Макс. перезапусков",
"max_relocate": "Макс. перемещений",
"cluster_only": "HA доступен только в режиме кластера.",
"no_resources": "Нет ресурсов под управлением HA.",
"select_guest": "Выберите VM или контейнер",
"remove_confirm": "Удалить из HA",
"added": "Добавлено в HA",
"removed": "Удалено из HA"
},
"templates": {
"title": "Шаблоны ОС",
"search": "Поиск шаблонов...",

View File

@@ -206,6 +206,33 @@ export interface NodeNetworkInterface {
ipam_name?: string;
}
export interface HAResource {
sid: string; // "vm:100" or "ct:101"
type?: string; // "vm" | "ct"
state?: string; // started, stopped, disabled, ignored
group?: string;
max_restart?: number;
max_relocate?: number;
comment?: string;
digest?: string;
}
export interface HAGroup {
group: string;
nodes?: string;
nofailback?: number;
restricted?: number;
comment?: string;
}
export interface HAStatus {
server_id: number;
is_cluster: boolean;
ha_enabled: boolean;
resources: HAResource[];
groups: HAGroup[];
}
// ==================== IPAM Types ====================
export interface IPAMNetwork {