3 Commits

Author SHA1 Message Date
ed4ff6576a feat(instances): show saved VM configuration on the Settings tab
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 10s
Read-only card on the instance Settings tab surfacing the panel-stored config
(cores/memory/disk/network/cloud-init) via the saved-config endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:30:44 +05:00
bb5d30ab68 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>
2026-06-15 00:30:44 +05:00
5d6acd619e feat(templates): add quick-deploy and CT template download
Per-template Deploy button opens a quick-deploy dialog (prefilled from the
quick-deploy endpoint defaults) that creates a VM via /templates/api/deploy.
New 'Download CT template' dialog browses the node's appliance repository
(available-lxc-templates) and pulls a template to a vztmpl storage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:29:34 +05:00
11 changed files with 503 additions and 17 deletions

View File

@@ -5,7 +5,7 @@ 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 { useVMConfig, useUpdateConfig, useResizeDisk, useVMOwner, useSetVMOwner, useExecuteScript } from '@/hooks/use-instances';
import { useVMConfig, useUpdateConfig, useResizeDisk, useVMOwner, useSetVMOwner, useExecuteScript, useSavedConfig } from '@/hooks/use-instances';
import { useProfile } from '@/hooks/use-settings';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from 'sonner';
@@ -137,6 +137,45 @@ function ExecuteScriptCard({ serverId, vmid, node }: { serverId: number; vmid: n
);
}
function SavedConfigCard({ serverId, vmid }: { serverId: number; vmid: number }) {
const { t } = useTranslation();
const { data } = useSavedConfig(serverId, vmid);
if (!data?.found || !data.config) return null;
const c = data.config;
const rows: [string, string | number | undefined][] = [
[t('instances.saved_name'), c.name],
['vCPU', c.cores],
[`${t('nodes.memory')} (MB)`, c.memory],
[`${t('nodes.disk')} (GB)`, c.disk_size],
['IP', c.ip_address ? `${c.ip_address}${c.ip_prefix ? `/${c.ip_prefix}` : ''}` : undefined],
[t('netif.gateway'), c.gateway],
['DNS', c.nameserver],
[t('instances.cloud_user'), c.cloud_init_user],
];
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Settings className="h-4 w-4" />{t('instances.saved_config')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-3 text-xs text-muted-foreground">{t('instances.saved_config_hint')}</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
{rows.filter(([, v]) => v != null && v !== '').map(([label, v]) => (
<div key={label} className="flex justify-between gap-2 border-b py-1">
<span className="text-muted-foreground">{label}</span>
<span className="font-mono">{String(v)}</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
export default function SettingsTab({ serverId, vmid, type, node }: Props) {
const { t } = useTranslation();
const { data: config } = useVMConfig(serverId, vmid, type, node);
@@ -282,6 +321,8 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
</CardContent>
</Card>
<SavedConfigCard serverId={serverId} vmid={vmid} />
{/* Raw Config */}
{config && (
<Card>

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

@@ -0,0 +1,177 @@
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useServers, useNodes, useStorages } from '@/hooks/use-nodes';
import { useQuickDeployInfo, useDeployVM } from '@/hooks/use-templates';
import { useAvailableLXCTemplates, useDownloadLXCTemplate } from '@/hooks/use-lxc-templates';
import type { OSTemplate } from '@/types';
import { toast } from 'sonner';
// ==================== Quick deploy (VM from template) ====================
export function DeployDialog({ template, onClose }: { template: OSTemplate | null; onClose: () => void }) {
const { t } = useTranslation();
const open = !!template;
const { data: info } = useQuickDeployInfo(template?.id ?? null);
const deploy = useDeployVM();
const [name, setName] = useState('');
const [cores, setCores] = useState('');
const [memory, setMemory] = useState('');
const [disk, setDisk] = useState('');
const [start, setStart] = useState(true);
// Prefill from the quick-deploy info (authoritative defaults from backend).
useEffect(() => {
if (info?.template) {
setCores(String(info.template.default_cores));
setMemory(String(info.template.default_memory));
setDisk(String(info.template.default_disk));
}
}, [info?.template]);
useEffect(() => { if (template) setName(''); }, [template]);
const submit = () => {
if (!template) return;
if (!name.trim()) { toast.error(t('templates.name_required')); return; }
deploy.mutate(
{
template_id: template.id,
name: name.trim(),
cores: Number(cores) || undefined,
memory: Number(memory) || undefined,
disk: Number(disk) || undefined,
start_after_create: start,
},
{
onSuccess: () => { toast.success(t('templates.deploy_started')); onClose(); },
onError: (e: Error) => toast.error(e.message),
},
);
};
const min = info?.template;
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent>
<DialogHeader><DialogTitle>{t('templates.deploy')}: {template?.name}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div><Label>{t('templates.vm_name')}</Label><Input value={name} onChange={e => setName(e.target.value)} className="mt-1" placeholder="web-01" /></div>
<div className="grid grid-cols-3 gap-3">
<div><Label>vCPU</Label><Input type="number" min={min?.min_cores ?? 1} value={cores} onChange={e => setCores(e.target.value)} className="mt-1" /></div>
<div><Label>{t('nodes.memory')} (MB)</Label><Input type="number" min={min?.min_memory ?? 128} value={memory} onChange={e => setMemory(e.target.value)} className="mt-1" /></div>
<div><Label>{t('nodes.disk')} (GB)</Label><Input type="number" min={min?.min_disk ?? 1} value={disk} onChange={e => setDisk(e.target.value)} className="mt-1" /></div>
</div>
{min && <p className="text-xs text-muted-foreground">{t('templates.minimums')}: {min.min_cores} vCPU / {min.min_memory} MB / {min.min_disk} GB</p>}
<div className="flex items-center gap-2">
<input id="deploy_start" type="checkbox" checked={start} onChange={e => setStart(e.target.checked)} className="h-4 w-4" />
<Label htmlFor="deploy_start" className="cursor-pointer">{t('templates.start_after')}</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={deploy.isPending || !name.trim()}>
{deploy.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('templates.deploy')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ==================== Download CT template ====================
export function DownloadCTDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const { data: servers = [] } = useServers();
const [serverId, setServerId] = useState(0);
const [node, setNode] = useState('');
const [storage, setStorage] = useState('');
const [template, setTemplate] = useState('');
const { data: nodesData } = useNodes(serverId);
const nodes = nodesData?.nodes ?? [];
const { data: available = [], isFetching } = useAvailableLXCTemplates(serverId || undefined, node || undefined);
const { data: storagesRaw = [] } = useStorages(serverId, node);
const download = useDownloadLXCTemplate(serverId);
// Storages that accept CT templates (vztmpl content).
const storages = useMemo(
() => (storagesRaw as { storage: string; content?: string }[]).filter(s => (s.content || '').split(',').includes('vztmpl')),
[storagesRaw],
);
// Default node to the first one.
useEffect(() => { if (!node && nodes.length) setNode(nodes[0].node); }, [nodes, node]);
const submit = () => {
if (!node || !storage || !template) { toast.error(t('templates.download_fields_required')); return; }
download.mutate({ node, storage, template }, {
onSuccess: () => { toast.success(t('templates.download_started')); onClose(); },
onError: (e: Error) => toast.error(e.message),
});
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent>
<DialogHeader><DialogTitle>{t('templates.download_ct')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div>
<Label>{t('templates.server')}</Label>
<Select value={serverId ? String(serverId) : ''} onValueChange={v => { if (v) { setServerId(Number(v)); setNode(''); setStorage(''); setTemplate(''); } }}>
<SelectTrigger className="mt-1"><SelectValue placeholder={t('templates.select_server')} /></SelectTrigger>
<SelectContent>{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}</SelectContent>
</Select>
</div>
{serverId > 0 && (
<>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('nodes.title')}</Label>
<Select value={node} onValueChange={v => { if (v) { setNode(v); setTemplate(''); setStorage(''); } }}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{nodes.map(n => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label>{t('storages.title')}</Label>
<Select value={storage} onValueChange={v => { if (v) setStorage(v); }}>
<SelectTrigger className="mt-1"><SelectValue placeholder={storages.length ? '' : t('templates.no_vztmpl_storage')} /></SelectTrigger>
<SelectContent>{storages.map(s => <SelectItem key={s.storage} value={s.storage}>{s.storage}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div>
<Label>{t('templates.template')} {isFetching && <Loader2 className="inline h-3 w-3 animate-spin" />}</Label>
<Select value={template} onValueChange={v => { if (v) setTemplate(v); }}>
<SelectTrigger className="mt-1"><SelectValue placeholder={t('templates.select_template')} /></SelectTrigger>
<SelectContent className="max-h-72">
{available.map(a => (
<SelectItem key={a.template} value={a.template}>{a.template}{a.headline ? ` — ${a.headline}` : ''}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={download.isPending || !node || !storage || !template}>
{download.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('templates.download_btn')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Monitor, Search, Download, Trash2, Loader2 } from 'lucide-react';
import { Monitor, Search, Download, Trash2, Loader2, Rocket, PackagePlus } from 'lucide-react';
import { OsLogo } from './OsLogo';
import { DeployDialog, DownloadCTDialog } from './TemplateDialogs';
import type { OSTemplate } from '@/types';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -20,6 +22,8 @@ export default function TemplatesPage() {
const [typeFilter, setTypeFilter] = useState<string>('');
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importServerId, setImportServerId] = useState<string>('');
const [deployTpl, setDeployTpl] = useState<OSTemplate | null>(null);
const [downloadOpen, setDownloadOpen] = useState(false);
const { data: groups = [] } = useTemplateGroups();
const { data: templates = [], isLoading } = useTemplates(
@@ -50,9 +54,14 @@ export default function TemplatesPage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('nav.templates')}</h1>
<Button onClick={() => setImportDialogOpen(true)}>
<Download className="mr-2 h-4 w-4" />{t('templates.auto_import')}
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setDownloadOpen(true)}>
<PackagePlus className="mr-2 h-4 w-4" />{t('templates.download_ct')}
</Button>
<Button onClick={() => setImportDialogOpen(true)}>
<Download className="mr-2 h-4 w-4" />{t('templates.auto_import')}
</Button>
</div>
</div>
{/* Filters */}
@@ -133,12 +142,20 @@ export default function TemplatesPage() {
<span>{tpl.server_name}</span>
<span>{tpl.vmid != null ? `VMID: ${tpl.vmid}` : t('templates.file_template')}</span>
</div>
{tpl.vm_type === 'qemu' && tpl.vmid != null && (
<Button size="sm" className="w-full" onClick={() => setDeployTpl(tpl)}>
<Rocket className="mr-1.5 h-4 w-4" />{t('templates.deploy')}
</Button>
)}
</CardContent>
</Card>
))}
</div>
)}
<DeployDialog template={deployTpl} onClose={() => setDeployTpl(null)} />
<DownloadCTDialog open={downloadOpen} onClose={() => setDownloadOpen(false)} />
{/* Import Dialog */}
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent className="sm:max-w-md">

View File

@@ -280,6 +280,26 @@ export function useSnapshotArchiveDetail(archiveId: number | null) {
});
}
// ==================== Saved config (DB) ====================
export interface SavedVMConfig {
found: boolean;
config?: {
cores?: number; memory?: number; disk_size?: number;
ip_address?: string; ip_prefix?: number; gateway?: string; nameserver?: string;
cloud_init_user?: string; cloud_init_password?: string; ssh_keys?: string;
name?: string; template_id?: number;
};
}
export function useSavedConfig(serverId: number, vmid: number, enabled = true) {
return useQuery<SavedVMConfig>({
queryKey: ['vm-saved-config', serverId, vmid],
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/vm/${vmid}/saved-config`),
enabled: enabled && serverId > 0 && vmid > 0,
});
}
// ==================== Execute script (guest agent) ====================
export interface ExecuteScriptResult {

View File

@@ -1,4 +1,4 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { LXCTemplate, LXCDeployRequest } from '@/types';
@@ -42,3 +42,37 @@ export function useDeployLXC() {
apiClient.post<{ task_id: number; status: string; name: string }>('/proxmox/api/lxc/deploy', data),
});
}
export interface AvailableLXCTemplate {
template: string;
type?: string;
os?: string;
version?: string;
headline?: string;
section?: string;
package?: string;
[k: string]: unknown;
}
// Templates available to download from the PVE appliance repository for a node.
export function useAvailableLXCTemplates(serverId?: number, node?: string) {
return useQuery({
queryKey: ['available-lxc-templates', serverId, node],
queryFn: () =>
apiClient.get<AvailableLXCTemplate[]>(`/proxmox/api/${serverId}/available-lxc-templates?node=${encodeURIComponent(node!)}`),
enabled: !!serverId && !!node,
staleTime: 5 * 60 * 1000,
});
}
export function useDownloadLXCTemplate(serverId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { node: string; storage: string; template: string }) =>
apiClient.post<{ success?: boolean; upid?: string; task_id?: number }>(`/proxmox/api/${serverId}/download-lxc-template`, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['all-tasks'] });
qc.invalidateQueries({ queryKey: ['lxc-templates', serverId] });
},
});
}

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

@@ -79,3 +79,38 @@ export function useDeleteTemplate() {
onSuccess: () => qc.invalidateQueries({ queryKey: ['templates'] }),
});
}
export interface QuickDeployInfo {
template: { id: number; name: string; vmid?: number; node?: string; description?: string;
default_cores: number; default_memory: number; default_disk: number;
min_cores: number; min_memory: number; min_disk: number };
group: { name?: string; icon?: string };
server: { id?: number; name?: string };
}
export function useQuickDeployInfo(templateId: number | null) {
return useQuery({
queryKey: ['quick-deploy', templateId],
queryFn: () => apiClient.get<QuickDeployInfo>(`/templates/api/quick-deploy/${templateId}`),
enabled: !!templateId,
});
}
export interface DeployVMArgs {
template_id: number; name: string;
cores?: number; memory?: number; disk?: number;
target_node?: string; target_storage?: string;
start_after_create?: boolean; onboot?: boolean;
}
export function useDeployVM() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: DeployVMArgs) =>
apiClient.post<{ task_id: number; status: string; name?: string }>('/templates/api/deploy', data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['all-tasks'] });
qc.invalidateQueries({ queryKey: ['virtual-machines'] });
},
});
}

View File

@@ -177,6 +177,10 @@
"no_owner": "No owner",
"owner_saved": "Owner updated",
"owner_hint": "Assign this instance to a user (admin only).",
"saved_config": "Saved configuration",
"saved_config_hint": "Values stored by the panel at creation/reinstall time.",
"saved_name": "Name",
"cloud_user": "Cloud-init user",
"execute_script": "Execute script",
"execute_script_hint": "Runs via the QEMU guest agent (must be installed and running in the VM).",
"interpreter": "Interpreter",
@@ -299,7 +303,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",
@@ -396,6 +405,20 @@
},
"templates": {
"title": "OS Templates",
"deploy": "Deploy",
"vm_name": "VM name",
"minimums": "Minimums",
"start_after": "Start after creation",
"deploy_started": "Deployment started",
"name_required": "Enter a VM name",
"download_ct": "Download CT template",
"download_started": "Download started",
"download_fields_required": "Select node, storage and template",
"download_btn": "Download",
"no_vztmpl_storage": "No vztmpl storage",
"template": "Template",
"select_template": "Select template",
"server": "Server",
"search": "Search templates...",
"all_groups": "All groups",
"all_servers": "All servers",

View File

@@ -177,6 +177,10 @@
"no_owner": "Без владельца",
"owner_saved": "Владелец обновлён",
"owner_hint": "Назначить инстанс пользователю (только админ).",
"saved_config": "Сохранённая конфигурация",
"saved_config_hint": "Значения, сохранённые панелью при создании/переустановке.",
"saved_name": "Имя",
"cloud_user": "Cloud-init пользователь",
"execute_script": "Выполнить скрипт",
"execute_script_hint": "Выполняется через QEMU guest agent (должен быть установлен и запущен в VM).",
"interpreter": "Интерпретатор",
@@ -299,7 +303,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": "Сетевые интерфейсы",
@@ -396,6 +405,20 @@
},
"templates": {
"title": "Шаблоны ОС",
"deploy": "Развернуть",
"vm_name": "Имя VM",
"minimums": "Минимум",
"start_after": "Запустить после создания",
"deploy_started": "Развёртывание запущено",
"name_required": "Укажите имя VM",
"download_ct": "Скачать CT-шаблон",
"download_started": "Загрузка запущена",
"download_fields_required": "Выберите узел, хранилище и шаблон",
"download_btn": "Скачать",
"no_vztmpl_storage": "Нет хранилища vztmpl",
"template": "Шаблон",
"select_template": "Выберите шаблон",
"server": "Сервер",
"search": "Поиск шаблонов...",
"all_groups": "Все группы",
"all_servers": "Все серверы",