Compare commits
3 Commits
508be2423a
...
ed4ff6576a
| Author | SHA1 | Date | |
|---|---|---|---|
| ed4ff6576a | |||
| bb5d30ab68 | |||
| 5d6acd619e |
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
177
frontend/src/features/templates/TemplateDialogs.tsx
Normal file
177
frontend/src/features/templates/TemplateDialogs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Все серверы",
|
||||
|
||||
Reference in New Issue
Block a user