feat(nodes): add server auto-setup and cluster-info badge

Add an 'Auto-setup' dialog that onboards a Proxmox server by password and
lets the backend auto-create the API token (adds all nodes for a cluster).
Show a cluster/standalone badge with node count on the node detail page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 00:30:44 +05:00
parent 5d6acd619e
commit bb5d30ab68
5 changed files with 137 additions and 11 deletions

View File

@@ -4,7 +4,7 @@ import { ArrowLeft, Cpu, MemoryStick, HardDrive, Clock } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useServer, useNodes } from '@/hooks/use-nodes';
import { useServer, useNodes, useClusterInfo } from '@/hooks/use-nodes';
import { useVirtualMachines } from '@/hooks/use-instances';
import { StatusDot } from '@/components/shared/status-dot';
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
@@ -18,6 +18,7 @@ export default function NodeDetailPage() {
const sid = Number(serverId);
const { data: server } = useServer(sid);
const { data: nodesData } = useNodes(sid);
const { data: clusterInfo } = useClusterInfo(sid);
const { data: allVMs = [] } = useVirtualMachines();
const nodes = nodesData?.nodes || [];
@@ -33,9 +34,18 @@ export default function NodeDetailPage() {
<h1 className="text-2xl font-bold">{server?.name || `Server #${sid}`}</h1>
<p className="text-sm text-muted-foreground font-mono">{server?.ip_address}:{server?.port}</p>
</div>
<Badge variant={server?.is_online ? 'default' : 'destructive'} className="ml-auto">
{server?.is_online ? t('common.online') : t('common.offline')}
</Badge>
<div className="ml-auto flex items-center gap-2">
{clusterInfo && (
<Badge variant={clusterInfo.is_cluster ? 'default' : 'secondary'}>
{clusterInfo.is_cluster
? `${t('nodes.cluster_mode')} · ${clusterInfo.node_count ?? clusterInfo.nodes?.length ?? 0}`
: t('nodes.standalone')}
</Badge>
)}
<Badge variant={server?.is_online ? 'default' : 'destructive'}>
{server?.is_online ? t('common.online') : t('common.offline')}
</Badge>
</div>
</div>
{/* Nodes in cluster */}

View File

@@ -1,14 +1,14 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Server, Monitor, Container, Plus, Pencil, Trash2, Wifi, Loader2 } from 'lucide-react';
import { Server, Monitor, Container, Plus, Pencil, Trash2, Wifi, Loader2, Zap } 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 { useServers, useCreateServer, useUpdateServer, useDeleteServer, useTestServerCredentials } from '@/hooks/use-nodes';
import { useServers, useCreateServer, useUpdateServer, useDeleteServer, useTestServerCredentials, useAutoSetupServer } from '@/hooks/use-nodes';
import { useVirtualMachines } from '@/hooks/use-instances';
import type { ProxmoxServerCreate } from '@/types';
import { toast } from 'sonner';
@@ -206,6 +206,7 @@ export default function NodesPage() {
const deleteServer = useDeleteServer();
const [createOpen, setCreateOpen] = useState(false);
const [autoOpen, setAutoOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [editServerId, setEditServerId] = useState<number | null>(null);
const [editInitial, setEditInitial] = useState<ServerFormData>(emptyForm);
@@ -240,9 +241,14 @@ export default function NodesPage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('nav.nodes')}</h1>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" />{t('nodes.add_server')}
</Button>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setAutoOpen(true)}>
<Zap className="h-4 w-4 mr-1" />{t('nodes.auto_setup')}
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" />{t('nodes.add_server')}
</Button>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -351,6 +357,57 @@ export default function NodesPage() {
isPending={updateServer.isPending}
serverId={editServerId ?? undefined}
/>
<AutoSetupDialog open={autoOpen} onOpenChange={setAutoOpen} />
</div>
);
}
function AutoSetupDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
const { t } = useTranslation();
const autoSetup = useAutoSetupServer();
const [form, setForm] = useState({ name: '', ip_address: '', port: '8006', api_user: 'root@pam', password: '', verify_ssl: false, description: '' });
const set = (k: keyof typeof form, v: string | boolean) => setForm(p => ({ ...p, [k]: v }));
useEffect(() => { if (open) setForm({ name: '', ip_address: '', port: '8006', api_user: 'root@pam', password: '', verify_ssl: false, description: '' }); }, [open]);
const submit = () => {
if (!form.name || !form.ip_address || !form.password) { toast.error(t('nodes.auto_fields_required')); return; }
autoSetup.mutate(
{ name: form.name, hostname: form.ip_address, ip_address: form.ip_address, port: Number(form.port) || 8006, api_user: form.api_user || 'root@pam', password: form.password, verify_ssl: form.verify_ssl, description: form.description || undefined },
{
onSuccess: (r) => { toast.success(r.message || t('common.save')); onOpenChange(false); },
onError: (e: Error) => toast.error(e.message),
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>{t('nodes.auto_setup')}</DialogTitle></DialogHeader>
<p className="text-xs text-muted-foreground">{t('nodes.auto_setup_hint')}</p>
<div className="space-y-3">
<div><Label>{t('common.name')}</Label><Input value={form.name} onChange={e => set('name', e.target.value)} className="mt-1" /></div>
<div className="grid grid-cols-2 gap-3">
<div><Label>{t('nodes.hostname')}</Label><Input value={form.ip_address} onChange={e => set('ip_address', e.target.value)} className="mt-1" placeholder="192.168.1.100" /></div>
<div><Label>{t('nodes.port')}</Label><Input type="number" value={form.port} onChange={e => set('port', e.target.value)} className="mt-1" /></div>
<div><Label>{t('nodes.api_user')}</Label><Input value={form.api_user} onChange={e => set('api_user', e.target.value)} className="mt-1" /></div>
<div><Label>{t('wizard.password')}</Label><Input type="password" value={form.password} onChange={e => set('password', e.target.value)} className="mt-1" /></div>
</div>
<div className="flex items-center gap-2">
<input id="auto_verify_ssl" type="checkbox" checked={form.verify_ssl} onChange={e => set('verify_ssl', e.target.checked)} className="h-4 w-4" />
<Label htmlFor="auto_verify_ssl" className="cursor-pointer">{t('nodes.verify_ssl')}</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={autoSetup.isPending}>
{autoSetup.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('nodes.auto_setup')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -129,6 +129,55 @@ export function useTestServerCredentials() {
});
}
export interface ClusterInfo {
server_id: number;
server_name: string;
is_cluster: boolean;
node_count?: number;
nodes?: string[];
error?: string;
}
export function useClusterInfo(serverId: number) {
return useQuery({
queryKey: nodeKeys.clusterInfo(serverId),
queryFn: () => apiClient.get<ClusterInfo>(`/proxmox/api/servers/${serverId}/cluster-info`),
enabled: serverId > 0,
});
}
export interface AutoSetupArgs {
name: string;
hostname?: string;
ip_address: string;
port?: number;
api_user?: string;
password: string;
verify_ssl?: boolean;
description?: string;
}
export interface AutoSetupResult {
cluster?: boolean;
nodes_count?: number;
servers?: Array<{ name: string; node: string; ip: string; status: string }>;
message?: string;
}
// Add a server by password — the backend auto-creates an API token.
export function useAutoSetupServer() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: AutoSetupArgs) => apiClient.post<AutoSetupResult>('/proxmox/api/servers/auto-setup', data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: nodeKeys.servers });
qc.invalidateQueries({ queryKey: nodeKeys.topology });
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
},
});
}
// ==================== Node network interfaces ====================
export function useNodeNetworks(serverId: number, node: string) {

View File

@@ -299,7 +299,12 @@
"use_password": "Use password auth",
"verify_ssl": "Verify SSL",
"connection_ok": "Connection successful",
"connection_failed": "Connection failed"
"connection_failed": "Connection failed",
"auto_setup": "Auto-setup",
"auto_setup_hint": "Connect by password — an API token is created automatically. For a cluster, all nodes are added.",
"auto_fields_required": "Name, host and password are required",
"cluster_mode": "Cluster",
"standalone": "Standalone"
},
"netif": {
"title": "Network Interfaces",

View File

@@ -299,7 +299,12 @@
"use_password": "Использовать аутентификацию по паролю",
"verify_ssl": "Проверять SSL",
"connection_ok": "Соединение успешно",
"connection_failed": "Ошибка соединения"
"connection_failed": "Ошибка соединения",
"auto_setup": "Авто-настройка",
"auto_setup_hint": "Подключение по паролю — API-токен создаётся автоматически. Для кластера добавятся все ноды.",
"auto_fields_required": "Нужны имя, хост и пароль",
"cluster_mode": "Кластер",
"standalone": "Одиночный"
},
"netif": {
"title": "Сетевые интерфейсы",