feat: add firewall, pools, access, disks, node admin and VM hardware management
Backend: new proxmox mixins and API routes for firewall, pools, access control, node admin and disks; extend storage and VM endpoints. Frontend: node managers (firewall, pool, access, disk, node admin), VM firewall and hardware tabs, PCI/USB device dialogs, related hooks and locale strings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -87,7 +87,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"flex w-full items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -20,7 +20,20 @@ import { formatBytes } from '@/lib/format';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
const STORAGE_TYPES = ['dir', 'nfs', 'cifs', 'pbs'];
|
||||
const STORAGE_TYPES = ['dir', 'nfs', 'cifs', 'pbs', 'lvm', 'lvmthin', 'zfspool', 'rbd', 'cephfs'];
|
||||
|
||||
/** Разумный content по умолчанию для типа хранилища. */
|
||||
const DEFAULT_CONTENT: Record<string, string> = {
|
||||
dir: 'images,iso,vztmpl,backup',
|
||||
nfs: 'images,iso,vztmpl,backup',
|
||||
cifs: 'images,iso,vztmpl,backup',
|
||||
pbs: 'backup',
|
||||
lvm: 'images,rootdir',
|
||||
lvmthin: 'images,rootdir',
|
||||
zfspool: 'images,rootdir',
|
||||
rbd: 'images,rootdir',
|
||||
cephfs: 'backup,iso,vztmpl',
|
||||
};
|
||||
|
||||
interface StorageRow {
|
||||
storage: string;
|
||||
@@ -51,12 +64,19 @@ interface StorageForm {
|
||||
username: string;
|
||||
password: string;
|
||||
fingerprint: string;
|
||||
vgname: string; // lvm / lvmthin — volume group
|
||||
thinpool: string; // lvmthin — thin pool
|
||||
pool: string; // zfspool — пул/датасет; rbd — ceph pool
|
||||
monhost: string; // rbd / cephfs (внешний ceph)
|
||||
fsname: string; // cephfs — имя файловой системы
|
||||
sparse: boolean; // zfspool — тонкое выделение
|
||||
}
|
||||
|
||||
const emptyForm: StorageForm = {
|
||||
storage: '', type: 'dir', content: 'backup', nodes: '',
|
||||
storage: '', type: 'dir', content: 'images,iso,vztmpl,backup', nodes: '',
|
||||
path: '', server: '', export: '', share: '', datastore: '',
|
||||
username: '', password: '', fingerprint: '',
|
||||
vgname: '', thinpool: '', pool: '', monhost: '', fsname: '', sparse: false,
|
||||
};
|
||||
|
||||
function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
||||
@@ -113,6 +133,19 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
||||
if (form.password) payload.password = form.password;
|
||||
if (form.fingerprint.trim()) payload.fingerprint = form.fingerprint.trim();
|
||||
}
|
||||
if (form.type === 'lvm') payload.vgname = form.vgname.trim();
|
||||
if (form.type === 'lvmthin') { payload.vgname = form.vgname.trim(); payload.thinpool = form.thinpool.trim(); }
|
||||
if (form.type === 'zfspool') { payload.pool = form.pool.trim(); if (form.sparse) payload.sparse = 1; }
|
||||
if (form.type === 'rbd') {
|
||||
payload.pool = form.pool.trim();
|
||||
if (form.monhost.trim()) payload.monhost = form.monhost.trim();
|
||||
if (form.username.trim()) payload.username = form.username.trim();
|
||||
}
|
||||
if (form.type === 'cephfs') {
|
||||
if (form.fsname.trim()) payload['fs-name'] = form.fsname.trim();
|
||||
if (form.monhost.trim()) payload.monhost = form.monhost.trim();
|
||||
if (form.username.trim()) payload.username = form.username.trim();
|
||||
}
|
||||
createStorage.mutate(payload, {
|
||||
onSuccess: () => done(t('storages.created')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
@@ -133,7 +166,11 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('storages.type')}</Label>
|
||||
<Select value={form.type} onValueChange={v => { if (v) set('type', v); }} disabled={!!editing}>
|
||||
<Select
|
||||
value={form.type}
|
||||
onValueChange={v => { if (v) setForm(p => ({ ...p, type: v, content: DEFAULT_CONTENT[v] ?? p.content })); }}
|
||||
disabled={!!editing}
|
||||
>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{STORAGE_TYPES.map(ty => <SelectItem key={ty} value={ty}>{ty.toUpperCase()}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
@@ -166,6 +203,38 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
||||
<div className="col-span-2"><Label>{t('storages.fingerprint')}</Label><Input value={form.fingerprint} onChange={e => set('fingerprint', e.target.value)} className="mt-1 font-mono" /></div>
|
||||
</div>
|
||||
)}
|
||||
{!editing && form.type === 'lvm' && (
|
||||
<div><Label>{t('storages.vgname')}</Label><Input value={form.vgname} onChange={e => set('vgname', e.target.value)} className="mt-1 font-mono" placeholder="pve" /></div>
|
||||
)}
|
||||
{!editing && form.type === 'lvmthin' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>{t('storages.vgname')}</Label><Input value={form.vgname} onChange={e => set('vgname', e.target.value)} className="mt-1 font-mono" placeholder="pve" /></div>
|
||||
<div><Label>{t('storages.thinpool')}</Label><Input value={form.thinpool} onChange={e => set('thinpool', e.target.value)} className="mt-1 font-mono" placeholder="data" /></div>
|
||||
</div>
|
||||
)}
|
||||
{!editing && form.type === 'zfspool' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>{t('storages.zfs_pool')}</Label><Input value={form.pool} onChange={e => set('pool', e.target.value)} className="mt-1 font-mono" placeholder="rpool/data" /></div>
|
||||
<label className="flex items-end gap-2 pb-1.5 text-sm">
|
||||
<input type="checkbox" checked={form.sparse} onChange={e => set('sparse', e.target.checked)} />
|
||||
{t('storages.sparse')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{!editing && form.type === 'rbd' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>{t('storages.ceph_pool')}</Label><Input value={form.pool} onChange={e => set('pool', e.target.value)} className="mt-1 font-mono" placeholder="vm-pool" /></div>
|
||||
<div><Label>{t('storages.username')}</Label><Input value={form.username} onChange={e => set('username', e.target.value)} className="mt-1" placeholder="admin" /></div>
|
||||
<div className="col-span-2"><Label>{t('storages.monhost')}</Label><Input value={form.monhost} onChange={e => set('monhost', e.target.value)} className="mt-1 font-mono" placeholder="10.0.0.1 10.0.0.2 (внешний Ceph)" /></div>
|
||||
</div>
|
||||
)}
|
||||
{!editing && form.type === 'cephfs' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>{t('storages.fsname')}</Label><Input value={form.fsname} onChange={e => set('fsname', e.target.value)} className="mt-1 font-mono" placeholder="cephfs" /></div>
|
||||
<div><Label>{t('storages.username')}</Label><Input value={form.username} onChange={e => set('username', e.target.value)} className="mt-1" placeholder="admin" /></div>
|
||||
<div className="col-span-2"><Label>{t('storages.monhost')}</Label><Input value={form.monhost} onChange={e => set('monhost', e.target.value)} className="mt-1 font-mono" placeholder="10.0.0.1 10.0.0.2 (внешний Ceph)" /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div><Label>{t('storages.content')}</Label><Input value={form.content} onChange={e => set('content', e.target.value)} className="mt-1" placeholder="backup,images,iso" /></div>
|
||||
<div><Label>{t('storages.nodes')}</Label><Input value={form.nodes} onChange={e => set('nodes', e.target.value)} className="mt-1" placeholder={t('storages.nodes_placeholder')} /></div>
|
||||
@@ -395,6 +464,7 @@ export default function StoragesTab() {
|
||||
const { data: storagesData, isLoading } = useBackupStorages(serverId);
|
||||
const { data: jobsData } = useProxmoxBackupJobs(serverId);
|
||||
const deleteStorage = useDeleteStorage(serverId);
|
||||
const updateStorage = useUpdateStorage(serverId);
|
||||
const updateJob = useUpdateProxmoxJob(serverId);
|
||||
const deleteJob = useDeleteProxmoxJob(serverId);
|
||||
|
||||
@@ -419,6 +489,14 @@ export default function StoragesTab() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleStorage = (s: StorageRow) => {
|
||||
const disabling = s.disable !== 1;
|
||||
updateStorage.mutate({ storage: s.storage, disable: disabling ? 1 : 0 }, {
|
||||
onSuccess: () => toast.success(disabling ? t('storages.disabled_ok', 'Хранилище отключено') : t('storages.enabled_ok', 'Хранилище включено')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteJob = async (job: NativeJob) => {
|
||||
if (!await confirm(`${t('storages.delete_job_confirm')} "${String(job.id)}"?`)) return;
|
||||
deleteJob.mutate(String(job.id), {
|
||||
@@ -498,6 +576,11 @@ export default function StoragesTab() {
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7"
|
||||
title={s.disable === 1 ? t('backups.enable') : t('backups.disable')}
|
||||
onClick={() => handleToggleStorage(s)}>
|
||||
{s.disable === 1 ? <Power className="h-3.5 w-3.5" /> : <PowerOff className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditing(s); setDialogOpen(true); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
175
frontend/src/features/instances/AddPciDeviceDialog.tsx
Normal file
175
frontend/src/features/instances/AddPciDeviceDialog.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useNodePciDevices, useUpdateConfig } from '@/hooks/use-instances';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
serverId: number;
|
||||
vmid: number;
|
||||
node: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Следующий свободный слот hostpciN из текущего конфига. */
|
||||
function nextHostpciKey(config?: Record<string, unknown>): string {
|
||||
const used = new Set(
|
||||
Object.keys(config ?? {})
|
||||
.filter((k) => /^hostpci\d+$/.test(k))
|
||||
.map((k) => Number(k.slice(7))),
|
||||
);
|
||||
let n = 0;
|
||||
while (used.has(n)) n++;
|
||||
return `hostpci${n}`;
|
||||
}
|
||||
|
||||
export default function AddPciDeviceDialog({ open, onClose, serverId, vmid, node, config }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: devices, isLoading } = useNodePciDevices(serverId, node, open);
|
||||
const updateConfig = useUpdateConfig(serverId, vmid, 'qemu', node);
|
||||
|
||||
const [deviceId, setDeviceId] = useState('');
|
||||
const [pcie, setPcie] = useState(true);
|
||||
const [allFunctions, setAllFunctions] = useState(false);
|
||||
const [primaryGpu, setPrimaryGpu] = useState(false);
|
||||
const [romBar, setRomBar] = useState(true);
|
||||
|
||||
const hostpciKey = useMemo(() => nextHostpciKey(config), [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDeviceId('');
|
||||
setPcie(true);
|
||||
setAllFunctions(false);
|
||||
setPrimaryGpu(false);
|
||||
setRomBar(true);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const buildValue = (): string => {
|
||||
// "All functions" пробрасывает всё устройство: 0000:01:00 (без .функции)
|
||||
const id = allFunctions ? deviceId.replace(/\.\d+$/, '') : deviceId;
|
||||
const parts = [id];
|
||||
if (pcie) parts.push('pcie=1');
|
||||
if (primaryGpu) parts.push('x-vga=1');
|
||||
if (!romBar) parts.push('rombar=0');
|
||||
return parts.join(',');
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!deviceId) {
|
||||
toast.error(t('hw.err_pci_required', 'Выберите PCI-устройство'));
|
||||
return;
|
||||
}
|
||||
updateConfig.mutate(
|
||||
{ [hostpciKey]: buildValue() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t('hw.pci_added', 'Устройство {{key}} добавлено', { key: hostpciKey }));
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error((e as Error).message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" />
|
||||
{t('hw.add_pci_title', 'Проброс PCI-устройства')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('hw.add_pci_description', 'Будет добавлено как {{key}}. VM должна быть выключена.', { key: hostpciKey })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('hw.pci_device', 'Устройство')}</Label>
|
||||
<Select value={deviceId} onValueChange={setDeviceId}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoading
|
||||
? t('common.loading', 'Загрузка…')
|
||||
: t('hw.select_pci', 'Выберите устройство')
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(devices ?? []).map((d) => {
|
||||
const label = [d.device_name, d.vendor_name].filter(Boolean).join(' — ');
|
||||
return (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs">{d.id}</span>
|
||||
{label && <span className="text-xs text-muted-foreground">{label}</span>}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
{!isLoading && (devices ?? []).length === 0 && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t('hw.no_pci', 'Устройства не найдены')}
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={pcie} onChange={(e) => setPcie(e.target.checked)} />
|
||||
{t('hw.pci_pcie', 'PCI-Express (только для q35)')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={allFunctions} onChange={(e) => setAllFunctions(e.target.checked)} />
|
||||
{t('hw.pci_all_functions', 'Все функции устройства')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={primaryGpu} onChange={(e) => setPrimaryGpu(e.target.checked)} />
|
||||
{t('hw.pci_primary_gpu', 'Основная видеокарта (x-vga)')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={romBar} onChange={(e) => setRomBar(e.target.checked)} />
|
||||
{t('hw.pci_rombar', 'ROM-Bar')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('hw.pci_hint', 'Проброс PCI требует включённого IOMMU (VT-d / AMD-Vi) на хосте.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button>
|
||||
<Button onClick={submit} disabled={updateConfig.isPending || !deviceId}>
|
||||
{t('hw.add_submit', 'Добавить')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
145
frontend/src/features/instances/AddUsbDeviceDialog.tsx
Normal file
145
frontend/src/features/instances/AddUsbDeviceDialog.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Usb } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useNodeUsbDevices, useUpdateConfig } from '@/hooks/use-instances';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
serverId: number;
|
||||
vmid: number;
|
||||
node: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Следующий свободный слот usbN из текущего конфига (макс. usb0..usb4). */
|
||||
function nextUsbKey(config?: Record<string, unknown>): string {
|
||||
const used = new Set(
|
||||
Object.keys(config ?? {})
|
||||
.filter((k) => /^usb\d+$/.test(k))
|
||||
.map((k) => Number(k.slice(3))),
|
||||
);
|
||||
let n = 0;
|
||||
while (used.has(n)) n++;
|
||||
return `usb${n}`;
|
||||
}
|
||||
|
||||
export default function AddUsbDeviceDialog({ open, onClose, serverId, vmid, node, config }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: devices, isLoading } = useNodeUsbDevices(serverId, node, open);
|
||||
const updateConfig = useUpdateConfig(serverId, vmid, 'qemu', node);
|
||||
|
||||
// value = "vendid:prodid" — привязка по конкретному устройству (host=id:id)
|
||||
const [selected, setSelected] = useState('');
|
||||
const [usb3, setUsb3] = useState(false);
|
||||
|
||||
const usbKey = useMemo(() => nextUsbKey(config), [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected('');
|
||||
setUsb3(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const submit = () => {
|
||||
if (!selected) {
|
||||
toast.error(t('hw.err_usb_required', 'Выберите USB-устройство'));
|
||||
return;
|
||||
}
|
||||
const parts = [`host=${selected}`];
|
||||
if (usb3) parts.push('usb3=1');
|
||||
updateConfig.mutate(
|
||||
{ [usbKey]: parts.join(',') },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t('hw.usb_added', 'Устройство {{key}} добавлено', { key: usbKey }));
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error((e as Error).message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Usb className="h-4 w-4" />
|
||||
{t('hw.add_usb_title', 'Проброс USB-устройства')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('hw.add_usb_description', 'Будет добавлено как {{key}}', { key: usbKey })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('hw.usb_device', 'Устройство')}</Label>
|
||||
<Select value={selected} onValueChange={setSelected}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoading
|
||||
? t('common.loading', 'Загрузка…')
|
||||
: t('hw.select_usb', 'Выберите устройство')
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(devices ?? []).map((d, i) => {
|
||||
const id = `${d.vendid}:${d.prodid}`;
|
||||
const label = [d.manufacturer, d.prodname].filter(Boolean).join(' ');
|
||||
return (
|
||||
<SelectItem key={`${id}-${d.usbpath ?? d.port ?? i}`} value={id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs">{id}</span>
|
||||
{label && <span className="text-xs text-muted-foreground">{label}</span>}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
{!isLoading && (devices ?? []).length === 0 && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t('hw.no_usb', 'Устройства не найдены')}
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={usb3} onChange={(e) => setUsb3(e.target.checked)} />
|
||||
{t('hw.usb3', 'USB 3.0')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button>
|
||||
<Button onClick={submit} disabled={updateConfig.isPending || !selected}>
|
||||
{t('hw.add_submit', 'Добавить')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,8 @@ import OverviewTab from './tabs/OverviewTab';
|
||||
import GraphsTab from './tabs/GraphsTab';
|
||||
import SnapshotsTab from './tabs/SnapshotsTab';
|
||||
import NetworkingTab from './tabs/NetworkingTab';
|
||||
import HardwareTab from './tabs/HardwareTab';
|
||||
import FirewallTab from './tabs/FirewallTab';
|
||||
import SettingsTab from './tabs/SettingsTab';
|
||||
import DestroyTab from './tabs/DestroyTab';
|
||||
|
||||
@@ -241,6 +243,10 @@ export default function InstanceDetailPage() {
|
||||
<TabsTrigger value="graphs">{t('instances.graphs', 'Graphs')}</TabsTrigger>
|
||||
<TabsTrigger value="snapshots">{t('common.snapshots', 'Snapshots')}</TabsTrigger>
|
||||
<TabsTrigger value="networking">{t('instances.networking', 'Networking')}</TabsTrigger>
|
||||
{type !== 'lxc' && (
|
||||
<TabsTrigger value="hardware">{t('instances.hardware', 'Hardware')}</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="firewall">{t('instances.firewall', 'Firewall')}</TabsTrigger>
|
||||
<TabsTrigger value="settings">{t('nav.settings', 'Settings')}</TabsTrigger>
|
||||
<TabsTrigger value="destroy" className="text-destructive data-[state=active]:text-destructive">
|
||||
{t('instances.destroy', 'Destroy')}
|
||||
@@ -259,6 +265,14 @@ export default function InstanceDetailPage() {
|
||||
<TabsContent value="networking" className="mt-4">
|
||||
<NetworkingTab serverId={sid} vmid={vid} type={type} node={node} />
|
||||
</TabsContent>
|
||||
{type !== 'lxc' && (
|
||||
<TabsContent value="hardware" className="mt-4">
|
||||
<HardwareTab serverId={sid} vmid={vid} type={type} node={node} />
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="firewall" className="mt-4">
|
||||
<FirewallTab serverId={sid} vmid={vid} type={type} node={node} />
|
||||
</TabsContent>
|
||||
<TabsContent value="settings" className="mt-4">
|
||||
<SettingsTab serverId={sid} vmid={vid} type={type} node={node} />
|
||||
</TabsContent>
|
||||
|
||||
190
frontend/src/features/instances/tabs/FirewallTab.tsx
Normal file
190
frontend/src/features/instances/tabs/FirewallTab.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Power, Shield } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
useFirewallGroups,
|
||||
useGuestFirewallOptions, useUpdateGuestFirewallOptions,
|
||||
useGuestFirewallRules, useCreateGuestFirewallRule, useUpdateGuestFirewallRule, useDeleteGuestFirewallRule,
|
||||
useGuestFirewallLog,
|
||||
type FirewallRule,
|
||||
} from '@/hooks/use-firewall';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
import { RuleDialog, RuleRow } from '@/features/nodes/FirewallManager';
|
||||
|
||||
interface Props {
|
||||
serverId: number;
|
||||
vmid: number;
|
||||
type: string; // 'qemu' | 'lxc'
|
||||
node: string;
|
||||
}
|
||||
|
||||
const POLICIES = ['ACCEPT', 'REJECT', 'DROP'];
|
||||
|
||||
export default function FirewallTab({ serverId, vmid, type, node }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const isLxc = type === 'lxc';
|
||||
|
||||
const { data: groupsData } = useFirewallGroups(serverId);
|
||||
const { data: optionsData, isLoading } = useGuestFirewallOptions(serverId, type, vmid, node);
|
||||
const { data: rulesData } = useGuestFirewallRules(serverId, type, vmid, node);
|
||||
|
||||
const updateOptions = useUpdateGuestFirewallOptions(serverId, type, vmid, node);
|
||||
const createRule = useCreateGuestFirewallRule(serverId, type, vmid, node);
|
||||
const updateRule = useUpdateGuestFirewallRule(serverId, type, vmid, node);
|
||||
const deleteRule = useDeleteGuestFirewallRule(serverId, type, vmid, node);
|
||||
|
||||
const groups = groupsData?.groups ?? [];
|
||||
const options = optionsData?.options ?? {};
|
||||
const rules = rulesData?.rules ?? [];
|
||||
const enabled = Number(options.enable) === 1;
|
||||
|
||||
const [ruleDialog, setRuleDialog] = useState(false);
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
const logQuery = useGuestFirewallLog(serverId, type, vmid, node, logOpen);
|
||||
const log = logQuery.data?.log ?? [];
|
||||
|
||||
const saveOption = (patch: Record<string, unknown>) => {
|
||||
updateOptions.mutate(patch, {
|
||||
onSuccess: () => toast.success(t('fw.options_saved', 'Опции сохранены')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
const addRule = (payload: Record<string, unknown>) => {
|
||||
createRule.mutate(payload, {
|
||||
onSuccess: () => { toast.success(t('fw.rule_added', 'Правило добавлено')); setRuleDialog(false); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
const toggleRule = (r: FirewallRule) => {
|
||||
if (r.pos == null) return;
|
||||
updateRule.mutate({ pos: r.pos, data: { enable: r.enable === 0 ? 1 : 0 } }, {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
const removeRule = async (pos?: number) => {
|
||||
if (pos == null || !await confirm(t('fw.delete_rule_confirm', 'Удалить правило?'))) return;
|
||||
deleteRule.mutate(pos, { onSuccess: () => toast.success(t('fw.rule_deleted', 'Правило удалено')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
|
||||
const boolOption = (key: string, label: string) => (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={Number(options[key]) === 1}
|
||||
onChange={(e) => saveOption({ [key]: e.target.checked ? 1 : 0 })} />
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="max-w-3xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Shield className="h-4 w-4" />
|
||||
{t('fw.guest_title', 'Firewall инстанса')}
|
||||
<Badge variant={enabled ? 'default' : 'secondary'}>{enabled ? t('fw.on', 'вкл') : t('fw.off', 'выкл')}</Badge>
|
||||
</CardTitle>
|
||||
<Button size="sm" variant={enabled ? 'outline' : 'default'} onClick={() => saveOption({ enable: enabled ? 0 : 1 })} disabled={updateOptions.isPending}>
|
||||
<Power className="mr-1 h-4 w-4" />{enabled ? t('fw.disable', 'Выключить') : t('fw.enable', 'Включить')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||
) : (
|
||||
<Tabs defaultValue="rules" onValueChange={(v) => setLogOpen(v === 'log')}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="rules">{t('fw.rules', 'Правила')} ({rules.length})</TabsTrigger>
|
||||
<TabsTrigger value="options">{t('fw.options', 'Опции')}</TabsTrigger>
|
||||
<TabsTrigger value="log">{t('fw.log_tab', 'Лог')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Rules */}
|
||||
<TabsContent value="rules" className="mt-4 space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => setRuleDialog(true)}>
|
||||
<Plus className="mr-1 h-4 w-4" />{t('fw.add_rule', 'Добавить правило')}
|
||||
</Button>
|
||||
</div>
|
||||
{rules.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>{t('fw.direction', 'Напр.')}</TableHead>
|
||||
<TableHead>{t('fw.action', 'Действие')}</TableHead>
|
||||
<TableHead>{t('fw.proto', 'Прото')}</TableHead>
|
||||
<TableHead>{t('fw.match', 'Источник → Назначение')}</TableHead>
|
||||
<TableHead>{t('common.comment', 'Комментарий')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.map(r => (
|
||||
<RuleRow key={r.pos} rule={r} onToggle={() => toggleRule(r)} onDelete={() => removeRule(r.pos)} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Options */}
|
||||
<TabsContent value="options" className="mt-4 space-y-4 max-w-md">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => saveOption({ enable: e.target.checked ? 1 : 0 })} className="h-4 w-4 accent-primary" />
|
||||
<span className="text-sm">{t('fw.enable_guest', 'Включить фаервол инстанса')}</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('fw.policy_in', 'Политика входящих')}</Label>
|
||||
<Select value={String(options.policy_in || 'DROP')} onValueChange={(v) => { if (v) saveOption({ policy_in: v }); }}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{POLICIES.map(p => <SelectItem key={p} value={p}>{p}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('fw.policy_out', 'Политика исходящих')}</Label>
|
||||
<Select value={String(options.policy_out || 'ACCEPT')} onValueChange={(v) => { if (v) saveOption({ policy_out: v }); }}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{POLICIES.map(p => <SelectItem key={p} value={p}>{p}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{boolOption('dhcp', t('fw.dhcp', 'Разрешить DHCP'))}
|
||||
{boolOption('macfilter', t('fw.macfilter', 'MAC-фильтр'))}
|
||||
{boolOption('ipfilter', t('fw.ipfilter', 'IP-фильтр (анти-спуфинг)'))}
|
||||
{isLxc && boolOption('ndp', t('fw.ndp', 'NDP (IPv6)'))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('fw.guest_options_hint', 'Фаервол инстанса работает только когда включён фаервол датацентра и стоит firewall=1 на NIC.')}</p>
|
||||
</TabsContent>
|
||||
|
||||
{/* Log */}
|
||||
<TabsContent value="log" className="mt-4">
|
||||
{logQuery.isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||
) : log.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<pre className="max-h-80 overflow-auto rounded-md bg-muted p-3 text-xs font-mono whitespace-pre-wrap">
|
||||
{log.map(l => l.t).filter(Boolean).join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<RuleDialog open={ruleDialog} onOpenChange={setRuleDialog} onSubmit={addRule} pending={createRule.isPending} groups={groups} allowGroupType />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
357
frontend/src/features/instances/tabs/HardwareTab.tsx
Normal file
357
frontend/src/features/instances/tabs/HardwareTab.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Cpu, Usb, Plus, Trash2, MemoryStick, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useVMConfig, useUpdateConfig, useVMStatus } from '@/hooks/use-instances';
|
||||
import { useImageStorages } from '@/hooks/use-image-catalog';
|
||||
import { toast } from 'sonner';
|
||||
import AddPciDeviceDialog from '../AddPciDeviceDialog';
|
||||
import AddUsbDeviceDialog from '../AddUsbDeviceDialog';
|
||||
|
||||
interface Props {
|
||||
serverId: number;
|
||||
vmid: number;
|
||||
type: string;
|
||||
node: string;
|
||||
}
|
||||
|
||||
const BIOS_OPTIONS = [
|
||||
{ value: 'seabios', label: 'SeaBIOS (default)' },
|
||||
{ value: 'ovmf', label: 'OVMF (UEFI)' },
|
||||
];
|
||||
const MACHINE_OPTIONS = [
|
||||
{ value: 'pc', label: 'Default (i440fx)' },
|
||||
{ value: 'q35', label: 'q35' },
|
||||
];
|
||||
const VGA_OPTIONS = ['std', 'qxl', 'virtio', 'vmware', 'serial0', 'none'];
|
||||
const SCSIHW_OPTIONS = [
|
||||
'virtio-scsi-single',
|
||||
'virtio-scsi-pci',
|
||||
'lsi',
|
||||
'lsi53c810',
|
||||
'megasas',
|
||||
'pvscsi',
|
||||
];
|
||||
|
||||
/** Нормализуем machine из конфига (напр. "pc-q35-8.1") к значению селекта. */
|
||||
function machineToOption(raw: unknown): string {
|
||||
const s = typeof raw === 'string' ? raw.toLowerCase() : '';
|
||||
if (s.includes('q35')) return 'q35';
|
||||
return 'pc';
|
||||
}
|
||||
|
||||
function PlatformCard({
|
||||
serverId, vmid, node, config,
|
||||
}: Props & { config?: Record<string, unknown> }) {
|
||||
const { t } = useTranslation();
|
||||
const updateConfig = useUpdateConfig(serverId, vmid, 'qemu', node);
|
||||
|
||||
const [bios, setBios] = useState('seabios');
|
||||
const [machine, setMachine] = useState('pc');
|
||||
const [vga, setVga] = useState('std');
|
||||
const [scsihw, setScsihw] = useState('virtio-scsi-single');
|
||||
const [sockets, setSockets] = useState('');
|
||||
const [efiStorage, setEfiStorage] = useState('');
|
||||
|
||||
const hasEfiDisk = !!config?.efidisk0;
|
||||
// EFI-диск нужно создать, только если переключаемся на OVMF и его ещё нет.
|
||||
const needsEfiDisk = bios === 'ovmf' && !hasEfiDisk;
|
||||
|
||||
// Хранилища ноды, поддерживающие образы дисков (content=images) — для efidisk0.
|
||||
const { data: storages, isLoading: storagesLoading } = useImageStorages(
|
||||
serverId, node, needsEfiDisk ? 'images' : undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
setBios(typeof config.bios === 'string' ? config.bios : 'seabios');
|
||||
setMachine(machineToOption(config.machine));
|
||||
setVga(typeof config.vga === 'string' ? String(config.vga).split(',')[0] : 'std');
|
||||
setScsihw(typeof config.scsihw === 'string' ? config.scsihw : 'virtio-scsi-single');
|
||||
setSockets(config.sockets != null ? String(config.sockets) : '');
|
||||
}, [config]);
|
||||
|
||||
const save = () => {
|
||||
if (needsEfiDisk && !efiStorage) {
|
||||
toast.error(t('hw.err_efi_storage', 'Выберите хранилище для EFI-диска'));
|
||||
return;
|
||||
}
|
||||
const updates: Record<string, unknown> = { bios, machine, vga, scsihw };
|
||||
if (sockets.trim() !== '') updates.sockets = Number(sockets);
|
||||
// При переходе на OVMF без существующего EFI-диска создаём efidisk0.
|
||||
// Формат как в web-интерфейсе PVE: <storage>:1,efitype=4m,pre-enrolled-keys=1
|
||||
if (needsEfiDisk) {
|
||||
updates.efidisk0 = `${efiStorage}:1,efitype=4m,pre-enrolled-keys=1`;
|
||||
}
|
||||
updateConfig.mutate(updates, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('hw.platform_saved', 'Оборудование обновлено'));
|
||||
setEfiStorage('');
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<MemoryStick className="h-4 w-4" />
|
||||
{t('hw.platform', 'Платформа')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('hw.bios', 'BIOS')}</Label>
|
||||
<Select value={bios} onValueChange={setBios}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{BIOS_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('hw.machine', 'Тип платформы')}</Label>
|
||||
<Select value={machine} onValueChange={setMachine}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MACHINE_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('hw.vga', 'Дисплей (VGA)')}</Label>
|
||||
<Select value={vga} onValueChange={setVga}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{VGA_OPTIONS.map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('hw.scsihw', 'SCSI-контроллер')}</Label>
|
||||
<Select value={scsihw} onValueChange={setScsihw}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCSIHW_OPTIONS.map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hw-sockets">{t('hw.sockets', 'CPU-сокеты')}</Label>
|
||||
<Input id="hw-sockets" type="number" min={1} max={4} value={sockets}
|
||||
onChange={(e) => setSockets(e.target.value)} placeholder="1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EFI-диск: обязателен для OVMF, показываем выбор хранилища */}
|
||||
{needsEfiDisk && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/5 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amber-600 dark:text-amber-500">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{t('hw.efi_required_title', 'Для OVMF (UEFI) нужен EFI-диск')}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('hw.efi_required_hint', 'EFI-диск будет создан автоматически. Выберите хранилище для него.')}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('hw.efi_storage', 'Хранилище EFI-диска')}</Label>
|
||||
<Select value={efiStorage} onValueChange={setEfiStorage}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={
|
||||
storagesLoading
|
||||
? t('common.loading', 'Загрузка…')
|
||||
: t('hw.select_storage', 'Выберите хранилище')
|
||||
} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(storages ?? []).map((s) => (
|
||||
<SelectItem key={s.storage} value={s.storage}>{s.storage}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button size="sm" onClick={save} disabled={updateConfig.isPending}>
|
||||
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('common.save', 'Сохранить')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('hw.platform_hint', 'Изменения оборудования применяются после выключения и повторного запуска VM.')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HardwareTab({ serverId, vmid, type, node }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
const { data: status } = useVMStatus(serverId, vmid, type, node);
|
||||
const updateConfig = useUpdateConfig(serverId, vmid, 'qemu', node);
|
||||
|
||||
const [showAddPci, setShowAddPci] = useState(false);
|
||||
const [showAddUsb, setShowAddUsb] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
|
||||
const cfg = config as Record<string, unknown> | undefined;
|
||||
// Проброс PCI/USB нельзя менять на работающей VM — устройства не поддерживают hotplug.
|
||||
const isRunning = status?.status === 'running';
|
||||
|
||||
// Проброшенные устройства: hostpciN (PCI) и usbN (USB)
|
||||
const passthrough = useMemo(() => {
|
||||
if (!cfg) return [] as Array<{ key: string; value: string; kind: 'pci' | 'usb' }>;
|
||||
return Object.entries(cfg)
|
||||
.filter(([k]) => /^(hostpci|usb)\d+$/.test(k))
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
kind: key.startsWith('hostpci') ? ('pci' as const) : ('usb' as const),
|
||||
}))
|
||||
.sort((a, b) => a.key.localeCompare(b.key));
|
||||
}, [cfg]);
|
||||
|
||||
const removeDevice = () => {
|
||||
if (!deleteTarget) return;
|
||||
updateConfig.mutate(
|
||||
{ delete: deleteTarget },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t('hw.device_removed', 'Устройство {{key}} удалено', { key: deleteTarget }));
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onError: (e) => toast.error((e as Error).message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<PlatformCard serverId={serverId} vmid={vmid} type={type} node={node} config={cfg} />
|
||||
|
||||
{/* Passthrough devices */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" />
|
||||
{t('hw.passthrough', 'Проброс устройств')}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" variant="outline" disabled={isRunning}
|
||||
title={isRunning ? t('hw.stop_first', 'Выключите VM для изменения проброса') : undefined}
|
||||
onClick={() => setShowAddPci(true)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />PCI
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={isRunning}
|
||||
title={isRunning ? t('hw.stop_first', 'Выключите VM для изменения проброса') : undefined}
|
||||
onClick={() => setShowAddUsb(true)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />USB
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-amber-500/40 bg-amber-500/5 p-2.5 text-xs text-amber-600 dark:text-amber-500">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
{t('hw.running_lock', 'VM запущена — проброс устройств нельзя добавить или удалить. Выключите VM.')}
|
||||
</div>
|
||||
)}
|
||||
{passthrough.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{t('hw.no_passthrough', 'Нет проброшенных устройств')}
|
||||
</p>
|
||||
)}
|
||||
{passthrough.map(({ key, value, kind }) => (
|
||||
<div key={key} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{kind === 'pci'
|
||||
? <Cpu className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
: <Usb className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
<Badge variant="outline">{key}</Badge>
|
||||
<span className="font-mono text-xs">{value}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
disabled={isRunning}
|
||||
onClick={() => setDeleteTarget(key)}
|
||||
title={isRunning
|
||||
? t('hw.stop_first', 'Выключите VM для изменения проброса')
|
||||
: t('hw.remove_device', 'Удалить устройство')}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('hw.passthrough_hint', 'Проброс PCI/USB требует выключенной VM и включённого IOMMU на хосте.')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AddPciDeviceDialog
|
||||
open={showAddPci}
|
||||
onClose={() => setShowAddPci(false)}
|
||||
serverId={serverId}
|
||||
vmid={vmid}
|
||||
node={node}
|
||||
config={cfg}
|
||||
/>
|
||||
<AddUsbDeviceDialog
|
||||
open={showAddUsb}
|
||||
onClose={() => setShowAddUsb(false)}
|
||||
serverId={serverId}
|
||||
vmid={vmid}
|
||||
node={node}
|
||||
config={cfg}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('hw.remove_title', 'Удалить устройство?')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('hw.remove_description', 'Устройство {{key}} будет удалено из конфигурации VM.', { key: deleteTarget ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
{t('common.cancel', 'Отмена')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={removeDevice} disabled={updateConfig.isPending}>
|
||||
{t('common.delete', 'Удалить')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -260,6 +260,63 @@ function AutostartCard({ serverId, vmid, type, node }: { serverId: number; vmid:
|
||||
);
|
||||
}
|
||||
|
||||
/** Разобрать строку тегов Proxmox ("web;prod") в массив. */
|
||||
function parseTags(spec: unknown): string[] {
|
||||
if (typeof spec !== 'string') return [];
|
||||
return spec.split(/[;,\s]+/).map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function TagsCard({ serverId, vmid, type, node }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
|
||||
|
||||
const [value, setValue] = useState<string | null>(null);
|
||||
const current = useMemo(() => parseTags(config?.tags).join(', '), [config]);
|
||||
const text = value ?? current;
|
||||
|
||||
const save = () => {
|
||||
// Proxmox хранит теги через ';'; пустая строка удаляет все теги.
|
||||
const tags = parseTags(text.replace(/,/g, ';')).join(';');
|
||||
updateConfig.mutate(
|
||||
tags ? { tags } : { delete: 'tags' },
|
||||
{
|
||||
onSuccess: () => { toast.success(t('instances.tags_saved', 'Теги сохранены')); setValue(null); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
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.tags', 'Теги')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap gap-1.5 min-h-[1.5rem]">
|
||||
{parseTags(text).length === 0
|
||||
? <span className="text-xs text-muted-foreground">{t('instances.no_tags', 'Нет тегов')}</span>
|
||||
: parseTags(text).map(tag => (
|
||||
<span key={tag} className="rounded-full bg-secondary px-2 py-0.5 text-xs">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label htmlFor="tags-input">{t('instances.tags_hint', 'Теги через запятую')}</Label>
|
||||
<Input id="tags-input" value={text} onChange={e => setValue(e.target.value)} placeholder="web, prod, db" />
|
||||
</div>
|
||||
<Button size="sm" onClick={save} disabled={updateConfig.isPending || text === current}>
|
||||
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('common.save', 'Сохранить')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedConfigCard({ serverId, vmid }: { serverId: number; vmid: number }) {
|
||||
const { t } = useTranslation();
|
||||
const { data } = useSavedConfig(serverId, vmid);
|
||||
@@ -400,6 +457,8 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
|
||||
|
||||
<AutostartCard serverId={serverId} vmid={vmid} type={type} node={node} />
|
||||
|
||||
<TagsCard serverId={serverId} vmid={vmid} type={type} node={node} />
|
||||
|
||||
{type !== 'lxc' && <ExecuteScriptCard serverId={serverId} vmid={vmid} node={node} />}
|
||||
|
||||
{/* CPU & Memory */}
|
||||
|
||||
362
frontend/src/features/nodes/AccessManager.tsx
Normal file
362
frontend/src/features/nodes/AccessManager.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Trash2, RefreshCw, KeyRound, ShieldQuestion, Copy } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
useRealms, useCreateRealm, useDeleteRealm,
|
||||
useAccessUsers, useUserTokens, useCreateToken, useDeleteToken,
|
||||
} from '@/hooks/use-access';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
const REALM_TYPES = ['ldap', 'ad', 'openid'];
|
||||
|
||||
// ==================== Realm dialog ====================
|
||||
|
||||
function RealmDialog({ serverId, open, onClose }: { serverId: number; open: boolean; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const createRealm = useCreateRealm(serverId);
|
||||
const [realm, setRealm] = useState('');
|
||||
const [type, setType] = useState('ldap');
|
||||
const [comment, setComment] = useState('');
|
||||
// type-specific
|
||||
const [server1, setServer1] = useState('');
|
||||
const [baseDn, setBaseDn] = useState('');
|
||||
const [userAttr, setUserAttr] = useState('uid');
|
||||
const [domain, setDomain] = useState('');
|
||||
const [issuerUrl, setIssuerUrl] = useState('');
|
||||
const [clientId, setClientId] = useState('');
|
||||
const [usernameClaim, setUsernameClaim] = useState('sub');
|
||||
const [secure, setSecure] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRealm(''); setType('ldap'); setComment('');
|
||||
setServer1(''); setBaseDn(''); setUserAttr('uid'); setDomain('');
|
||||
setIssuerUrl(''); setClientId(''); setUsernameClaim('sub'); setSecure(true);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const submit = () => {
|
||||
if (!realm.trim()) { toast.error(t('access.err_realm', 'Укажите имя realm')); return; }
|
||||
const data: Record<string, unknown> = { realm: realm.trim(), type };
|
||||
if (comment.trim()) data.comment = comment.trim();
|
||||
if (type === 'ldap') {
|
||||
data.server1 = server1.trim(); data.base_dn = baseDn.trim(); data.user_attr = userAttr.trim();
|
||||
data.secure = secure ? 1 : 0;
|
||||
} else if (type === 'ad') {
|
||||
data.server1 = server1.trim(); data.domain = domain.trim(); data.secure = secure ? 1 : 0;
|
||||
} else if (type === 'openid') {
|
||||
data['issuer-url'] = issuerUrl.trim(); data['client-id'] = clientId.trim();
|
||||
data['username-claim'] = usernameClaim.trim();
|
||||
}
|
||||
createRealm.mutate(data, {
|
||||
onSuccess: () => { toast.success(t('access.realm_created', 'Realm создан')); onClose(); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader><DialogTitle>{t('access.add_realm', 'Добавить realm')}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('access.realm_id', 'Имя (realm)')}</Label>
|
||||
<Input value={realm} onChange={(e) => setRealm(e.target.value)} className="font-mono" placeholder="company-ldap" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('access.type', 'Тип')}</Label>
|
||||
<Select value={type} onValueChange={(v) => { if (v) setType(v); }}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{REALM_TYPES.map(r => <SelectItem key={r} value={r}>{r.toUpperCase()}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(type === 'ldap' || type === 'ad') && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('access.server', 'Сервер')}</Label>
|
||||
<Input value={server1} onChange={(e) => setServer1(e.target.value)} className="font-mono" placeholder="ldap.example.com" />
|
||||
</div>
|
||||
{type === 'ldap' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5"><Label>Base DN</Label><Input value={baseDn} onChange={(e) => setBaseDn(e.target.value)} className="font-mono" placeholder="dc=example,dc=com" /></div>
|
||||
<div className="space-y-1.5"><Label>{t('access.user_attr', 'User attribute')}</Label><Input value={userAttr} onChange={(e) => setUserAttr(e.target.value)} className="font-mono" /></div>
|
||||
</div>
|
||||
)}
|
||||
{type === 'ad' && (
|
||||
<div className="space-y-1.5"><Label>{t('access.domain', 'Домен')}</Label><Input value={domain} onChange={(e) => setDomain(e.target.value)} className="font-mono" placeholder="example.com" /></div>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={secure} onChange={(e) => setSecure(e.target.checked)} />
|
||||
{t('access.secure', 'SSL/TLS (secure)')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'openid' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5"><Label>Issuer URL</Label><Input value={issuerUrl} onChange={(e) => setIssuerUrl(e.target.value)} className="font-mono" placeholder="https://idp.example.com" /></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5"><Label>Client ID</Label><Input value={clientId} onChange={(e) => setClientId(e.target.value)} className="font-mono" /></div>
|
||||
<div className="space-y-1.5"><Label>{t('access.username_claim', 'Username claim')}</Label><Input value={usernameClaim} onChange={(e) => setUsernameClaim(e.target.value)} className="font-mono" /></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5"><Label>{t('common.comment', 'Комментарий')}</Label><Input value={comment} onChange={(e) => setComment(e.target.value)} /></div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button>
|
||||
<Button onClick={submit} disabled={createRealm.isPending}>{t('common.create', 'Создать')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Token create + secret reveal ====================
|
||||
|
||||
function TokenDialog({ serverId, userid, open, onClose }: { serverId: number; userid: string; open: boolean; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const createToken = useCreateToken(serverId);
|
||||
const [tokenid, setTokenid] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const [privsep, setPrivsep] = useState(true);
|
||||
const [secret, setSecret] = useState<{ full: string; value: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { setTokenid(''); setComment(''); setPrivsep(true); setSecret(null); }
|
||||
}, [open]);
|
||||
|
||||
const submit = () => {
|
||||
if (!tokenid.trim()) { toast.error(t('access.err_tokenid', 'Укажите ID токена')); return; }
|
||||
createToken.mutate(
|
||||
{ userid, tokenid: tokenid.trim(), comment: comment.trim() || undefined, privsep },
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
if (r.value && r.full_tokenid) setSecret({ full: r.full_tokenid, value: r.value });
|
||||
else { toast.success(t('access.token_created', 'Токен создан')); onClose(); }
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const copy = (text: string) => { navigator.clipboard?.writeText(text); toast.success(t('common.copied', 'Скопировано')); };
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('access.add_token', 'Создать API-токен')}</DialogTitle>
|
||||
<DialogDescription className="font-mono text-xs">{userid}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{secret ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/5 p-3 text-xs text-amber-600 dark:text-amber-500">
|
||||
{t('access.secret_warning', 'Секрет показывается только один раз — сохраните его сейчас.')}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Token ID</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={secret.full} className="font-mono text-xs" />
|
||||
<Button size="icon" variant="outline" onClick={() => copy(secret.full)}><Copy className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('access.secret', 'Секрет')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={secret.value} className="font-mono text-xs" />
|
||||
<Button size="icon" variant="outline" onClick={() => copy(secret.value)}><Copy className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>{t('common.done', 'Готово')}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5"><Label>{t('access.token_id', 'ID токена')}</Label><Input value={tokenid} onChange={(e) => setTokenid(e.target.value)} className="font-mono" placeholder="automation" /></div>
|
||||
<div className="space-y-1.5"><Label>{t('common.comment', 'Комментарий')}</Label><Input value={comment} onChange={(e) => setComment(e.target.value)} /></div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={privsep} onChange={(e) => setPrivsep(e.target.checked)} />
|
||||
{t('access.privsep', 'Privilege separation (отдельные права токена)')}
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button>
|
||||
<Button onClick={submit} disabled={createToken.isPending}>{t('common.create', 'Создать')}</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Tokens tab ====================
|
||||
|
||||
function TokensTab({ serverId }: { serverId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data: usersData } = useAccessUsers(serverId);
|
||||
const users = usersData?.users ?? [];
|
||||
const [userid, setUserid] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (users.length > 0 && !users.some(u => u.userid === userid)) setUserid(users[0].userid);
|
||||
}, [users, userid]);
|
||||
|
||||
const { data: tokensData } = useUserTokens(serverId, userid);
|
||||
const tokens = tokensData?.tokens ?? [];
|
||||
const deleteToken = useDeleteToken(serverId, userid);
|
||||
const [dialog, setDialog] = useState(false);
|
||||
|
||||
const remove = async (tid: string) => {
|
||||
if (!await confirm(`${t('access.delete_token', 'Удалить токен')} "${tid}"?`)) return;
|
||||
deleteToken.mutate(tid, { onSuccess: () => toast.success(t('access.token_deleted', 'Токен удалён')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={userid} onValueChange={(v) => { if (v) setUserid(v); }}>
|
||||
<SelectTrigger className="w-[240px]"><SelectValue placeholder={t('access.select_user', 'Пользователь')} /></SelectTrigger>
|
||||
<SelectContent>{users.map(u => <SelectItem key={u.userid} value={u.userid}>{u.userid}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" className="ml-auto" onClick={() => setDialog(true)} disabled={!userid}>
|
||||
<Plus className="mr-1 h-4 w-4" />{t('access.add_token', 'Создать токен')}
|
||||
</Button>
|
||||
</div>
|
||||
{tokens.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead>Token ID</TableHead><TableHead>{t('common.comment', 'Комментарий')}</TableHead>
|
||||
<TableHead>privsep</TableHead><TableHead>{t('access.expire', 'Истекает')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{tokens.map(tok => (
|
||||
<TableRow key={tok.tokenid}>
|
||||
<TableCell className="font-mono font-medium">{tok.tokenid}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{tok.comment || ''}</TableCell>
|
||||
<TableCell>{tok.privsep ? <Badge variant="outline">on</Badge> : <Badge variant="secondary">off</Badge>}</TableCell>
|
||||
<TableCell className="text-xs">{tok.expire ? new Date(tok.expire * 1000).toLocaleDateString() : '—'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => remove(tok.tokenid)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<TokenDialog serverId={serverId} userid={userid} open={dialog} onClose={() => setDialog(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Main ====================
|
||||
|
||||
export default function AccessManager({ serverId }: { serverId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data: realmsData, isLoading, refetch, isFetching } = useRealms(serverId);
|
||||
const deleteRealm = useDeleteRealm(serverId);
|
||||
const realms = realmsData?.realms ?? [];
|
||||
const [realmDialog, setRealmDialog] = useState(false);
|
||||
|
||||
const removeRealm = async (r: string) => {
|
||||
if (!await confirm(`${t('access.delete_realm', 'Удалить realm')} "${r}"?`)) return;
|
||||
deleteRealm.mutate(r, { onSuccess: () => toast.success(t('access.realm_deleted', 'Realm удалён')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
|
||||
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">
|
||||
<KeyRound className="h-5 w-5" />{t('access.title', 'PVE Access')}
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="tokens">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tokens">{t('access.tokens', 'API-токены')}</TabsTrigger>
|
||||
<TabsTrigger value="realms">{t('access.realms', 'Realms')} ({realms.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="tokens" className="mt-4">
|
||||
<TokensTab serverId={serverId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="realms" className="mt-4 space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => setRealmDialog(true)}>
|
||||
<Plus className="mr-1 h-4 w-4" />{t('access.add_realm', 'Добавить realm')}
|
||||
</Button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||
) : realms.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead>{t('access.realm_id', 'Realm')}</TableHead><TableHead>{t('access.type', 'Тип')}</TableHead>
|
||||
<TableHead>TFA</TableHead><TableHead>{t('common.comment', 'Комментарий')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{realms.map(r => {
|
||||
const builtin = r.type === 'pam' || r.type === 'pve';
|
||||
return (
|
||||
<TableRow key={r.realm}>
|
||||
<TableCell className="font-mono font-medium">{r.realm}</TableCell>
|
||||
<TableCell><Badge variant="outline">{r.type}</Badge></TableCell>
|
||||
<TableCell className="text-xs">{r.tfa || '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{r.comment || ''}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{builtin ? (
|
||||
<span className="text-xs text-muted-foreground flex items-center justify-end gap-1">
|
||||
<ShieldQuestion className="h-3.5 w-3.5" />{t('access.builtin', 'встроенный')}
|
||||
</span>
|
||||
) : (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeRealm(r.realm)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
|
||||
<RealmDialog serverId={serverId} open={realmDialog} onClose={() => setRealmDialog(false)} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
336
frontend/src/features/nodes/DiskManager.tsx
Normal file
336
frontend/src/features/nodes/DiskManager.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HardDrive, RefreshCw, Activity, Trash2, Plus, Database, Eraser } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
useNodeDisks, useDiskSmart, useZfsPools, useCreateZfsPool, useDestroyZfsPool, useWipeDisk,
|
||||
type NodeDisk,
|
||||
} from '@/hooks/use-disks';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
const RAID_LEVELS = ['single', 'mirror', 'raid10', 'raidz', 'raidz2', 'raidz3'];
|
||||
const COMPRESSIONS = ['on', 'off', 'lz4', 'zstd', 'gzip'];
|
||||
|
||||
function healthVariant(h?: string): 'default' | 'secondary' | 'destructive' {
|
||||
if (!h) return 'secondary';
|
||||
const up = h.toUpperCase();
|
||||
if (up === 'PASSED' || up === 'OK' || up === 'ONLINE') return 'default';
|
||||
if (up.includes('FAIL') || up === 'DEGRADED') return 'destructive';
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
// ==================== SMART dialog ====================
|
||||
|
||||
function SmartDialog({ serverId, node, disk, onClose }: { serverId: number; node: string; disk: string | null; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useDiskSmart(serverId, node, disk ?? '', !!disk);
|
||||
const smart = data?.smart;
|
||||
const attrs = smart?.attributes ?? [];
|
||||
|
||||
return (
|
||||
<Dialog open={!!disk} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />SMART — <span className="font-mono">{disk}</span>
|
||||
</DialogTitle>
|
||||
{smart?.health && (
|
||||
<DialogDescription>
|
||||
{t('disks.health', 'Состояние')}: <Badge variant={healthVariant(smart.health)}>{smart.health}</Badge>
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
{isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||
) : smart?.error ? (
|
||||
<p className="py-6 text-center text-sm text-destructive">{smart.error}</p>
|
||||
) : attrs.length > 0 ? (
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead>ID</TableHead><TableHead>{t('disks.attribute', 'Атрибут')}</TableHead>
|
||||
<TableHead>{t('disks.value', 'Знач.')}</TableHead><TableHead>{t('disks.worst', 'Худш.')}</TableHead>
|
||||
<TableHead>{t('disks.threshold', 'Порог')}</TableHead><TableHead>RAW</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{attrs.map((a, i) => (
|
||||
<TableRow key={a.id ?? i}>
|
||||
<TableCell className="text-xs text-muted-foreground">{a.id ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs">{a.name ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs tabular-nums">{a.value ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs tabular-nums">{a.worst ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs tabular-nums">{a.threshold ?? '—'}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{a.raw ?? '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : smart?.text ? (
|
||||
<pre className="max-h-96 overflow-auto rounded-md bg-muted p-3 text-xs font-mono whitespace-pre-wrap">{smart.text}</pre>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Create ZFS pool dialog ====================
|
||||
|
||||
function CreateZfsDialog({ serverId, node, disks, open, onClose }: {
|
||||
serverId: number; node: string; disks: NodeDisk[]; open: boolean; onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const createPool = useCreateZfsPool(serverId, node);
|
||||
const [name, setName] = useState('');
|
||||
const [raidlevel, setRaid] = useState('single');
|
||||
const [ashift, setAshift] = useState('12');
|
||||
const [compression, setCompression] = useState('on');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
// Свободные диски (без файловой системы / роли) — кандидаты под пул.
|
||||
const available = useMemo(() => disks.filter(d => !d.used), [disks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { setName(''); setRaid('single'); setAshift('12'); setCompression('on'); setSelected(new Set()); }
|
||||
}, [open]);
|
||||
|
||||
const toggle = (dev: string) => setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(dev)) next.delete(dev); else next.add(dev);
|
||||
return next;
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
if (!name.trim()) { toast.error(t('disks.err_name', 'Укажите имя пула')); return; }
|
||||
if (selected.size === 0) { toast.error(t('disks.err_devices', 'Выберите диски')); return; }
|
||||
createPool.mutate(
|
||||
{ name: name.trim(), devices: Array.from(selected).join(','), raidlevel, ashift: Number(ashift), compression, add_storage: true },
|
||||
{
|
||||
onSuccess: () => { toast.success(t('disks.pool_created', 'ZFS-пул создаётся')); onClose(); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('disks.create_zfs', 'Создать ZFS-пул')}</DialogTitle>
|
||||
<DialogDescription>{t('disks.create_zfs_hint', 'Диски будут очищены и объединены в пул.')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('disks.pool_name', 'Имя пула')}</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} className="font-mono" placeholder="tank" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('disks.raid_level', 'RAID-уровень')}</Label>
|
||||
<Select value={raidlevel} onValueChange={(v) => { if (v) setRaid(v); }}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{RAID_LEVELS.map(r => <SelectItem key={r} value={r}>{r}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>ashift</Label>
|
||||
<Input type="number" min={9} max={16} value={ashift} onChange={(e) => setAshift(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('disks.compression', 'Сжатие')}</Label>
|
||||
<Select value={compression} onValueChange={(v) => { if (v) setCompression(v); }}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{COMPRESSIONS.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('disks.devices', 'Диски')} ({selected.size})</Label>
|
||||
<div className="max-h-48 space-y-1 overflow-auto rounded-md border p-2">
|
||||
{available.length === 0
|
||||
? <p className="py-2 text-center text-xs text-muted-foreground">{t('disks.no_free_disks', 'Нет свободных дисков')}</p>
|
||||
: available.map(d => (
|
||||
<label key={d.devpath} className="flex items-center gap-2 rounded px-1 py-0.5 text-sm hover:bg-muted/50">
|
||||
<input type="checkbox" checked={selected.has(d.devpath)} onChange={() => toggle(d.devpath)} />
|
||||
<span className="font-mono text-xs">{d.devpath}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatBytes(d.size || 0)}</span>
|
||||
{d.model && <span className="text-xs text-muted-foreground truncate">{d.model}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button>
|
||||
<Button onClick={submit} disabled={createPool.isPending}>{t('common.create', 'Создать')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Main ====================
|
||||
|
||||
export default function DiskManager({ serverId, nodeNames }: { serverId: number; nodeNames: string[] }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const [node, setNode] = useState('');
|
||||
useEffect(() => {
|
||||
if (nodeNames.length > 0 && !nodeNames.includes(node)) setNode(nodeNames[0]);
|
||||
}, [nodeNames, node]);
|
||||
|
||||
const { data: disksData, isLoading, refetch, isFetching } = useNodeDisks(serverId, node);
|
||||
const { data: zfsData } = useZfsPools(serverId, node);
|
||||
const destroyPool = useDestroyZfsPool(serverId, node);
|
||||
const wipeDisk = useWipeDisk(serverId, node);
|
||||
|
||||
const disks = disksData?.disks ?? [];
|
||||
const pools = zfsData?.pools ?? [];
|
||||
|
||||
const [smartDisk, setSmartDisk] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const wipe = async (dev: string) => {
|
||||
if (!await confirm(`${t('disks.wipe_confirm', 'Очистить диск (все данные будут уничтожены)')} ${dev}?`)) return;
|
||||
wipeDisk.mutate(dev, { onSuccess: () => toast.success(t('disks.wiped', 'Диск очищается')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
const destroy = async (name: string) => {
|
||||
if (!await confirm(`${t('disks.destroy_pool', 'Уничтожить ZFS-пул')} "${name}"?`)) return;
|
||||
destroyPool.mutate({ name }, { onSuccess: () => toast.success(t('disks.pool_destroyed', 'Пул уничтожается')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
|
||||
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">
|
||||
<HardDrive className="h-5 w-5" />{t('disks.title', 'Диски и ZFS')}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{nodeNames.length > 1 && (
|
||||
<Select value={node} onValueChange={(v) => { if (v) setNode(v); }}>
|
||||
<SelectTrigger className="w-[140px] h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{nodeNames.map(n => <SelectItem key={n} value={n}>{n}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!node ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<Tabs defaultValue="disks">
|
||||
<TabsList>
|
||||
<TabsTrigger value="disks">{t('disks.physical', 'Диски')} ({disks.length})</TabsTrigger>
|
||||
<TabsTrigger value="zfs">ZFS ({pools.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Physical disks */}
|
||||
<TabsContent value="disks" className="mt-4">
|
||||
{isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||
) : disks.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead>{t('disks.device', 'Устройство')}</TableHead>
|
||||
<TableHead>{t('disks.type', 'Тип')}</TableHead>
|
||||
<TableHead>{t('disks.model', 'Модель')}</TableHead>
|
||||
<TableHead>{t('disks.size', 'Объём')}</TableHead>
|
||||
<TableHead>{t('disks.usage', 'Использование')}</TableHead>
|
||||
<TableHead>{t('disks.health', 'Состояние')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{disks.map(d => (
|
||||
<TableRow key={d.devpath}>
|
||||
<TableCell className="font-mono text-xs font-medium">{d.devpath}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="uppercase text-[10px]">{d.type || '—'}</Badge></TableCell>
|
||||
<TableCell className="text-xs">{d.model || '—'}{d.serial ? <span className="block text-[10px] text-muted-foreground font-mono">{d.serial}</span> : null}</TableCell>
|
||||
<TableCell className="text-xs tabular-nums">{formatBytes(d.size || 0)}</TableCell>
|
||||
<TableCell className="text-xs">{d.used || <span className="text-muted-foreground">{t('disks.free', 'свободен')}</span>}</TableCell>
|
||||
<TableCell>
|
||||
{d.health ? <Badge variant={healthVariant(d.health)}>{d.health}</Badge> : <span className="text-xs text-muted-foreground">—</span>}
|
||||
{d.wearout != null && d.wearout !== '' && <span className="ml-1 text-[10px] text-muted-foreground">wear {d.wearout}%</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" title="SMART" onClick={() => setSmartDisk(d.devpath)}>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" title={t('disks.wipe', 'Очистить')}
|
||||
disabled={!!d.used} onClick={() => wipe(d.devpath)}>
|
||||
<Eraser className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ZFS pools */}
|
||||
<TabsContent value="zfs" className="mt-4 space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1 h-4 w-4" />{t('disks.create_zfs', 'Создать ZFS-пул')}
|
||||
</Button>
|
||||
</div>
|
||||
{pools.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead>{t('disks.pool_name', 'Имя')}</TableHead>
|
||||
<TableHead>{t('disks.size', 'Объём')}</TableHead>
|
||||
<TableHead>{t('disks.alloc', 'Занято')}</TableHead>
|
||||
<TableHead>{t('disks.free', 'Свободно')}</TableHead>
|
||||
<TableHead>{t('disks.health', 'Состояние')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{pools.map(p => (
|
||||
<TableRow key={p.name}>
|
||||
<TableCell className="font-mono font-medium flex items-center gap-2"><Database className="h-3.5 w-3.5 text-muted-foreground" />{p.name}</TableCell>
|
||||
<TableCell className="text-xs tabular-nums">{p.size ? formatBytes(p.size) : '—'}</TableCell>
|
||||
<TableCell className="text-xs tabular-nums">{p.alloc != null ? formatBytes(p.alloc) : '—'}</TableCell>
|
||||
<TableCell className="text-xs tabular-nums">{p.free != null ? formatBytes(p.free) : '—'}</TableCell>
|
||||
<TableCell>{p.health ? <Badge variant={healthVariant(p.health)}>{p.health}</Badge> : '—'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => destroy(p.name)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<SmartDialog serverId={serverId} node={node} disk={smartDisk} onClose={() => setSmartDisk(null)} />
|
||||
<CreateZfsDialog serverId={serverId} node={node} disks={disks} open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
685
frontend/src/features/nodes/FirewallManager.tsx
Normal file
685
frontend/src/features/nodes/FirewallManager.tsx
Normal file
@@ -0,0 +1,685 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Trash2, RefreshCw, Shield, ChevronRight, Power } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
useFirewallOptions, useUpdateFirewallOptions,
|
||||
useFirewallRules, useCreateFirewallRule, useDeleteFirewallRule, useUpdateFirewallRule,
|
||||
useFirewallGroups, useCreateFirewallGroup, useDeleteFirewallGroup,
|
||||
useFirewallGroupRules, useCreateFirewallGroupRule, useDeleteFirewallGroupRule,
|
||||
useFirewallIpsets, useCreateFirewallIpset, useDeleteFirewallIpset,
|
||||
useFirewallIpsetEntries, useAddIpsetEntry, useDeleteIpsetEntry,
|
||||
useFirewallAliases, useCreateFirewallAlias, useDeleteFirewallAlias,
|
||||
type FirewallRule, type FirewallGroup,
|
||||
} from '@/hooks/use-firewall';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
const POLICIES = ['ACCEPT', 'REJECT', 'DROP'];
|
||||
const ACTIONS = ['ACCEPT', 'DROP', 'REJECT'];
|
||||
const PROTOS = ['', 'tcp', 'udp', 'icmp'];
|
||||
|
||||
// ==================== Rule dialog (shared: datacenter + group rules) ====================
|
||||
|
||||
interface RuleForm {
|
||||
type: string; // 'in' | 'out' | 'group'
|
||||
action: string; // ACCEPT/DROP/REJECT | <group name>
|
||||
enable: boolean;
|
||||
proto: string;
|
||||
source: string;
|
||||
dest: string;
|
||||
dport: string;
|
||||
sport: string;
|
||||
macro: string;
|
||||
comment: string;
|
||||
log: boolean;
|
||||
}
|
||||
|
||||
const emptyRule: RuleForm = {
|
||||
type: 'in', action: 'ACCEPT', enable: true, proto: '', source: '', dest: '',
|
||||
dport: '', sport: '', macro: '', comment: '', log: false,
|
||||
};
|
||||
|
||||
export function RuleDialog({
|
||||
open, onOpenChange, onSubmit, pending, groups, allowGroupType,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
onSubmit: (data: Record<string, unknown>) => void;
|
||||
pending: boolean;
|
||||
groups: FirewallGroup[];
|
||||
allowGroupType: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<RuleForm>(emptyRule);
|
||||
const set = <K extends keyof RuleForm>(k: K, v: RuleForm[K]) => setForm(p => ({ ...p, [k]: v }));
|
||||
|
||||
const isGroup = form.type === 'group';
|
||||
|
||||
const submit = () => {
|
||||
if (isGroup && !form.action) {
|
||||
toast.error(t('fw.err_group_required', 'Выберите security group'));
|
||||
return;
|
||||
}
|
||||
const payload: Record<string, unknown> = {
|
||||
type: form.type,
|
||||
action: form.action,
|
||||
enable: form.enable ? 1 : 0,
|
||||
};
|
||||
if (!isGroup) {
|
||||
if (form.proto) payload.proto = form.proto;
|
||||
if (form.source) payload.source = form.source;
|
||||
if (form.dest) payload.dest = form.dest;
|
||||
if (form.dport) payload.dport = form.dport;
|
||||
if (form.sport) payload.sport = form.sport;
|
||||
if (form.macro) payload.macro = form.macro;
|
||||
if (form.log) payload.log = 'info';
|
||||
}
|
||||
if (form.comment) payload.comment = form.comment;
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) setForm(emptyRule); }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('fw.add_rule', 'Добавить правило')}</DialogTitle>
|
||||
<DialogDescription>{t('fw.rule_hint', 'Правила применяются сверху вниз.')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('fw.direction', 'Направление')}</Label>
|
||||
<Select value={form.type} onValueChange={(v) => { if (v) { set('type', v); set('action', v === 'group' ? '' : 'ACCEPT'); } }}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="in">in</SelectItem>
|
||||
<SelectItem value="out">out</SelectItem>
|
||||
{allowGroupType && <SelectItem value="group">{t('fw.security_group', 'Security group')}</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{isGroup ? t('fw.security_group', 'Security group') : t('fw.action', 'Действие')}</Label>
|
||||
<Select value={form.action} onValueChange={(v) => { if (v) set('action', v); }}>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder={isGroup ? t('fw.select_group', 'Выберите группу') : ''} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{isGroup
|
||||
? groups.map(g => <SelectItem key={g.group} value={g.group}>{g.group}</SelectItem>)
|
||||
: ACTIONS.map(a => <SelectItem key={a} value={a}>{a}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isGroup && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('fw.proto', 'Протокол')}</Label>
|
||||
<Select value={form.proto} onValueChange={(v) => set('proto', v === 'any' ? '' : (v ?? ''))}>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder="any" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROTOS.map(p => <SelectItem key={p || 'any'} value={p || 'any'}>{p || 'any'}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('fw.macro', 'Макрос')}</Label>
|
||||
<Input value={form.macro} onChange={(e) => set('macro', e.target.value)} placeholder="HTTP, SSH…" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('fw.source', 'Источник')}</Label>
|
||||
<Input value={form.source} onChange={(e) => set('source', e.target.value)} placeholder="10.0.0.0/24, +ipset, alias" className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('fw.dest', 'Назначение')}</Label>
|
||||
<Input value={form.dest} onChange={(e) => set('dest', e.target.value)} placeholder="10.0.0.5" className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('fw.dport', 'Порт назн.')}</Label>
|
||||
<Input value={form.dport} onChange={(e) => set('dport', e.target.value)} placeholder="80, 443, 8000:8100" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('fw.sport', 'Порт источн.')}</Label>
|
||||
<Input value={form.sport} onChange={(e) => set('sport', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('common.comment', 'Комментарий')}</Label>
|
||||
<Input value={form.comment} onChange={(e) => set('comment', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.enable} onChange={(e) => set('enable', e.target.checked)} />
|
||||
{t('fw.enabled', 'Включено')}
|
||||
</label>
|
||||
{!isGroup && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.log} onChange={(e) => set('log', e.target.checked)} />
|
||||
{t('fw.log', 'Логировать')}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel', 'Отмена')}</Button>
|
||||
<Button onClick={submit} disabled={pending}>{t('common.add', 'Добавить')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Rule row (shared render) ====================
|
||||
|
||||
export function RuleRow({ rule, onToggle, onDelete }: {
|
||||
rule: FirewallRule; onToggle?: () => void; onDelete: () => void;
|
||||
}) {
|
||||
const disabled = rule.enable === 0;
|
||||
const actionColor = rule.action === 'ACCEPT' ? 'text-green-600 dark:text-green-500'
|
||||
: rule.action === 'DROP' || rule.action === 'REJECT' ? 'text-destructive' : '';
|
||||
return (
|
||||
<TableRow className={disabled ? 'opacity-50' : ''}>
|
||||
<TableCell className="text-muted-foreground tabular-nums">{rule.pos}</TableCell>
|
||||
<TableCell><Badge variant="outline">{rule.type}</Badge></TableCell>
|
||||
<TableCell className={`font-medium ${actionColor}`}>{rule.macro || rule.action}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{rule.proto || '—'}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{rule.source || '*'} → {rule.dest || '*'}{rule.dport ? `:${rule.dport}` : ''}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground truncate max-w-[160px]">{rule.comment || ''}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{onToggle && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onToggle} title={disabled ? 'Enable' : 'Disable'}>
|
||||
<Power className={`h-3.5 w-3.5 ${disabled ? '' : 'text-green-600 dark:text-green-500'}`} />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={onDelete}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Security group section (expandable rules) ====================
|
||||
|
||||
function GroupRules({ serverId, group }: { serverId: number; group: string }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data } = useFirewallGroupRules(serverId, group);
|
||||
const createRule = useCreateFirewallGroupRule(serverId, group);
|
||||
const deleteRule = useDeleteFirewallGroupRule(serverId, group);
|
||||
const [dialog, setDialog] = useState(false);
|
||||
const rules = data?.rules ?? [];
|
||||
|
||||
const add = (payload: Record<string, unknown>) => {
|
||||
createRule.mutate(payload, {
|
||||
onSuccess: () => { toast.success(t('fw.rule_added', 'Правило добавлено')); setDialog(false); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
const remove = async (pos?: number) => {
|
||||
if (pos == null || !await confirm(t('fw.delete_rule_confirm', 'Удалить правило?'))) return;
|
||||
deleteRule.mutate(pos, { onSuccess: () => toast.success(t('fw.rule_deleted', 'Правило удалено')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => setDialog(true)}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />{t('fw.add_rule', 'Добавить правило')}
|
||||
</Button>
|
||||
</div>
|
||||
{rules.length === 0 ? (
|
||||
<p className="py-3 text-center text-xs text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableBody>
|
||||
{rules.map(r => <RuleRow key={r.pos} rule={r} onDelete={() => remove(r.pos)} />)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<RuleDialog open={dialog} onOpenChange={setDialog} onSubmit={add} pending={createRule.isPending} groups={[]} allowGroupType={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== IP set entries (expandable) ====================
|
||||
|
||||
function IpsetEntries({ serverId, name }: { serverId: number; name: string }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data } = useFirewallIpsetEntries(serverId, name);
|
||||
const addEntry = useAddIpsetEntry(serverId, name);
|
||||
const delEntry = useDeleteIpsetEntry(serverId, name);
|
||||
const [cidr, setCidr] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const entries = data?.entries ?? [];
|
||||
|
||||
const add = () => {
|
||||
if (!cidr.trim()) { toast.error(t('fw.err_cidr', 'Укажите CIDR')); return; }
|
||||
addEntry.mutate({ cidr: cidr.trim(), comment: comment.trim() || undefined }, {
|
||||
onSuccess: () => { toast.success(t('fw.entry_added', 'Запись добавлена')); setCidr(''); setComment(''); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
const remove = async (c: string) => {
|
||||
if (!await confirm(`${t('fw.delete_entry', 'Удалить запись')} ${c}?`)) return;
|
||||
delEntry.mutate(c, { onSuccess: () => toast.success(t('fw.entry_deleted', 'Запись удалена')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 space-y-1"><Label className="text-xs">CIDR</Label><Input value={cidr} onChange={(e) => setCidr(e.target.value)} placeholder="10.0.0.0/24" className="font-mono h-8" /></div>
|
||||
<div className="flex-1 space-y-1"><Label className="text-xs">{t('common.comment', 'Комментарий')}</Label><Input value={comment} onChange={(e) => setComment(e.target.value)} className="h-8" /></div>
|
||||
<Button size="sm" onClick={add} disabled={addEntry.isPending}><Plus className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<p className="py-2 text-center text-xs text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{entries.map(e => (
|
||||
<div key={e.cidr} className="flex items-center gap-2 rounded border px-2 py-1 text-xs">
|
||||
<span className="font-mono">{e.cidr}</span>
|
||||
{e.nomatch ? <Badge variant="outline" className="text-[10px]">nomatch</Badge> : null}
|
||||
{e.comment && <span className="text-muted-foreground">— {e.comment}</span>}
|
||||
<Button variant="ghost" size="icon" className="ml-auto h-6 w-6 text-destructive" onClick={() => remove(e.cidr)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Main ====================
|
||||
|
||||
export default function FirewallManager({ serverId }: { serverId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const { data: optionsData, isLoading, refetch, isFetching } = useFirewallOptions(serverId);
|
||||
const { data: rulesData } = useFirewallRules(serverId);
|
||||
const { data: groupsData } = useFirewallGroups(serverId);
|
||||
const { data: ipsetsData } = useFirewallIpsets(serverId);
|
||||
const { data: aliasesData } = useFirewallAliases(serverId);
|
||||
|
||||
const updateOptions = useUpdateFirewallOptions(serverId);
|
||||
const createRule = useCreateFirewallRule(serverId);
|
||||
const updateRule = useUpdateFirewallRule(serverId);
|
||||
const deleteRule = useDeleteFirewallRule(serverId);
|
||||
const createGroup = useCreateFirewallGroup(serverId);
|
||||
const deleteGroup = useDeleteFirewallGroup(serverId);
|
||||
const createIpset = useCreateFirewallIpset(serverId);
|
||||
const deleteIpset = useDeleteFirewallIpset(serverId);
|
||||
const createAlias = useCreateFirewallAlias(serverId);
|
||||
const deleteAlias = useDeleteFirewallAlias(serverId);
|
||||
|
||||
const options = optionsData?.options ?? {};
|
||||
const rules = rulesData?.rules ?? [];
|
||||
const groups = groupsData?.groups ?? [];
|
||||
const ipsets = ipsetsData?.ipsets ?? [];
|
||||
const aliases = aliasesData?.aliases ?? [];
|
||||
|
||||
const [ruleDialog, setRuleDialog] = useState(false);
|
||||
const [groupDialog, setGroupDialog] = useState(false);
|
||||
const [ipsetDialog, setIpsetDialog] = useState(false);
|
||||
const [aliasDialog, setAliasDialog] = useState(false);
|
||||
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
|
||||
const [expandedIpset, setExpandedIpset] = useState<string | null>(null);
|
||||
|
||||
// ---- Options local state ----
|
||||
const enabled = Number(options.enable) === 1;
|
||||
const toggleEnable = () => {
|
||||
updateOptions.mutate({ enable: enabled ? 0 : 1 }, {
|
||||
onSuccess: () => toast.success(t('fw.options_saved', 'Опции сохранены')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
const setPolicy = (dir: 'policy_in' | 'policy_out', v: string) => {
|
||||
updateOptions.mutate({ [dir]: v }, {
|
||||
onSuccess: () => toast.success(t('fw.options_saved', 'Опции сохранены')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
// ---- Rules ----
|
||||
const addRule = (payload: Record<string, unknown>) => {
|
||||
createRule.mutate(payload, {
|
||||
onSuccess: () => { toast.success(t('fw.rule_added', 'Правило добавлено')); setRuleDialog(false); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
const toggleRule = (r: FirewallRule) => {
|
||||
if (r.pos == null) return;
|
||||
updateRule.mutate({ pos: r.pos, data: { enable: r.enable === 0 ? 1 : 0 } }, {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
const removeRule = async (pos?: number) => {
|
||||
if (pos == null || !await confirm(t('fw.delete_rule_confirm', 'Удалить правило?'))) return;
|
||||
deleteRule.mutate(pos, { onSuccess: () => toast.success(t('fw.rule_deleted', 'Правило удалено')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
|
||||
// ---- Groups / ipsets / aliases delete ----
|
||||
const removeGroup = async (g: string) => {
|
||||
if (!await confirm(`${t('fw.delete_group', 'Удалить security group')} "${g}"?`)) return;
|
||||
deleteGroup.mutate(g, { onSuccess: () => toast.success(t('fw.group_deleted', 'Группа удалена')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
const removeIpset = async (n: string) => {
|
||||
if (!await confirm(`${t('fw.delete_ipset', 'Удалить IP set')} "${n}"?`)) return;
|
||||
deleteIpset.mutate(n, { onSuccess: () => toast.success(t('fw.ipset_deleted', 'IP set удалён')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
const removeAlias = async (n: string) => {
|
||||
if (!await confirm(`${t('fw.delete_alias', 'Удалить алиас')} "${n}"?`)) return;
|
||||
deleteAlias.mutate(n, { onSuccess: () => toast.success(t('fw.alias_deleted', 'Алиас удалён')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
|
||||
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">
|
||||
<Shield className="h-5 w-5" />
|
||||
{t('fw.title', 'Firewall (датацентр)')}
|
||||
<Badge variant={enabled ? 'default' : 'secondary'}>
|
||||
{enabled ? t('fw.on', 'вкл') : t('fw.off', 'выкл')}
|
||||
</Badge>
|
||||
</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" variant={enabled ? 'outline' : 'default'} onClick={toggleEnable} disabled={updateOptions.isPending}>
|
||||
<Power className="mr-1 h-4 w-4" />{enabled ? t('fw.disable', 'Выключить') : t('fw.enable', 'Включить')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||
) : (
|
||||
<Tabs defaultValue="rules">
|
||||
<TabsList>
|
||||
<TabsTrigger value="rules">{t('fw.rules', 'Правила')} ({rules.length})</TabsTrigger>
|
||||
<TabsTrigger value="groups">{t('fw.groups', 'Security Groups')} ({groups.length})</TabsTrigger>
|
||||
<TabsTrigger value="ipsets">{t('fw.ipsets', 'IP Sets')} ({ipsets.length})</TabsTrigger>
|
||||
<TabsTrigger value="aliases">{t('fw.aliases', 'Aliases')} ({aliases.length})</TabsTrigger>
|
||||
<TabsTrigger value="options">{t('fw.options', 'Опции')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Rules */}
|
||||
<TabsContent value="rules" className="mt-4 space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => setRuleDialog(true)}>
|
||||
<Plus className="mr-1 h-4 w-4" />{t('fw.add_rule', 'Добавить правило')}
|
||||
</Button>
|
||||
</div>
|
||||
{rules.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>{t('fw.direction', 'Напр.')}</TableHead>
|
||||
<TableHead>{t('fw.action', 'Действие')}</TableHead>
|
||||
<TableHead>{t('fw.proto', 'Прото')}</TableHead>
|
||||
<TableHead>{t('fw.match', 'Источник → Назначение')}</TableHead>
|
||||
<TableHead>{t('common.comment', 'Комментарий')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.map(r => (
|
||||
<RuleRow key={r.pos} rule={r} onToggle={() => toggleRule(r)} onDelete={() => removeRule(r.pos)} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Security groups */}
|
||||
<TabsContent value="groups" className="mt-4 space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => setGroupDialog(true)}>
|
||||
<Plus className="mr-1 h-4 w-4" />{t('fw.add_group', 'Добавить группу')}
|
||||
</Button>
|
||||
</div>
|
||||
{groups.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{groups.map(g => (
|
||||
<div key={g.group} className="rounded-md border">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<button className="flex flex-1 items-center gap-2 text-left" onClick={() => setExpandedGroup(p => p === g.group ? null : g.group)}>
|
||||
<ChevronRight className={`h-4 w-4 transition-transform ${expandedGroup === g.group ? 'rotate-90' : ''}`} />
|
||||
<span className="font-mono font-medium">{g.group}</span>
|
||||
{g.comment && <span className="text-xs text-muted-foreground">— {g.comment}</span>}
|
||||
</button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeGroup(g.group)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{expandedGroup === g.group && (
|
||||
<div className="px-3 pb-3"><GroupRules serverId={serverId} group={g.group} /></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* IP sets */}
|
||||
<TabsContent value="ipsets" className="mt-4 space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => setIpsetDialog(true)}>
|
||||
<Plus className="mr-1 h-4 w-4" />{t('fw.add_ipset', 'Добавить IP set')}
|
||||
</Button>
|
||||
</div>
|
||||
{ipsets.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{ipsets.map(s => (
|
||||
<div key={s.name} className="rounded-md border">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<button className="flex flex-1 items-center gap-2 text-left" onClick={() => setExpandedIpset(p => p === s.name ? null : s.name)}>
|
||||
<ChevronRight className={`h-4 w-4 transition-transform ${expandedIpset === s.name ? 'rotate-90' : ''}`} />
|
||||
<span className="font-mono font-medium">{s.name}</span>
|
||||
{s.comment && <span className="text-xs text-muted-foreground">— {s.comment}</span>}
|
||||
</button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeIpset(s.name)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{expandedIpset === s.name && (
|
||||
<div className="px-3 pb-3"><IpsetEntries serverId={serverId} name={s.name} /></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Aliases */}
|
||||
<TabsContent value="aliases" className="mt-4 space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => setAliasDialog(true)}>
|
||||
<Plus className="mr-1 h-4 w-4" />{t('fw.add_alias', 'Добавить алиас')}
|
||||
</Button>
|
||||
</div>
|
||||
{aliases.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('fw.name', 'Имя')}</TableHead>
|
||||
<TableHead>CIDR</TableHead>
|
||||
<TableHead>{t('common.comment', 'Комментарий')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{aliases.map(a => (
|
||||
<TableRow key={a.name}>
|
||||
<TableCell className="font-mono font-medium">{a.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{a.cidr}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{a.comment || ''}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeAlias(a.name)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Options */}
|
||||
<TabsContent value="options" className="mt-4 space-y-4 max-w-md">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||
<input type="checkbox" checked={enabled} onChange={toggleEnable} className="h-4 w-4 accent-primary" />
|
||||
<span className="text-sm">{t('fw.enable_dc', 'Включить фаервол датацентра')}</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('fw.policy_in', 'Политика входящих')}</Label>
|
||||
<Select value={options.policy_in || 'DROP'} onValueChange={(v) => { if (v) setPolicy('policy_in', v); }}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{POLICIES.map(p => <SelectItem key={p} value={p}>{p}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('fw.policy_out', 'Политика исходящих')}</Label>
|
||||
<Select value={options.policy_out || 'ACCEPT'} onValueChange={(v) => { if (v) setPolicy('policy_out', v); }}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{POLICIES.map(p => <SelectItem key={p} value={p}>{p}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('fw.options_hint', 'Политики применяются к трафику, не совпавшему ни с одним правилом.')}</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Dialogs */}
|
||||
<RuleDialog open={ruleDialog} onOpenChange={setRuleDialog} onSubmit={addRule} pending={createRule.isPending} groups={groups} allowGroupType />
|
||||
|
||||
<SimpleCreateDialog
|
||||
open={groupDialog} onOpenChange={setGroupDialog}
|
||||
title={t('fw.add_group', 'Добавить security group')}
|
||||
nameLabel={t('fw.group_name', 'Имя группы')}
|
||||
withCidr={false}
|
||||
pending={createGroup.isPending}
|
||||
onSubmit={({ name, comment }) => createGroup.mutate({ group: name, comment }, {
|
||||
onSuccess: () => { toast.success(t('fw.group_added', 'Группа создана')); setGroupDialog(false); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})}
|
||||
/>
|
||||
|
||||
<SimpleCreateDialog
|
||||
open={ipsetDialog} onOpenChange={setIpsetDialog}
|
||||
title={t('fw.add_ipset', 'Добавить IP set')}
|
||||
nameLabel={t('fw.ipset_name', 'Имя IP set')}
|
||||
withCidr={false}
|
||||
pending={createIpset.isPending}
|
||||
onSubmit={({ name, comment }) => createIpset.mutate({ name, comment }, {
|
||||
onSuccess: () => { toast.success(t('fw.ipset_added', 'IP set создан')); setIpsetDialog(false); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})}
|
||||
/>
|
||||
|
||||
<SimpleCreateDialog
|
||||
open={aliasDialog} onOpenChange={setAliasDialog}
|
||||
title={t('fw.add_alias', 'Добавить алиас')}
|
||||
nameLabel={t('fw.name', 'Имя')}
|
||||
withCidr
|
||||
pending={createAlias.isPending}
|
||||
onSubmit={({ name, cidr, comment }) => createAlias.mutate({ name, cidr: cidr!, comment }, {
|
||||
onSuccess: () => { toast.success(t('fw.alias_added', 'Алиас создан')); setAliasDialog(false); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Simple create dialog (group / ipset / alias) ====================
|
||||
|
||||
function SimpleCreateDialog({
|
||||
open, onOpenChange, title, nameLabel, withCidr, pending, onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
title: string;
|
||||
nameLabel: string;
|
||||
withCidr: boolean;
|
||||
pending: boolean;
|
||||
onSubmit: (data: { name: string; cidr?: string; comment?: string }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
const [cidr, setCidr] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const reset = () => { setName(''); setCidr(''); setComment(''); };
|
||||
|
||||
const submit = () => {
|
||||
if (!name.trim()) { toast.error(t('fw.err_name', 'Укажите имя')); return; }
|
||||
if (withCidr && !cidr.trim()) { toast.error(t('fw.err_cidr', 'Укажите CIDR')); return; }
|
||||
onSubmit({ name: name.trim(), cidr: withCidr ? cidr.trim() : undefined, comment: comment.trim() || undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) reset(); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{nameLabel}</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} className="font-mono" />
|
||||
</div>
|
||||
{withCidr && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>CIDR / IP</Label>
|
||||
<Input value={cidr} onChange={(e) => setCidr(e.target.value)} placeholder="10.0.0.0/24" className="font-mono" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('common.comment', 'Комментарий')}</Label>
|
||||
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel', 'Отмена')}</Button>
|
||||
<Button onClick={submit} disabled={pending}>{t('common.add', 'Добавить')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
186
frontend/src/features/nodes/NodeAdminManager.tsx
Normal file
186
frontend/src/features/nodes/NodeAdminManager.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCw, Server, Play, Square, RotateCw, PackageCheck } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
useNodeServices, useServiceAction,
|
||||
useAptUpdates, useAptRefresh, useAptRepositories, useSetAptRepository,
|
||||
} from '@/hooks/use-node-admin';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
function stateVariant(s?: string): 'default' | 'secondary' | 'destructive' {
|
||||
if (!s) return 'secondary';
|
||||
if (s === 'running' || s === 'active') return 'default';
|
||||
if (s === 'dead' || s === 'failed') return 'destructive';
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
export default function NodeAdminManager({ serverId, nodeNames }: { serverId: number; nodeNames: string[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [node, setNode] = useState('');
|
||||
useEffect(() => {
|
||||
if (nodeNames.length > 0 && !nodeNames.includes(node)) setNode(nodeNames[0]);
|
||||
}, [nodeNames, node]);
|
||||
|
||||
const { data: servicesData, refetch, isFetching } = useNodeServices(serverId, node);
|
||||
const serviceAction = useServiceAction(serverId, node);
|
||||
const [aptOpen, setAptOpen] = useState(false);
|
||||
const [reposOpen, setReposOpen] = useState(false);
|
||||
const { data: updatesData } = useAptUpdates(serverId, node, aptOpen);
|
||||
const aptRefresh = useAptRefresh(serverId, node);
|
||||
const { data: reposData } = useAptRepositories(serverId, node, reposOpen);
|
||||
const setRepo = useSetAptRepository(serverId, node);
|
||||
|
||||
const services = servicesData?.services ?? [];
|
||||
const updates = updatesData?.updates ?? [];
|
||||
const files = reposData?.repositories?.files ?? [];
|
||||
|
||||
const doAction = (service: string, action: string) => {
|
||||
serviceAction.mutate({ service, action }, {
|
||||
onSuccess: () => toast.success(`${service}: ${action}`),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
const refreshApt = () => {
|
||||
aptRefresh.mutate(undefined, {
|
||||
onSuccess: () => toast.success(t('nodeadm.apt_refreshed', 'Списки пакетов обновляются')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
const toggleRepo = (path: string, index: number, enabled: boolean) => {
|
||||
setRepo.mutate({ path, index, enabled }, {
|
||||
onSuccess: () => toast.success(t('nodeadm.repo_updated', 'Репозиторий обновлён')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
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">
|
||||
<Server className="h-5 w-5" />{t('nodeadm.title', 'Администрирование ноды')}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{nodeNames.length > 1 && (
|
||||
<Select value={node} onValueChange={(v) => { if (v) setNode(v); }}>
|
||||
<SelectTrigger className="w-[140px] h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{nodeNames.map(n => <SelectItem key={n} value={n}>{n}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!node ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<Tabs defaultValue="services" onValueChange={(v) => { setAptOpen(v === 'updates'); setReposOpen(v === 'repos'); }}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="services">{t('nodeadm.services', 'Сервисы')} ({services.length})</TabsTrigger>
|
||||
<TabsTrigger value="updates">{t('nodeadm.updates', 'Обновления')}</TabsTrigger>
|
||||
<TabsTrigger value="repos">{t('nodeadm.repos', 'Репозитории')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Services */}
|
||||
<TabsContent value="services" className="mt-4">
|
||||
{services.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead>{t('nodeadm.service', 'Сервис')}</TableHead>
|
||||
<TableHead>{t('nodeadm.desc', 'Описание')}</TableHead>
|
||||
<TableHead>{t('common.status', 'Статус')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{services.map(s => (
|
||||
<TableRow key={s.service}>
|
||||
<TableCell className="font-mono text-xs font-medium">{s.service}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground truncate max-w-[280px]">{s.desc || s.name}</TableCell>
|
||||
<TableCell><Badge variant={stateVariant(s.state)}>{s.state || '—'}</Badge></TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" title="start" onClick={() => doAction(s.service, 'start')}><Play className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" title="restart" onClick={() => doAction(s.service, 'restart')}><RotateCw className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" title="stop" onClick={() => doAction(s.service, 'stop')}><Square className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Updates */}
|
||||
<TabsContent value="updates" className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{updates.length > 0
|
||||
? t('nodeadm.updates_count', 'Доступно обновлений: {{n}}', { n: updates.length })
|
||||
: t('nodeadm.up_to_date', 'Обновлений нет')}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={refreshApt} disabled={aptRefresh.isPending}>
|
||||
<PackageCheck className="mr-1 h-4 w-4" />{t('nodeadm.refresh', 'Проверить (apt update)')}
|
||||
</Button>
|
||||
</div>
|
||||
{updates.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead>{t('nodeadm.package', 'Пакет')}</TableHead>
|
||||
<TableHead>{t('nodeadm.current', 'Текущая')}</TableHead>
|
||||
<TableHead>{t('nodeadm.new', 'Новая')}</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{updates.map(u => (
|
||||
<TableRow key={u.Package}>
|
||||
<TableCell className="font-mono text-xs font-medium">{u.Package}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">{u.OldVersion || '—'}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-green-600 dark:text-green-500">{u.Version || '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{t('nodeadm.upgrade_hint', 'Установка обновлений выполняется в консоли ноды (apt dist-upgrade) — API не поддерживает неинтерактивный upgrade.')}</p>
|
||||
</TabsContent>
|
||||
|
||||
{/* Repositories */}
|
||||
<TabsContent value="repos" className="mt-4 space-y-4">
|
||||
{files.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : files.map(f => (
|
||||
<div key={f.path} className="space-y-2">
|
||||
<p className="font-mono text-xs text-muted-foreground">{f.path}</p>
|
||||
<div className="space-y-1">
|
||||
{(f.repositories ?? []).map((r, idx) => {
|
||||
const enabled = r.Enabled === 1 || r.Enabled === true;
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-2 rounded border px-2 py-1.5 text-xs">
|
||||
<Badge variant={enabled ? 'default' : 'secondary'} className="text-[10px]">{enabled ? 'on' : 'off'}</Badge>
|
||||
<span className="font-mono truncate">{(r.URIs || []).join(' ')} {(r.Suites || []).join(' ')} {(r.Components || []).join(' ')}</span>
|
||||
<Button size="sm" variant="outline" className="ml-auto h-6 text-[11px]"
|
||||
onClick={() => toggleRepo(f.path, idx, !enabled)} disabled={setRepo.isPending}>
|
||||
{enabled ? t('nodeadm.disable', 'Выключить') : t('nodeadm.enable', 'Включить')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,12 @@ import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
|
||||
import NodeNetworks from './NodeNetworks';
|
||||
import HAResources from './HAResources';
|
||||
import SDNManager from './SDNManager';
|
||||
import FirewallManager from './FirewallManager';
|
||||
import NodeFirewallManager from './NodeFirewallManager';
|
||||
import PoolManager from './PoolManager';
|
||||
import DiskManager from './DiskManager';
|
||||
import AccessManager from './AccessManager';
|
||||
import NodeAdminManager from './NodeAdminManager';
|
||||
|
||||
export default function NodeDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -88,6 +94,39 @@ export default function NodeDetailPage() {
|
||||
<SDNManager serverId={sid} />
|
||||
)}
|
||||
|
||||
{/* Datacenter Firewall */}
|
||||
{nodes.length > 0 && (
|
||||
<FirewallManager serverId={sid} />
|
||||
)}
|
||||
|
||||
{/* Node Firewall */}
|
||||
{nodes.length > 0 && (
|
||||
<NodeFirewallManager serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||
)}
|
||||
|
||||
{/* Resource Pools */}
|
||||
{nodes.length > 0 && (
|
||||
<PoolManager
|
||||
serverId={sid}
|
||||
vms={serverVMs.map(v => ({ vmid: v.vmid, name: v.name, type: v.type, node: v.node }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Disks & ZFS */}
|
||||
{nodes.length > 0 && (
|
||||
<DiskManager serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||
)}
|
||||
|
||||
{/* Node administration: services & APT */}
|
||||
{nodes.length > 0 && (
|
||||
<NodeAdminManager serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||
)}
|
||||
|
||||
{/* PVE Access: realms & API tokens */}
|
||||
{nodes.length > 0 && (
|
||||
<AccessManager serverId={sid} />
|
||||
)}
|
||||
|
||||
{/* VMs on this server */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t('nodes.instances_on_server')} ({serverVMs.length})</h2>
|
||||
|
||||
206
frontend/src/features/nodes/NodeFirewallManager.tsx
Normal file
206
frontend/src/features/nodes/NodeFirewallManager.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, RefreshCw, ShieldCheck, Power } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
useFirewallGroups,
|
||||
useNodeFirewallOptions, useUpdateNodeFirewallOptions,
|
||||
useNodeFirewallRules, useCreateNodeFirewallRule, useUpdateNodeFirewallRule, useDeleteNodeFirewallRule,
|
||||
useNodeFirewallLog,
|
||||
type FirewallRule,
|
||||
} from '@/hooks/use-firewall';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
import { RuleDialog, RuleRow } from './FirewallManager';
|
||||
|
||||
const LOG_LEVELS = ['nolog', 'emerg', 'alert', 'crit', 'err', 'warning', 'notice', 'info', 'debug'];
|
||||
|
||||
export default function NodeFirewallManager({ serverId, nodeNames }: { serverId: number; nodeNames: string[] }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const [node, setNode] = useState('');
|
||||
useEffect(() => {
|
||||
if (nodeNames.length > 0 && !nodeNames.includes(node)) setNode(nodeNames[0]);
|
||||
}, [nodeNames, node]);
|
||||
|
||||
const { data: groupsData } = useFirewallGroups(serverId);
|
||||
const { data: optionsData, isLoading, refetch, isFetching } = useNodeFirewallOptions(serverId, node);
|
||||
const { data: rulesData } = useNodeFirewallRules(serverId, node);
|
||||
const { data: logData } = useNodeFirewallLog(serverId, node, false); // грузим лениво по вкладке
|
||||
|
||||
const updateOptions = useUpdateNodeFirewallOptions(serverId, node);
|
||||
const createRule = useCreateNodeFirewallRule(serverId, node);
|
||||
const updateRule = useUpdateNodeFirewallRule(serverId, node);
|
||||
const deleteRule = useDeleteNodeFirewallRule(serverId, node);
|
||||
|
||||
const groups = groupsData?.groups ?? [];
|
||||
const options = optionsData?.options ?? {};
|
||||
const rules = rulesData?.rules ?? [];
|
||||
const enabled = Number(options.enable) === 1;
|
||||
|
||||
const [ruleDialog, setRuleDialog] = useState(false);
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
const logQuery = useNodeFirewallLog(serverId, node, logOpen);
|
||||
const log = (logOpen ? logQuery.data : logData)?.log ?? [];
|
||||
|
||||
const saveOption = (patch: Record<string, unknown>) => {
|
||||
updateOptions.mutate(patch, {
|
||||
onSuccess: () => toast.success(t('fw.options_saved', 'Опции сохранены')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
const addRule = (payload: Record<string, unknown>) => {
|
||||
createRule.mutate(payload, {
|
||||
onSuccess: () => { toast.success(t('fw.rule_added', 'Правило добавлено')); setRuleDialog(false); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
const toggleRule = (r: FirewallRule) => {
|
||||
if (r.pos == null) return;
|
||||
updateRule.mutate({ pos: r.pos, data: { enable: r.enable === 0 ? 1 : 0 } }, {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
const removeRule = async (pos?: number) => {
|
||||
if (pos == null || !await confirm(t('fw.delete_rule_confirm', 'Удалить правило?'))) return;
|
||||
deleteRule.mutate(pos, { onSuccess: () => toast.success(t('fw.rule_deleted', 'Правило удалено')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
|
||||
const boolOption = (key: string, label: string) => (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Number(options[key]) === 1}
|
||||
onChange={(e) => saveOption({ [key]: e.target.checked ? 1 : 0 })}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
|
||||
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('fw.node_title', 'Firewall (нода)')}
|
||||
<Badge variant={enabled ? 'default' : 'secondary'}>{enabled ? t('fw.on', 'вкл') : t('fw.off', 'выкл')}</Badge>
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{nodeNames.length > 1 && (
|
||||
<Select value={node} onValueChange={(v) => { if (v) setNode(v); }}>
|
||||
<SelectTrigger className="w-[140px] h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{nodeNames.map(n => <SelectItem key={n} value={n}>{n}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button size="sm" variant={enabled ? 'outline' : 'default'} onClick={() => saveOption({ enable: enabled ? 0 : 1 })} disabled={updateOptions.isPending || !node}>
|
||||
<Power className="mr-1 h-4 w-4" />{enabled ? t('fw.disable', 'Выключить') : t('fw.enable', 'Включить')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!node ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : isLoading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||
) : (
|
||||
<Tabs defaultValue="rules" onValueChange={(v) => setLogOpen(v === 'log')}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="rules">{t('fw.rules', 'Правила')} ({rules.length})</TabsTrigger>
|
||||
<TabsTrigger value="options">{t('fw.options', 'Опции')}</TabsTrigger>
|
||||
<TabsTrigger value="log">{t('fw.log_tab', 'Лог')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Rules */}
|
||||
<TabsContent value="rules" className="mt-4 space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => setRuleDialog(true)}>
|
||||
<Plus className="mr-1 h-4 w-4" />{t('fw.add_rule', 'Добавить правило')}
|
||||
</Button>
|
||||
</div>
|
||||
{rules.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>{t('fw.direction', 'Напр.')}</TableHead>
|
||||
<TableHead>{t('fw.action', 'Действие')}</TableHead>
|
||||
<TableHead>{t('fw.proto', 'Прото')}</TableHead>
|
||||
<TableHead>{t('fw.match', 'Источник → Назначение')}</TableHead>
|
||||
<TableHead>{t('common.comment', 'Комментарий')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.map(r => (
|
||||
<RuleRow key={r.pos} rule={r} onToggle={() => toggleRule(r)} onDelete={() => removeRule(r.pos)} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Options */}
|
||||
<TabsContent value="options" className="mt-4 space-y-4 max-w-md">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => saveOption({ enable: e.target.checked ? 1 : 0 })} className="h-4 w-4 accent-primary" />
|
||||
<span className="text-sm">{t('fw.enable_node', 'Включить фаервол ноды')}</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('fw.log_level_in', 'Лог входящих')}</Label>
|
||||
<Select value={String(options.log_level_in || 'nolog')} onValueChange={(v) => { if (v) saveOption({ log_level_in: v }); }}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{LOG_LEVELS.map(l => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('fw.log_level_out', 'Лог исходящих')}</Label>
|
||||
<Select value={String(options.log_level_out || 'nolog')} onValueChange={(v) => { if (v) saveOption({ log_level_out: v }); }}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{LOG_LEVELS.map(l => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{boolOption('nosmurfs', t('fw.nosmurfs', 'Блокировать SMURF-атаки'))}
|
||||
{boolOption('tcpflags', t('fw.tcpflags', 'Фильтровать недопустимые TCP-флаги'))}
|
||||
{boolOption('protection_synflood', t('fw.synflood', 'Защита от SYN-flood'))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('fw.node_options_hint', 'Опции применяются к трафику самой ноды хоста Proxmox.')}</p>
|
||||
</TabsContent>
|
||||
|
||||
{/* Log */}
|
||||
<TabsContent value="log" className="mt-4">
|
||||
{logQuery.isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||
) : log.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<pre className="max-h-80 overflow-auto rounded-md bg-muted p-3 text-xs font-mono whitespace-pre-wrap">
|
||||
{log.map(l => l.t).filter(Boolean).join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<RuleDialog open={ruleDialog} onOpenChange={setRuleDialog} onSubmit={addRule} pending={createRule.isPending} groups={groups} allowGroupType />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
192
frontend/src/features/nodes/PoolManager.tsx
Normal file
192
frontend/src/features/nodes/PoolManager.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Trash2, RefreshCw, FolderTree, ChevronRight } 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
usePools, usePool, useCreatePool, useUpdatePool, useDeletePool,
|
||||
} from '@/hooks/use-pools';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
interface PoolVm {
|
||||
vmid: number;
|
||||
name?: string;
|
||||
type: string;
|
||||
node?: string;
|
||||
}
|
||||
|
||||
// ==================== Pool members (expandable) ====================
|
||||
|
||||
function PoolMembers({ serverId, poolid, vms }: { serverId: number; poolid: string; vms: PoolVm[] }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data } = usePool(serverId, poolid);
|
||||
const updatePool = useUpdatePool(serverId);
|
||||
const [addVmid, setAddVmid] = useState('');
|
||||
|
||||
const members = data?.pool?.members ?? [];
|
||||
const memberVmids = new Set(members.filter(m => m.vmid != null).map(m => m.vmid));
|
||||
const available = useMemo(() => vms.filter(v => !memberVmids.has(v.vmid)), [vms, memberVmids]);
|
||||
|
||||
const add = () => {
|
||||
if (!addVmid) return;
|
||||
updatePool.mutate({ poolid, data: { vms: addVmid } }, {
|
||||
onSuccess: () => { toast.success(t('pools.member_added', 'Добавлено в пул')); setAddVmid(''); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
const remove = async (vmid: number) => {
|
||||
if (!await confirm(t('pools.remove_member_confirm', 'Убрать из пула?'))) return;
|
||||
updatePool.mutate({ poolid, data: { vms: String(vmid), delete: 1 } }, {
|
||||
onSuccess: () => toast.success(t('pools.member_removed', 'Убрано из пула')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-xs">{t('pools.add_member', 'Добавить VM/LXC')}</Label>
|
||||
<Select value={addVmid} onValueChange={(v) => { if (v) setAddVmid(v); }}>
|
||||
<SelectTrigger className="w-full h-8"><SelectValue placeholder={t('pools.select_vm', 'Выберите инстанс')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{available.length === 0
|
||||
? <div className="px-2 py-1.5 text-xs text-muted-foreground">{t('pools.no_available', 'Нет доступных')}</div>
|
||||
: available.map(v => (
|
||||
<SelectItem key={v.vmid} value={String(v.vmid)}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px]">{v.type === 'qemu' ? 'VM' : 'LXC'}</Badge>
|
||||
{v.name || `#${v.vmid}`} <span className="text-xs text-muted-foreground">#{v.vmid}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" onClick={add} disabled={!addVmid || updatePool.isPending}><Plus className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
{members.length === 0 ? (
|
||||
<p className="py-2 text-center text-xs text-muted-foreground">{t('pools.empty', 'Пул пуст')}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{members.map(m => (
|
||||
<div key={m.id} className="flex items-center gap-2 rounded border px-2 py-1 text-xs">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{m.type === 'storage' ? 'STORAGE' : m.type === 'qemu' ? 'VM' : 'LXC'}
|
||||
</Badge>
|
||||
<span className="font-medium">{m.name || m.storage || m.id}</span>
|
||||
{m.vmid != null && <span className="font-mono text-muted-foreground">#{m.vmid}</span>}
|
||||
{m.node && <span className="text-muted-foreground">{m.node}</span>}
|
||||
{m.vmid != null && (
|
||||
<Button variant="ghost" size="icon" className="ml-auto h-6 w-6 text-destructive" onClick={() => remove(m.vmid!)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Main ====================
|
||||
|
||||
export default function PoolManager({ serverId, vms }: { serverId: number; vms: PoolVm[] }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data, isLoading, refetch, isFetching } = usePools(serverId);
|
||||
const createPool = useCreatePool(serverId);
|
||||
const deletePool = useDeletePool(serverId);
|
||||
|
||||
const pools = data?.pools ?? [];
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [dialog, setDialog] = useState(false);
|
||||
const [poolid, setPoolid] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const create = () => {
|
||||
if (!poolid.trim()) { toast.error(t('pools.err_id', 'Укажите имя пула')); return; }
|
||||
createPool.mutate({ poolid: poolid.trim(), comment: comment.trim() || undefined }, {
|
||||
onSuccess: () => { toast.success(t('pools.created', 'Пул создан')); setDialog(false); setPoolid(''); setComment(''); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
const remove = async (p: string) => {
|
||||
if (!await confirm(`${t('pools.delete', 'Удалить пул')} "${p}"? ${t('pools.delete_hint', '(пул должен быть пустым)')}`)) return;
|
||||
deletePool.mutate(p, { onSuccess: () => toast.success(t('pools.deleted', 'Пул удалён')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
|
||||
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">
|
||||
<FolderTree className="h-5 w-5" />
|
||||
{t('pools.title', 'Пулы ресурсов')} ({pools.length})
|
||||
</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={() => setDialog(true)}>
|
||||
<Plus className="mr-1 h-4 w-4" />{t('pools.add', 'Создать пул')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||
) : pools.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pools.map(p => (
|
||||
<div key={p.poolid} className="rounded-md border">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<button className="flex flex-1 items-center gap-2 text-left" onClick={() => setExpanded(prev => prev === p.poolid ? null : p.poolid)}>
|
||||
<ChevronRight className={`h-4 w-4 transition-transform ${expanded === p.poolid ? 'rotate-90' : ''}`} />
|
||||
<span className="font-mono font-medium">{p.poolid}</span>
|
||||
{p.comment && <span className="text-xs text-muted-foreground">— {p.comment}</span>}
|
||||
</button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => remove(p.poolid)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{expanded === p.poolid && (
|
||||
<div className="px-3 pb-3"><PoolMembers serverId={serverId} poolid={p.poolid} vms={vms} /></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={dialog} onOpenChange={(v) => { setDialog(v); if (!v) { setPoolid(''); setComment(''); } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{t('pools.add', 'Создать пул')}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('pools.pool_id', 'Имя пула')}</Label>
|
||||
<Input value={poolid} onChange={(e) => setPoolid(e.target.value)} className="font-mono" placeholder="production" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('common.comment', 'Комментарий')}</Label>
|
||||
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialog(false)}>{t('common.cancel', 'Отмена')}</Button>
|
||||
<Button onClick={create} disabled={createPool.isPending}>{t('common.add', 'Создать')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
97
frontend/src/hooks/use-access.ts
Normal file
97
frontend/src/hooks/use-access.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export interface Realm {
|
||||
realm: string;
|
||||
type: string; // pam | pve | ldap | ad | openid
|
||||
comment?: string;
|
||||
tfa?: string;
|
||||
}
|
||||
|
||||
export interface AccessUser {
|
||||
userid: string;
|
||||
enable?: number;
|
||||
comment?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface ApiToken {
|
||||
tokenid: string;
|
||||
comment?: string;
|
||||
expire?: number;
|
||||
privsep?: number;
|
||||
}
|
||||
|
||||
export const accessKeys = {
|
||||
realms: (s: number) => ['access-realms', s] as const,
|
||||
users: (s: number) => ['access-users', s] as const,
|
||||
tokens: (s: number, u: string) => ['access-tokens', s, u] as const,
|
||||
};
|
||||
|
||||
const base = (s: number) => `/proxmox/api/servers/${s}/access`;
|
||||
|
||||
// ---------- Realms ----------
|
||||
export function useRealms(serverId: number, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: accessKeys.realms(serverId),
|
||||
queryFn: () => apiClient.get<{ realms: Realm[] }>(`${base(serverId)}/realms`),
|
||||
enabled: serverId > 0 && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRealm(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => apiClient.post(`${base(serverId)}/realms`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: accessKeys.realms(serverId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRealm(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (realm: string) => apiClient.delete(`${base(serverId)}/realms/${realm}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: accessKeys.realms(serverId) }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Users & tokens ----------
|
||||
export function useAccessUsers(serverId: number, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: accessKeys.users(serverId),
|
||||
queryFn: () => apiClient.get<{ users: AccessUser[] }>(`${base(serverId)}/users`),
|
||||
enabled: serverId > 0 && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserTokens(serverId: number, userid: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: accessKeys.tokens(serverId, userid),
|
||||
queryFn: () => apiClient.get<{ tokens: ApiToken[] }>(`${base(serverId)}/users/${encodeURIComponent(userid)}/tokens`),
|
||||
enabled: serverId > 0 && !!userid && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export interface CreatedToken {
|
||||
success: boolean;
|
||||
value?: string;
|
||||
full_tokenid?: string;
|
||||
}
|
||||
|
||||
export function useCreateToken(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<CreatedToken, Error, { userid: string; tokenid: string; comment?: string; privsep: boolean }>({
|
||||
mutationFn: ({ userid, tokenid, ...data }) =>
|
||||
apiClient.post(`${base(serverId)}/users/${encodeURIComponent(userid)}/tokens/${encodeURIComponent(tokenid)}`, data),
|
||||
onSuccess: (_r, v) => qc.invalidateQueries({ queryKey: accessKeys.tokens(serverId, v.userid) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteToken(serverId: number, userid: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (tokenid: string) =>
|
||||
apiClient.delete(`${base(serverId)}/users/${encodeURIComponent(userid)}/tokens/${encodeURIComponent(tokenid)}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: accessKeys.tokens(serverId, userid) }),
|
||||
});
|
||||
}
|
||||
109
frontend/src/hooks/use-disks.ts
Normal file
109
frontend/src/hooks/use-disks.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export interface NodeDisk {
|
||||
devpath: string;
|
||||
size?: number;
|
||||
model?: string;
|
||||
serial?: string;
|
||||
type?: string; // 'ssd' | 'hdd' | 'nvme' | ...
|
||||
vendor?: string;
|
||||
used?: string; // 'LVM' | 'ZFS' | 'partitions' | ... | undefined
|
||||
health?: string; // 'PASSED' | 'OK' | 'FAILED' | ...
|
||||
wearout?: number | string;
|
||||
rpm?: number;
|
||||
gpt?: number;
|
||||
}
|
||||
|
||||
export interface ZfsPool {
|
||||
name: string;
|
||||
size?: number;
|
||||
alloc?: number;
|
||||
free?: number;
|
||||
frag?: number;
|
||||
dedup?: number;
|
||||
health?: string;
|
||||
}
|
||||
|
||||
export interface SmartAttribute {
|
||||
id?: number;
|
||||
name?: string;
|
||||
value?: number | string;
|
||||
worst?: number | string;
|
||||
threshold?: number | string;
|
||||
raw?: string;
|
||||
flags?: string;
|
||||
}
|
||||
|
||||
export interface SmartData {
|
||||
health?: string;
|
||||
type?: string;
|
||||
text?: string;
|
||||
attributes?: SmartAttribute[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const diskKeys = {
|
||||
disks: (s: number, n: string) => ['node-disks', s, n] as const,
|
||||
smart: (s: number, n: string, d: string) => ['disk-smart', s, n, d] as const,
|
||||
zfs: (s: number, n: string) => ['zfs-pools', s, n] as const,
|
||||
};
|
||||
|
||||
const base = (s: number, n: string) => `/proxmox/api/servers/${s}/nodes/${n}/disks`;
|
||||
|
||||
export function useNodeDisks(serverId: number, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: diskKeys.disks(serverId, node),
|
||||
queryFn: () => apiClient.get<{ disks: NodeDisk[] }>(base(serverId, node)),
|
||||
enabled: serverId > 0 && !!node && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDiskSmart(serverId: number, node: string, disk: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: diskKeys.smart(serverId, node, disk),
|
||||
queryFn: () => apiClient.get<{ smart: SmartData }>(`${base(serverId, node)}/smart?disk=${encodeURIComponent(disk)}`),
|
||||
enabled: serverId > 0 && !!node && !!disk && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useZfsPools(serverId: number, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: diskKeys.zfs(serverId, node),
|
||||
queryFn: () => apiClient.get<{ pools: ZfsPool[] }>(`${base(serverId, node)}/zfs`),
|
||||
enabled: serverId > 0 && !!node && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidate(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return () => {
|
||||
qc.invalidateQueries({ queryKey: diskKeys.zfs(serverId, node) });
|
||||
qc.invalidateQueries({ queryKey: diskKeys.disks(serverId, node) });
|
||||
};
|
||||
}
|
||||
|
||||
export function useCreateZfsPool(serverId: number, node: string) {
|
||||
const invalidate = useInvalidate(serverId, node);
|
||||
return useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => apiClient.post(`${base(serverId, node)}/zfs`, data),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDestroyZfsPool(serverId: number, node: string) {
|
||||
const invalidate = useInvalidate(serverId, node);
|
||||
return useMutation({
|
||||
mutationFn: ({ name, cleanupDisks }: { name: string; cleanupDisks?: boolean }) =>
|
||||
apiClient.delete(`${base(serverId, node)}/zfs/${name}?cleanup_disks=${cleanupDisks ? 'true' : 'false'}`),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWipeDisk(serverId: number, node: string) {
|
||||
const invalidate = useInvalidate(serverId, node);
|
||||
return useMutation({
|
||||
mutationFn: (disk: string) => apiClient.put(`${base(serverId, node)}/wipe?disk=${encodeURIComponent(disk)}`, {}),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
391
frontend/src/hooks/use-firewall.ts
Normal file
391
frontend/src/hooks/use-firewall.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
// ==================== Types ====================
|
||||
export interface FirewallRule {
|
||||
pos?: number;
|
||||
type: string; // 'in' | 'out' | 'group'
|
||||
action: string; // ACCEPT | DROP | REJECT | <security group name>
|
||||
enable?: number;
|
||||
source?: string;
|
||||
dest?: string;
|
||||
proto?: string;
|
||||
dport?: string;
|
||||
sport?: string;
|
||||
macro?: string;
|
||||
iface?: string;
|
||||
log?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface FirewallGroup {
|
||||
group: string;
|
||||
comment?: string;
|
||||
digest?: string;
|
||||
}
|
||||
|
||||
export interface FirewallIpset {
|
||||
name: string;
|
||||
comment?: string;
|
||||
digest?: string;
|
||||
}
|
||||
|
||||
export interface IpsetEntry {
|
||||
cidr: string;
|
||||
comment?: string;
|
||||
nomatch?: number;
|
||||
}
|
||||
|
||||
export interface FirewallAlias {
|
||||
name: string;
|
||||
cidr: string;
|
||||
comment?: string;
|
||||
ipversion?: number;
|
||||
}
|
||||
|
||||
export interface FirewallOptions {
|
||||
enable?: number;
|
||||
policy_in?: string;
|
||||
policy_out?: string;
|
||||
log_ratelimit?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface FirewallMacro {
|
||||
macro: string;
|
||||
descr?: string;
|
||||
}
|
||||
|
||||
export interface FirewallLogLine {
|
||||
n?: number;
|
||||
t?: string;
|
||||
}
|
||||
|
||||
// ==================== Query keys ====================
|
||||
export const fwKeys = {
|
||||
options: (s: number) => ['fw-options', s] as const,
|
||||
rules: (s: number) => ['fw-rules', s] as const,
|
||||
groups: (s: number) => ['fw-groups', s] as const,
|
||||
groupRules: (s: number, g: string) => ['fw-group-rules', s, g] as const,
|
||||
ipsets: (s: number) => ['fw-ipsets', s] as const,
|
||||
ipsetEntries: (s: number, n: string) => ['fw-ipset-entries', s, n] as const,
|
||||
aliases: (s: number) => ['fw-aliases', s] as const,
|
||||
macros: (s: number) => ['fw-macros', s] as const,
|
||||
nodeOptions: (s: number, n: string) => ['fw-node-options', s, n] as const,
|
||||
nodeRules: (s: number, n: string) => ['fw-node-rules', s, n] as const,
|
||||
nodeLog: (s: number, n: string) => ['fw-node-log', s, n] as const,
|
||||
guestOptions: (s: number, t: string, v: number) => ['fw-guest-options', s, t, v] as const,
|
||||
guestRules: (s: number, t: string, v: number) => ['fw-guest-rules', s, t, v] as const,
|
||||
guestLog: (s: number, t: string, v: number) => ['fw-guest-log', s, t, v] as const,
|
||||
};
|
||||
|
||||
const base = (s: number) => `/proxmox/api/servers/${s}/firewall`;
|
||||
|
||||
// ==================== Options ====================
|
||||
export function useFirewallOptions(serverId: number, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: fwKeys.options(serverId),
|
||||
queryFn: () => apiClient.get<{ options: FirewallOptions }>(`${base(serverId)}/options`),
|
||||
enabled: serverId > 0 && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateFirewallOptions(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => apiClient.put(`${base(serverId)}/options`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.options(serverId) }),
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Datacenter rules ====================
|
||||
export function useFirewallRules(serverId: number, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: fwKeys.rules(serverId),
|
||||
queryFn: () => apiClient.get<{ rules: FirewallRule[] }>(`${base(serverId)}/rules`),
|
||||
enabled: serverId > 0 && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateFirewallRule(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => apiClient.post(`${base(serverId)}/rules`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.rules(serverId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateFirewallRule(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ pos, data }: { pos: number; data: Record<string, unknown> }) =>
|
||||
apiClient.put(`${base(serverId)}/rules/${pos}`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.rules(serverId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteFirewallRule(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (pos: number) => apiClient.delete(`${base(serverId)}/rules/${pos}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.rules(serverId) }),
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Security groups ====================
|
||||
export function useFirewallGroups(serverId: number, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: fwKeys.groups(serverId),
|
||||
queryFn: () => apiClient.get<{ groups: FirewallGroup[] }>(`${base(serverId)}/groups`),
|
||||
enabled: serverId > 0 && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateFirewallGroup(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { group: string; comment?: string }) => apiClient.post(`${base(serverId)}/groups`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.groups(serverId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteFirewallGroup(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (group: string) => apiClient.delete(`${base(serverId)}/groups/${group}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.groups(serverId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFirewallGroupRules(serverId: number, group: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: fwKeys.groupRules(serverId, group),
|
||||
queryFn: () => apiClient.get<{ rules: FirewallRule[] }>(`${base(serverId)}/groups/${group}/rules`),
|
||||
enabled: serverId > 0 && !!group && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateFirewallGroupRule(serverId: number, group: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => apiClient.post(`${base(serverId)}/groups/${group}/rules`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.groupRules(serverId, group) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteFirewallGroupRule(serverId: number, group: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (pos: number) => apiClient.delete(`${base(serverId)}/groups/${group}/rules/${pos}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.groupRules(serverId, group) }),
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== IP sets ====================
|
||||
export function useFirewallIpsets(serverId: number, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: fwKeys.ipsets(serverId),
|
||||
queryFn: () => apiClient.get<{ ipsets: FirewallIpset[] }>(`${base(serverId)}/ipsets`),
|
||||
enabled: serverId > 0 && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateFirewallIpset(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string; comment?: string }) => apiClient.post(`${base(serverId)}/ipsets`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.ipsets(serverId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteFirewallIpset(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => apiClient.delete(`${base(serverId)}/ipsets/${name}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.ipsets(serverId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFirewallIpsetEntries(serverId: number, name: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: fwKeys.ipsetEntries(serverId, name),
|
||||
queryFn: () => apiClient.get<{ entries: IpsetEntry[] }>(`${base(serverId)}/ipsets/${name}/entries`),
|
||||
enabled: serverId > 0 && !!name && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddIpsetEntry(serverId: number, name: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { cidr: string; comment?: string; nomatch?: boolean }) =>
|
||||
apiClient.post(`${base(serverId)}/ipsets/${name}/entries`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.ipsetEntries(serverId, name) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteIpsetEntry(serverId: number, name: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (cidr: string) =>
|
||||
apiClient.delete(`${base(serverId)}/ipsets/${name}/entries?cidr=${encodeURIComponent(cidr)}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.ipsetEntries(serverId, name) }),
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Aliases ====================
|
||||
export function useFirewallAliases(serverId: number, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: fwKeys.aliases(serverId),
|
||||
queryFn: () => apiClient.get<{ aliases: FirewallAlias[] }>(`${base(serverId)}/aliases`),
|
||||
enabled: serverId > 0 && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateFirewallAlias(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string; cidr: string; comment?: string }) =>
|
||||
apiClient.post(`${base(serverId)}/aliases`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.aliases(serverId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateFirewallAlias(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ name, data }: { name: string; data: Record<string, unknown> }) =>
|
||||
apiClient.put(`${base(serverId)}/aliases/${name}`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.aliases(serverId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteFirewallAlias(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => apiClient.delete(`${base(serverId)}/aliases/${name}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.aliases(serverId) }),
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Node-level ====================
|
||||
const nodeBase = (s: number, n: string) => `/proxmox/api/servers/${s}/nodes/${n}/firewall`;
|
||||
|
||||
export function useNodeFirewallOptions(serverId: number, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: fwKeys.nodeOptions(serverId, node),
|
||||
queryFn: () => apiClient.get<{ options: FirewallOptions }>(`${nodeBase(serverId, node)}/options`),
|
||||
enabled: serverId > 0 && !!node && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateNodeFirewallOptions(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => apiClient.put(`${nodeBase(serverId, node)}/options`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.nodeOptions(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useNodeFirewallRules(serverId: number, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: fwKeys.nodeRules(serverId, node),
|
||||
queryFn: () => apiClient.get<{ rules: FirewallRule[] }>(`${nodeBase(serverId, node)}/rules`),
|
||||
enabled: serverId > 0 && !!node && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateNodeFirewallRule(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => apiClient.post(`${nodeBase(serverId, node)}/rules`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.nodeRules(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateNodeFirewallRule(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ pos, data }: { pos: number; data: Record<string, unknown> }) =>
|
||||
apiClient.put(`${nodeBase(serverId, node)}/rules/${pos}`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.nodeRules(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteNodeFirewallRule(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (pos: number) => apiClient.delete(`${nodeBase(serverId, node)}/rules/${pos}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.nodeRules(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useNodeFirewallLog(serverId: number, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: fwKeys.nodeLog(serverId, node),
|
||||
queryFn: () => apiClient.get<{ log: FirewallLogLine[] }>(`${nodeBase(serverId, node)}/log?limit=200`),
|
||||
enabled: serverId > 0 && !!node && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Guest-level (VM/LXC) ====================
|
||||
const guestBase = (s: number, t: string, v: number) => `/proxmox/api/servers/${s}/guests/${t}/${v}/firewall`;
|
||||
|
||||
export function useGuestFirewallOptions(serverId: number, vmType: string, vmid: number, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: fwKeys.guestOptions(serverId, vmType, vmid),
|
||||
queryFn: () => apiClient.get<{ options: FirewallOptions }>(`${guestBase(serverId, vmType, vmid)}/options?node=${node}`),
|
||||
enabled: serverId > 0 && !!node && vmid > 0 && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateGuestFirewallOptions(serverId: number, vmType: string, vmid: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
apiClient.put(`${guestBase(serverId, vmType, vmid)}/options?node=${node}`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.guestOptions(serverId, vmType, vmid) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGuestFirewallRules(serverId: number, vmType: string, vmid: number, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: fwKeys.guestRules(serverId, vmType, vmid),
|
||||
queryFn: () => apiClient.get<{ rules: FirewallRule[] }>(`${guestBase(serverId, vmType, vmid)}/rules?node=${node}`),
|
||||
enabled: serverId > 0 && !!node && vmid > 0 && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateGuestFirewallRule(serverId: number, vmType: string, vmid: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
apiClient.post(`${guestBase(serverId, vmType, vmid)}/rules?node=${node}`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.guestRules(serverId, vmType, vmid) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateGuestFirewallRule(serverId: number, vmType: string, vmid: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ pos, data }: { pos: number; data: Record<string, unknown> }) =>
|
||||
apiClient.put(`${guestBase(serverId, vmType, vmid)}/rules/${pos}?node=${node}`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.guestRules(serverId, vmType, vmid) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteGuestFirewallRule(serverId: number, vmType: string, vmid: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (pos: number) =>
|
||||
apiClient.delete(`${guestBase(serverId, vmType, vmid)}/rules/${pos}?node=${node}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.guestRules(serverId, vmType, vmid) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGuestFirewallLog(serverId: number, vmType: string, vmid: number, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: fwKeys.guestLog(serverId, vmType, vmid),
|
||||
queryFn: () => apiClient.get<{ log: FirewallLogLine[] }>(`${guestBase(serverId, vmType, vmid)}/log?node=${node}&limit=200`),
|
||||
enabled: serverId > 0 && !!node && vmid > 0 && enabled,
|
||||
});
|
||||
}
|
||||
@@ -682,6 +682,44 @@ export function useNodeNetworks(serverId: number, node: string, enabled = true)
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Node hardware (PCI/USB passthrough) ====================
|
||||
export interface NodePciDevice {
|
||||
id: string; // 0000:01:00.0
|
||||
vendor_name?: string;
|
||||
device_name?: string;
|
||||
subsystem_device_name?: string;
|
||||
iommugroup?: number;
|
||||
mdev?: number | boolean;
|
||||
}
|
||||
|
||||
export interface NodeUsbDevice {
|
||||
busnum?: number | string;
|
||||
devnum?: number | string;
|
||||
vendid?: string;
|
||||
prodid?: string;
|
||||
port?: number | string;
|
||||
usbpath?: string;
|
||||
speed?: string;
|
||||
manufacturer?: string;
|
||||
prodname?: string;
|
||||
}
|
||||
|
||||
export function useNodePciDevices(serverId: number, node: string, enabled = true) {
|
||||
return useQuery<NodePciDevice[]>({
|
||||
queryKey: ['node-pci', serverId, node],
|
||||
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/nodes/${node}/hardware/pci`),
|
||||
enabled: enabled && !!node,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNodeUsbDevices(serverId: number, node: string, enabled = true) {
|
||||
return useQuery<NodeUsbDevice[]>({
|
||||
queryKey: ['node-usb', serverId, node],
|
||||
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/nodes/${node}/hardware/usb`),
|
||||
enabled: enabled && !!node,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ISO mount/unmount (KVM only) ====================
|
||||
export interface IsoItem {
|
||||
volid: string;
|
||||
|
||||
101
frontend/src/hooks/use-node-admin.ts
Normal file
101
frontend/src/hooks/use-node-admin.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export interface NodeService {
|
||||
service: string;
|
||||
name?: string;
|
||||
desc?: string;
|
||||
state?: string; // running | stopped | dead | ...
|
||||
'active-state'?: string;
|
||||
'unit-state'?: string;
|
||||
}
|
||||
|
||||
export interface AptUpdate {
|
||||
Package: string;
|
||||
Version?: string;
|
||||
OldVersion?: string;
|
||||
Title?: string;
|
||||
Description?: string;
|
||||
Priority?: string;
|
||||
}
|
||||
|
||||
export interface AptRepo {
|
||||
Enabled?: number | boolean;
|
||||
Types?: string[];
|
||||
URIs?: string[];
|
||||
Suites?: string[];
|
||||
Components?: string[];
|
||||
Comment?: string;
|
||||
}
|
||||
|
||||
export interface AptRepoFile {
|
||||
path: string;
|
||||
'file-type'?: string;
|
||||
repositories?: AptRepo[];
|
||||
}
|
||||
|
||||
export interface AptRepositories {
|
||||
files?: AptRepoFile[];
|
||||
'standard-repos'?: Array<{ handle: string; name: string; status?: number }>;
|
||||
errors?: unknown[];
|
||||
}
|
||||
|
||||
export const nodeAdminKeys = {
|
||||
services: (s: number, n: string) => ['node-services', s, n] as const,
|
||||
updates: (s: number, n: string) => ['apt-updates', s, n] as const,
|
||||
repos: (s: number, n: string) => ['apt-repos', s, n] as const,
|
||||
};
|
||||
|
||||
const base = (s: number, n: string) => `/proxmox/api/servers/${s}/nodes/${n}`;
|
||||
|
||||
// ---------- Services ----------
|
||||
export function useNodeServices(serverId: number, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: nodeAdminKeys.services(serverId, node),
|
||||
queryFn: () => apiClient.get<{ services: NodeService[] }>(`${base(serverId, node)}/services`),
|
||||
enabled: serverId > 0 && !!node && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useServiceAction(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ service, action }: { service: string; action: string }) =>
|
||||
apiClient.post(`${base(serverId, node)}/services/${service}/${action}`, {}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: nodeAdminKeys.services(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- APT ----------
|
||||
export function useAptUpdates(serverId: number, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: nodeAdminKeys.updates(serverId, node),
|
||||
queryFn: () => apiClient.get<{ updates: AptUpdate[] }>(`${base(serverId, node)}/apt/updates`),
|
||||
enabled: serverId > 0 && !!node && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAptRefresh(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.post(`${base(serverId, node)}/apt/refresh`, {}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: nodeAdminKeys.updates(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAptRepositories(serverId: number, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: nodeAdminKeys.repos(serverId, node),
|
||||
queryFn: () => apiClient.get<{ repositories: AptRepositories }>(`${base(serverId, node)}/apt/repositories`),
|
||||
enabled: serverId > 0 && !!node && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetAptRepository(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { path: string; index: number; enabled: boolean }) =>
|
||||
apiClient.post(`${base(serverId, node)}/apt/repositories`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: nodeAdminKeys.repos(serverId, node) }),
|
||||
});
|
||||
}
|
||||
78
frontend/src/hooks/use-pools.ts
Normal file
78
frontend/src/hooks/use-pools.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export interface Pool {
|
||||
poolid: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface PoolMember {
|
||||
id: string;
|
||||
type: string; // 'qemu' | 'lxc' | 'storage'
|
||||
vmid?: number;
|
||||
name?: string;
|
||||
node?: string;
|
||||
storage?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface PoolDetail {
|
||||
comment?: string;
|
||||
members?: PoolMember[];
|
||||
}
|
||||
|
||||
export const poolKeys = {
|
||||
list: (s: number) => ['pools', s] as const,
|
||||
detail: (s: number, p: string) => ['pool', s, p] as const,
|
||||
};
|
||||
|
||||
const base = (s: number) => `/proxmox/api/servers/${s}/pools`;
|
||||
|
||||
export function usePools(serverId: number, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: poolKeys.list(serverId),
|
||||
queryFn: () => apiClient.get<{ pools: Pool[] }>(base(serverId)),
|
||||
enabled: serverId > 0 && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePool(serverId: number, poolid: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: poolKeys.detail(serverId, poolid),
|
||||
queryFn: () => apiClient.get<{ pool: PoolDetail }>(`${base(serverId)}/${poolid}`),
|
||||
enabled: serverId > 0 && !!poolid && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidatePools(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return (poolid?: string) => {
|
||||
qc.invalidateQueries({ queryKey: poolKeys.list(serverId) });
|
||||
if (poolid) qc.invalidateQueries({ queryKey: poolKeys.detail(serverId, poolid) });
|
||||
};
|
||||
}
|
||||
|
||||
export function useCreatePool(serverId: number) {
|
||||
const invalidate = useInvalidatePools(serverId);
|
||||
return useMutation({
|
||||
mutationFn: (data: { poolid: string; comment?: string }) => apiClient.post(base(serverId), data),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePool(serverId: number) {
|
||||
const invalidate = useInvalidatePools(serverId);
|
||||
return useMutation({
|
||||
mutationFn: ({ poolid, data }: { poolid: string; data: Record<string, unknown> }) =>
|
||||
apiClient.put(`${base(serverId)}/${poolid}`, data),
|
||||
onSuccess: (_r, v) => invalidate(v.poolid),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePool(serverId: number) {
|
||||
const invalidate = useInvalidatePools(serverId);
|
||||
return useMutation({
|
||||
mutationFn: (poolid: string) => apiClient.delete(`${base(serverId)}/${poolid}`),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
@@ -276,6 +276,12 @@
|
||||
"overview": "Overview",
|
||||
"graphs": "Graphs",
|
||||
"networking": "Networking",
|
||||
"hardware": "Hardware",
|
||||
"firewall": "Firewall",
|
||||
"tags": "Tags",
|
||||
"no_tags": "No tags",
|
||||
"tags_hint": "Comma-separated tags",
|
||||
"tags_saved": "Tags saved",
|
||||
"destroy": "Destroy",
|
||||
"task_activity": "Task Activity",
|
||||
"status_creating": "Creating",
|
||||
@@ -453,6 +459,219 @@
|
||||
"applied": "Network configuration applied",
|
||||
"reverted": "Pending changes reverted"
|
||||
},
|
||||
"hw": {
|
||||
"platform": "Platform",
|
||||
"bios": "BIOS",
|
||||
"machine": "Machine type",
|
||||
"vga": "Display (VGA)",
|
||||
"scsihw": "SCSI controller",
|
||||
"sockets": "CPU sockets",
|
||||
"platform_saved": "Hardware updated",
|
||||
"platform_hint": "Hardware changes take effect after the VM is powered off and started again.",
|
||||
"passthrough": "Device passthrough",
|
||||
"no_passthrough": "No passthrough devices",
|
||||
"remove_device": "Remove device",
|
||||
"device_removed": "Device {{key}} removed",
|
||||
"remove_title": "Remove device?",
|
||||
"remove_description": "Device {{key}} will be removed from the VM configuration.",
|
||||
"passthrough_hint": "PCI/USB passthrough requires the VM to be powered off and IOMMU enabled on the host.",
|
||||
"stop_first": "Power off the VM to change passthrough",
|
||||
"running_lock": "The VM is running — passthrough devices cannot be added or removed. Power off the VM.",
|
||||
"efi_required_title": "OVMF (UEFI) requires an EFI disk",
|
||||
"efi_required_hint": "The EFI disk will be created automatically. Choose a storage for it.",
|
||||
"efi_storage": "EFI disk storage",
|
||||
"select_storage": "Select a storage",
|
||||
"err_efi_storage": "Select a storage for the EFI disk",
|
||||
"add_submit": "Add",
|
||||
"add_pci_title": "PCI passthrough",
|
||||
"add_pci_description": "Will be added as {{key}}. The VM must be powered off.",
|
||||
"pci_device": "Device",
|
||||
"select_pci": "Select a device",
|
||||
"no_pci": "No devices found",
|
||||
"pci_pcie": "PCI-Express (q35 only)",
|
||||
"pci_all_functions": "All device functions",
|
||||
"pci_primary_gpu": "Primary GPU (x-vga)",
|
||||
"pci_rombar": "ROM-Bar",
|
||||
"pci_hint": "PCI passthrough requires IOMMU (VT-d / AMD-Vi) enabled on the host.",
|
||||
"err_pci_required": "Select a PCI device",
|
||||
"pci_added": "Device {{key}} added",
|
||||
"add_usb_title": "USB passthrough",
|
||||
"add_usb_description": "Will be added as {{key}}",
|
||||
"usb_device": "Device",
|
||||
"select_usb": "Select a device",
|
||||
"no_usb": "No devices found",
|
||||
"usb3": "USB 3.0",
|
||||
"err_usb_required": "Select a USB device",
|
||||
"usb_added": "Device {{key}} added"
|
||||
},
|
||||
"fw": {
|
||||
"title": "Firewall (datacenter)",
|
||||
"on": "on",
|
||||
"off": "off",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"rules": "Rules",
|
||||
"groups": "Security Groups",
|
||||
"ipsets": "IP Sets",
|
||||
"aliases": "Aliases",
|
||||
"options": "Options",
|
||||
"add_rule": "Add rule",
|
||||
"rule_hint": "Rules are evaluated top to bottom.",
|
||||
"direction": "Direction",
|
||||
"action": "Action",
|
||||
"match": "Source → Destination",
|
||||
"security_group": "Security group",
|
||||
"select_group": "Select a group",
|
||||
"proto": "Protocol",
|
||||
"macro": "Macro",
|
||||
"source": "Source",
|
||||
"dest": "Destination",
|
||||
"dport": "Dest. port",
|
||||
"sport": "Src. port",
|
||||
"enabled": "Enabled",
|
||||
"log": "Log",
|
||||
"err_group_required": "Select a security group",
|
||||
"rule_added": "Rule added",
|
||||
"rule_deleted": "Rule deleted",
|
||||
"delete_rule_confirm": "Delete this rule?",
|
||||
"add_group": "Add security group",
|
||||
"delete_group": "Delete security group",
|
||||
"group_name": "Group name",
|
||||
"group_added": "Security group created",
|
||||
"group_deleted": "Group deleted",
|
||||
"add_ipset": "Add IP set",
|
||||
"delete_ipset": "Delete IP set",
|
||||
"ipset_name": "IP set name",
|
||||
"ipset_added": "IP set created",
|
||||
"ipset_deleted": "IP set deleted",
|
||||
"add_alias": "Add alias",
|
||||
"delete_alias": "Delete alias",
|
||||
"alias_added": "Alias created",
|
||||
"alias_deleted": "Alias deleted",
|
||||
"name": "Name",
|
||||
"entry_added": "Entry added",
|
||||
"entry_deleted": "Entry deleted",
|
||||
"delete_entry": "Delete entry",
|
||||
"enable_dc": "Enable datacenter firewall",
|
||||
"policy_in": "Inbound policy",
|
||||
"policy_out": "Outbound policy",
|
||||
"options_hint": "Policies apply to traffic that matches no rule.",
|
||||
"options_saved": "Options saved",
|
||||
"err_name": "Enter a name",
|
||||
"err_cidr": "Enter a CIDR",
|
||||
"node_title": "Firewall (node)",
|
||||
"log_tab": "Log",
|
||||
"enable_node": "Enable node firewall",
|
||||
"log_level_in": "Inbound log level",
|
||||
"log_level_out": "Outbound log level",
|
||||
"nosmurfs": "Block SMURF attacks",
|
||||
"tcpflags": "Filter illegal TCP flags",
|
||||
"synflood": "SYN-flood protection",
|
||||
"node_options_hint": "These options apply to traffic of the Proxmox host node itself.",
|
||||
"guest_title": "Instance firewall",
|
||||
"enable_guest": "Enable instance firewall",
|
||||
"dhcp": "Allow DHCP",
|
||||
"macfilter": "MAC filter",
|
||||
"ipfilter": "IP filter (anti-spoofing)",
|
||||
"ndp": "NDP (IPv6)",
|
||||
"guest_options_hint": "The instance firewall only works when the datacenter firewall is enabled and firewall=1 is set on the NIC."
|
||||
},
|
||||
"pools": {
|
||||
"title": "Resource Pools",
|
||||
"add": "Create pool",
|
||||
"pool_id": "Pool name",
|
||||
"created": "Pool created",
|
||||
"deleted": "Pool deleted",
|
||||
"delete": "Delete pool",
|
||||
"delete_hint": "(the pool must be empty)",
|
||||
"err_id": "Enter a pool name",
|
||||
"empty": "Pool is empty",
|
||||
"add_member": "Add VM/LXC",
|
||||
"select_vm": "Select an instance",
|
||||
"no_available": "None available",
|
||||
"member_added": "Added to pool",
|
||||
"member_removed": "Removed from pool",
|
||||
"remove_member_confirm": "Remove from pool?"
|
||||
},
|
||||
"disks": {
|
||||
"title": "Disks & ZFS",
|
||||
"physical": "Disks",
|
||||
"device": "Device",
|
||||
"type": "Type",
|
||||
"model": "Model",
|
||||
"size": "Size",
|
||||
"usage": "Usage",
|
||||
"free": "free",
|
||||
"health": "Health",
|
||||
"attribute": "Attribute",
|
||||
"value": "Value",
|
||||
"worst": "Worst",
|
||||
"threshold": "Threshold",
|
||||
"wipe": "Wipe",
|
||||
"wipe_confirm": "Wipe disk (all data will be destroyed)",
|
||||
"wiped": "Disk is being wiped",
|
||||
"create_zfs": "Create ZFS pool",
|
||||
"create_zfs_hint": "The disks will be wiped and combined into a pool.",
|
||||
"pool_name": "Pool name",
|
||||
"raid_level": "RAID level",
|
||||
"compression": "Compression",
|
||||
"devices": "Disks",
|
||||
"no_free_disks": "No free disks",
|
||||
"err_name": "Enter a pool name",
|
||||
"err_devices": "Select disks",
|
||||
"pool_created": "ZFS pool is being created",
|
||||
"destroy_pool": "Destroy ZFS pool",
|
||||
"pool_destroyed": "Pool is being destroyed",
|
||||
"alloc": "Allocated"
|
||||
},
|
||||
"access": {
|
||||
"title": "PVE Access",
|
||||
"tokens": "API Tokens",
|
||||
"realms": "Realms",
|
||||
"type": "Type",
|
||||
"add_realm": "Add realm",
|
||||
"realm_id": "Realm",
|
||||
"realm_created": "Realm created",
|
||||
"realm_deleted": "Realm deleted",
|
||||
"delete_realm": "Delete realm",
|
||||
"err_realm": "Enter a realm name",
|
||||
"server": "Server",
|
||||
"user_attr": "User attribute",
|
||||
"domain": "Domain",
|
||||
"username_claim": "Username claim",
|
||||
"secure": "SSL/TLS (secure)",
|
||||
"builtin": "built-in",
|
||||
"select_user": "User",
|
||||
"add_token": "Create token",
|
||||
"token_id": "Token ID",
|
||||
"privsep": "Privilege separation (token has its own permissions)",
|
||||
"secret": "Secret",
|
||||
"secret_warning": "The secret is shown only once — save it now.",
|
||||
"token_created": "Token created",
|
||||
"token_deleted": "Token deleted",
|
||||
"delete_token": "Delete token",
|
||||
"err_tokenid": "Enter a token ID",
|
||||
"expire": "Expires"
|
||||
},
|
||||
"nodeadm": {
|
||||
"title": "Node administration",
|
||||
"services": "Services",
|
||||
"updates": "Updates",
|
||||
"repos": "Repositories",
|
||||
"service": "Service",
|
||||
"desc": "Description",
|
||||
"package": "Package",
|
||||
"current": "Current",
|
||||
"new": "New",
|
||||
"refresh": "Check (apt update)",
|
||||
"apt_refreshed": "Package lists are refreshing",
|
||||
"up_to_date": "No updates",
|
||||
"updates_count": "Updates available: {{n}}",
|
||||
"upgrade_hint": "Installing updates is done in the node console (apt dist-upgrade) — the API does not support unattended upgrade.",
|
||||
"repo_updated": "Repository updated",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable"
|
||||
},
|
||||
"ha": {
|
||||
"title": "High Availability",
|
||||
"add": "Add to HA",
|
||||
@@ -572,6 +791,15 @@
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"fingerprint": "Fingerprint",
|
||||
"vgname": "Volume group",
|
||||
"thinpool": "Thin pool",
|
||||
"zfs_pool": "ZFS pool / dataset",
|
||||
"ceph_pool": "Ceph pool",
|
||||
"monhost": "Monitor hosts",
|
||||
"fsname": "CephFS name",
|
||||
"sparse": "Thin provisioning",
|
||||
"disabled_ok": "Storage disabled",
|
||||
"enabled_ok": "Storage enabled",
|
||||
"disabled": "Disabled",
|
||||
"enabled": "Enabled",
|
||||
"id_required": "Storage ID is required",
|
||||
|
||||
@@ -276,6 +276,12 @@
|
||||
"overview": "Обзор",
|
||||
"graphs": "Графики",
|
||||
"networking": "Сеть",
|
||||
"hardware": "Оборудование",
|
||||
"firewall": "Firewall",
|
||||
"tags": "Теги",
|
||||
"no_tags": "Нет тегов",
|
||||
"tags_hint": "Теги через запятую",
|
||||
"tags_saved": "Теги сохранены",
|
||||
"destroy": "Уничтожить",
|
||||
"task_activity": "Активность задач",
|
||||
"status_creating": "Создание",
|
||||
@@ -453,6 +459,219 @@
|
||||
"applied": "Конфигурация сети применена",
|
||||
"reverted": "Ожидающие изменения отменены"
|
||||
},
|
||||
"hw": {
|
||||
"platform": "Платформа",
|
||||
"bios": "BIOS",
|
||||
"machine": "Тип платформы",
|
||||
"vga": "Дисплей (VGA)",
|
||||
"scsihw": "SCSI-контроллер",
|
||||
"sockets": "CPU-сокеты",
|
||||
"platform_saved": "Оборудование обновлено",
|
||||
"platform_hint": "Изменения оборудования применяются после выключения и повторного запуска VM.",
|
||||
"passthrough": "Проброс устройств",
|
||||
"no_passthrough": "Нет проброшенных устройств",
|
||||
"remove_device": "Удалить устройство",
|
||||
"device_removed": "Устройство {{key}} удалено",
|
||||
"remove_title": "Удалить устройство?",
|
||||
"remove_description": "Устройство {{key}} будет удалено из конфигурации VM.",
|
||||
"passthrough_hint": "Проброс PCI/USB требует выключенной VM и включённого IOMMU на хосте.",
|
||||
"stop_first": "Выключите VM для изменения проброса",
|
||||
"running_lock": "VM запущена — проброс устройств нельзя добавить или удалить. Выключите VM.",
|
||||
"efi_required_title": "Для OVMF (UEFI) нужен EFI-диск",
|
||||
"efi_required_hint": "EFI-диск будет создан автоматически. Выберите хранилище для него.",
|
||||
"efi_storage": "Хранилище EFI-диска",
|
||||
"select_storage": "Выберите хранилище",
|
||||
"err_efi_storage": "Выберите хранилище для EFI-диска",
|
||||
"add_submit": "Добавить",
|
||||
"add_pci_title": "Проброс PCI-устройства",
|
||||
"add_pci_description": "Будет добавлено как {{key}}. VM должна быть выключена.",
|
||||
"pci_device": "Устройство",
|
||||
"select_pci": "Выберите устройство",
|
||||
"no_pci": "Устройства не найдены",
|
||||
"pci_pcie": "PCI-Express (только для q35)",
|
||||
"pci_all_functions": "Все функции устройства",
|
||||
"pci_primary_gpu": "Основная видеокарта (x-vga)",
|
||||
"pci_rombar": "ROM-Bar",
|
||||
"pci_hint": "Проброс PCI требует включённого IOMMU (VT-d / AMD-Vi) на хосте.",
|
||||
"err_pci_required": "Выберите PCI-устройство",
|
||||
"pci_added": "Устройство {{key}} добавлено",
|
||||
"add_usb_title": "Проброс USB-устройства",
|
||||
"add_usb_description": "Будет добавлено как {{key}}",
|
||||
"usb_device": "Устройство",
|
||||
"select_usb": "Выберите устройство",
|
||||
"no_usb": "Устройства не найдены",
|
||||
"usb3": "USB 3.0",
|
||||
"err_usb_required": "Выберите USB-устройство",
|
||||
"usb_added": "Устройство {{key}} добавлено"
|
||||
},
|
||||
"fw": {
|
||||
"title": "Firewall (датацентр)",
|
||||
"on": "вкл",
|
||||
"off": "выкл",
|
||||
"enable": "Включить",
|
||||
"disable": "Выключить",
|
||||
"rules": "Правила",
|
||||
"groups": "Security Groups",
|
||||
"ipsets": "IP Sets",
|
||||
"aliases": "Aliases",
|
||||
"options": "Опции",
|
||||
"add_rule": "Добавить правило",
|
||||
"rule_hint": "Правила применяются сверху вниз.",
|
||||
"direction": "Направление",
|
||||
"action": "Действие",
|
||||
"match": "Источник → Назначение",
|
||||
"security_group": "Security group",
|
||||
"select_group": "Выберите группу",
|
||||
"proto": "Протокол",
|
||||
"macro": "Макрос",
|
||||
"source": "Источник",
|
||||
"dest": "Назначение",
|
||||
"dport": "Порт назн.",
|
||||
"sport": "Порт источн.",
|
||||
"enabled": "Включено",
|
||||
"log": "Логировать",
|
||||
"err_group_required": "Выберите security group",
|
||||
"rule_added": "Правило добавлено",
|
||||
"rule_deleted": "Правило удалено",
|
||||
"delete_rule_confirm": "Удалить правило?",
|
||||
"add_group": "Добавить security group",
|
||||
"delete_group": "Удалить security group",
|
||||
"group_name": "Имя группы",
|
||||
"group_added": "Группа создана",
|
||||
"group_deleted": "Группа удалена",
|
||||
"add_ipset": "Добавить IP set",
|
||||
"delete_ipset": "Удалить IP set",
|
||||
"ipset_name": "Имя IP set",
|
||||
"ipset_added": "IP set создан",
|
||||
"ipset_deleted": "IP set удалён",
|
||||
"add_alias": "Добавить алиас",
|
||||
"delete_alias": "Удалить алиас",
|
||||
"alias_added": "Алиас создан",
|
||||
"alias_deleted": "Алиас удалён",
|
||||
"name": "Имя",
|
||||
"entry_added": "Запись добавлена",
|
||||
"entry_deleted": "Запись удалена",
|
||||
"delete_entry": "Удалить запись",
|
||||
"enable_dc": "Включить фаервол датацентра",
|
||||
"policy_in": "Политика входящих",
|
||||
"policy_out": "Политика исходящих",
|
||||
"options_hint": "Политики применяются к трафику, не совпавшему ни с одним правилом.",
|
||||
"options_saved": "Опции сохранены",
|
||||
"err_name": "Укажите имя",
|
||||
"err_cidr": "Укажите CIDR",
|
||||
"node_title": "Firewall (нода)",
|
||||
"log_tab": "Лог",
|
||||
"enable_node": "Включить фаервол ноды",
|
||||
"log_level_in": "Лог входящих",
|
||||
"log_level_out": "Лог исходящих",
|
||||
"nosmurfs": "Блокировать SMURF-атаки",
|
||||
"tcpflags": "Фильтровать недопустимые TCP-флаги",
|
||||
"synflood": "Защита от SYN-flood",
|
||||
"node_options_hint": "Опции применяются к трафику самой ноды хоста Proxmox.",
|
||||
"guest_title": "Firewall инстанса",
|
||||
"enable_guest": "Включить фаервол инстанса",
|
||||
"dhcp": "Разрешить DHCP",
|
||||
"macfilter": "MAC-фильтр",
|
||||
"ipfilter": "IP-фильтр (анти-спуфинг)",
|
||||
"ndp": "NDP (IPv6)",
|
||||
"guest_options_hint": "Фаервол инстанса работает только когда включён фаервол датацентра и стоит firewall=1 на NIC."
|
||||
},
|
||||
"pools": {
|
||||
"title": "Пулы ресурсов",
|
||||
"add": "Создать пул",
|
||||
"pool_id": "Имя пула",
|
||||
"created": "Пул создан",
|
||||
"deleted": "Пул удалён",
|
||||
"delete": "Удалить пул",
|
||||
"delete_hint": "(пул должен быть пустым)",
|
||||
"err_id": "Укажите имя пула",
|
||||
"empty": "Пул пуст",
|
||||
"add_member": "Добавить VM/LXC",
|
||||
"select_vm": "Выберите инстанс",
|
||||
"no_available": "Нет доступных",
|
||||
"member_added": "Добавлено в пул",
|
||||
"member_removed": "Убрано из пула",
|
||||
"remove_member_confirm": "Убрать из пула?"
|
||||
},
|
||||
"disks": {
|
||||
"title": "Диски и ZFS",
|
||||
"physical": "Диски",
|
||||
"device": "Устройство",
|
||||
"type": "Тип",
|
||||
"model": "Модель",
|
||||
"size": "Объём",
|
||||
"usage": "Использование",
|
||||
"free": "свободен",
|
||||
"health": "Состояние",
|
||||
"attribute": "Атрибут",
|
||||
"value": "Знач.",
|
||||
"worst": "Худш.",
|
||||
"threshold": "Порог",
|
||||
"wipe": "Очистить",
|
||||
"wipe_confirm": "Очистить диск (все данные будут уничтожены)",
|
||||
"wiped": "Диск очищается",
|
||||
"create_zfs": "Создать ZFS-пул",
|
||||
"create_zfs_hint": "Диски будут очищены и объединены в пул.",
|
||||
"pool_name": "Имя пула",
|
||||
"raid_level": "RAID-уровень",
|
||||
"compression": "Сжатие",
|
||||
"devices": "Диски",
|
||||
"no_free_disks": "Нет свободных дисков",
|
||||
"err_name": "Укажите имя пула",
|
||||
"err_devices": "Выберите диски",
|
||||
"pool_created": "ZFS-пул создаётся",
|
||||
"destroy_pool": "Уничтожить ZFS-пул",
|
||||
"pool_destroyed": "Пул уничтожается",
|
||||
"alloc": "Занято"
|
||||
},
|
||||
"access": {
|
||||
"title": "PVE Access",
|
||||
"tokens": "API-токены",
|
||||
"realms": "Realms",
|
||||
"type": "Тип",
|
||||
"add_realm": "Добавить realm",
|
||||
"realm_id": "Realm",
|
||||
"realm_created": "Realm создан",
|
||||
"realm_deleted": "Realm удалён",
|
||||
"delete_realm": "Удалить realm",
|
||||
"err_realm": "Укажите имя realm",
|
||||
"server": "Сервер",
|
||||
"user_attr": "User attribute",
|
||||
"domain": "Домен",
|
||||
"username_claim": "Username claim",
|
||||
"secure": "SSL/TLS (secure)",
|
||||
"builtin": "встроенный",
|
||||
"select_user": "Пользователь",
|
||||
"add_token": "Создать токен",
|
||||
"token_id": "ID токена",
|
||||
"privsep": "Privilege separation (у токена свои права)",
|
||||
"secret": "Секрет",
|
||||
"secret_warning": "Секрет показывается только один раз — сохраните его сейчас.",
|
||||
"token_created": "Токен создан",
|
||||
"token_deleted": "Токен удалён",
|
||||
"delete_token": "Удалить токен",
|
||||
"err_tokenid": "Укажите ID токена",
|
||||
"expire": "Истекает"
|
||||
},
|
||||
"nodeadm": {
|
||||
"title": "Администрирование ноды",
|
||||
"services": "Сервисы",
|
||||
"updates": "Обновления",
|
||||
"repos": "Репозитории",
|
||||
"service": "Сервис",
|
||||
"desc": "Описание",
|
||||
"package": "Пакет",
|
||||
"current": "Текущая",
|
||||
"new": "Новая",
|
||||
"refresh": "Проверить (apt update)",
|
||||
"apt_refreshed": "Списки пакетов обновляются",
|
||||
"up_to_date": "Обновлений нет",
|
||||
"updates_count": "Доступно обновлений: {{n}}",
|
||||
"upgrade_hint": "Установка обновлений выполняется в консоли ноды (apt dist-upgrade) — API не поддерживает неинтерактивный upgrade.",
|
||||
"repo_updated": "Репозиторий обновлён",
|
||||
"enable": "Включить",
|
||||
"disable": "Выключить"
|
||||
},
|
||||
"ha": {
|
||||
"title": "Высокая доступность (HA)",
|
||||
"add": "Добавить в HA",
|
||||
@@ -572,6 +791,15 @@
|
||||
"username": "Пользователь",
|
||||
"password": "Пароль",
|
||||
"fingerprint": "Отпечаток",
|
||||
"vgname": "Volume group",
|
||||
"thinpool": "Thin pool",
|
||||
"zfs_pool": "ZFS пул / датасет",
|
||||
"ceph_pool": "Ceph pool",
|
||||
"monhost": "Monitor hosts",
|
||||
"fsname": "Имя CephFS",
|
||||
"sparse": "Тонкое выделение",
|
||||
"disabled_ok": "Хранилище отключено",
|
||||
"enabled_ok": "Хранилище включено",
|
||||
"disabled": "Отключено",
|
||||
"enabled": "Включено",
|
||||
"id_required": "Укажите ID хранилища",
|
||||
|
||||
Reference in New Issue
Block a user