feat(nodes): TLS-сертификаты ноды и массовая миграция инстансов
- Сертификаты: методы клиента + endpoints GET/POST/DELETE
/nodes/{node}/certificates, хуки и вкладка «Сертификаты» в
NodeAdminManager (просмотр, загрузка PEM cert+key, удаление кастомного,
бейдж срока действия)
- Bulk migrate: тип bulk_migrate в очереди задач с WS-прогрессом,
target_node/online в BulkOperationItem, диалог выбора целевой ноды и
кнопка в тулбаре инстансов (в пределах одного сервера/кластера)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -346,6 +346,69 @@ async def set_apt_repository(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== TLS certificates ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/certificates")
|
||||
async def node_certificates(
|
||||
server_id: int,
|
||||
node: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("node:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"certificates": await _run_in_executor(
|
||||
lambda: client.get_node_certificates(node))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting certificates on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/nodes/{node}/certificates")
|
||||
async def upload_node_certificate(
|
||||
server_id: int,
|
||||
node: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("node:manage")),
|
||||
):
|
||||
"""Загрузить пользовательский сертификат pveproxy (PEM cert + key)."""
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
certificates = data.get("certificates")
|
||||
if not certificates:
|
||||
raise HTTPException(status_code=400, detail="certificates (PEM) is required")
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.upload_node_certificate(
|
||||
node, certificates, data.get("key"),
|
||||
bool(data.get("force", True)), bool(data.get("restart", True)))))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error uploading certificate on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/servers/{server_id}/nodes/{node}/certificates")
|
||||
async def delete_node_certificate(
|
||||
server_id: int,
|
||||
node: str,
|
||||
restart: bool = True,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("node:manage")),
|
||||
):
|
||||
"""Удалить пользовательский сертификат (вернуть самоподписанный)."""
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(
|
||||
lambda: client.delete_node_certificate(node, restart)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting certificate on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/nodes/{node}/apt/repositories")
|
||||
async def add_standard_repository(
|
||||
server_id: int,
|
||||
|
||||
@@ -83,10 +83,12 @@ class BulkOperationItem(BaseModel):
|
||||
vm_type: str # 'qemu' or 'lxc'
|
||||
name: str
|
||||
node: str
|
||||
target_node: Optional[str] = None # для action=migrate
|
||||
online: Optional[bool] = None # live-миграция запущенной VM
|
||||
|
||||
|
||||
class BulkOperationRequest(BaseModel):
|
||||
action: str # start, stop, restart, shutdown, delete
|
||||
action: str # start, stop, restart, shutdown, delete, migrate
|
||||
items: TypingList[BulkOperationItem]
|
||||
|
||||
|
||||
@@ -3598,19 +3600,26 @@ def create_bulk_operation(
|
||||
'restart': 'bulk_restart',
|
||||
'shutdown': 'bulk_shutdown',
|
||||
'delete': 'bulk_delete',
|
||||
'migrate': 'bulk_migrate',
|
||||
}
|
||||
|
||||
|
||||
task_type = action_to_task_type.get(request.action)
|
||||
if not task_type:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid action: {request.action}")
|
||||
|
||||
|
||||
if not request.items:
|
||||
raise HTTPException(status_code=400, detail="No items selected")
|
||||
|
||||
|
||||
# For delete action, require higher permission
|
||||
if request.action == 'delete':
|
||||
if not check_permission(current_user, "vm:delete"):
|
||||
raise HTTPException(status_code=403, detail="Delete permission required")
|
||||
|
||||
# For migrate, каждый элемент обязан нести целевую ноду
|
||||
if request.action == 'migrate':
|
||||
missing = [i.vmid for i in request.items if not i.target_node]
|
||||
if missing:
|
||||
raise HTTPException(status_code=400, detail="target_node is required for migrate")
|
||||
|
||||
# Convert to list of dicts
|
||||
items = [item.model_dump() for item in request.items]
|
||||
|
||||
@@ -253,3 +253,62 @@ class NodeAdminMixin:
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding standard repo {handle} on {node}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ---------- TLS certificates ----------
|
||||
|
||||
def get_node_certificates(self, node: str) -> List[Dict]:
|
||||
"""
|
||||
Информация о TLS-сертификатах ноды.
|
||||
GET /nodes/{node}/certificates/info →
|
||||
[{filename, fingerprint, subject, issuer, notbefore, notafter, san,
|
||||
pem, public-key-type, public-key-bits}].
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.nodes(node).certificates.info.get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting certificates for {node}: {e}")
|
||||
return []
|
||||
|
||||
def upload_node_certificate(self, node: str, certificates: str, key: str = None,
|
||||
force: bool = True, restart: bool = True) -> Dict:
|
||||
"""
|
||||
Загрузить/заменить пользовательский сертификат pveproxy (PEM).
|
||||
POST /nodes/{node}/certificates/custom.
|
||||
|
||||
Args:
|
||||
certificates: PEM-цепочка (сертификат + промежуточные)
|
||||
key: приватный ключ PEM (опционально, если уже загружен)
|
||||
force: перезаписать существующий пользовательский сертификат
|
||||
restart: перезапустить pveproxy для применения
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
params = {
|
||||
"certificates": certificates,
|
||||
"force": 1 if force else 0,
|
||||
"restart": 1 if restart else 0,
|
||||
}
|
||||
if key:
|
||||
params["key"] = key
|
||||
try:
|
||||
self.proxmox.nodes(node).certificates.custom.post(**params)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error uploading certificate on {node}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_node_certificate(self, node: str, restart: bool = True) -> Dict:
|
||||
"""
|
||||
Удалить пользовательский сертификат (вернуться к самоподписанному).
|
||||
DELETE /nodes/{node}/certificates/custom.
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.nodes(node).certificates.custom.delete(restart=1 if restart else 0)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting certificate on {node}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@@ -40,7 +40,7 @@ class TaskQueueService:
|
||||
"""Service for managing and processing bulk operation tasks"""
|
||||
|
||||
# Valid task types
|
||||
TASK_TYPES = ['bulk_start', 'bulk_stop', 'bulk_restart', 'bulk_delete', 'bulk_shutdown']
|
||||
TASK_TYPES = ['bulk_start', 'bulk_stop', 'bulk_restart', 'bulk_delete', 'bulk_shutdown', 'bulk_migrate']
|
||||
|
||||
@staticmethod
|
||||
def create_task(
|
||||
@@ -164,12 +164,14 @@ class TaskQueueProcessor:
|
||||
return None
|
||||
|
||||
def _execute_vm_action(
|
||||
self,
|
||||
client: ProxmoxClient,
|
||||
action: str,
|
||||
vmid: int,
|
||||
vm_type: str,
|
||||
node: str
|
||||
self,
|
||||
client: ProxmoxClient,
|
||||
action: str,
|
||||
vmid: int,
|
||||
vm_type: str,
|
||||
node: str,
|
||||
target_node: Optional[str] = None,
|
||||
online: bool = False,
|
||||
) -> tuple[bool, str]:
|
||||
"""Execute action on VM/container"""
|
||||
try:
|
||||
@@ -185,6 +187,10 @@ class TaskQueueProcessor:
|
||||
client.stop_container(node, vmid)
|
||||
elif action == 'delete':
|
||||
client.delete_container(node, vmid)
|
||||
elif action == 'migrate':
|
||||
if not target_node:
|
||||
return False, "target_node is required"
|
||||
client.migrate_container(node, vmid, target_node, online=online)
|
||||
else:
|
||||
return False, f"Unknown action: {action}"
|
||||
else: # qemu
|
||||
@@ -199,9 +205,13 @@ class TaskQueueProcessor:
|
||||
client.stop_vm(node, vmid)
|
||||
elif action == 'delete':
|
||||
client.delete_vm(node, vmid)
|
||||
elif action == 'migrate':
|
||||
if not target_node:
|
||||
return False, "target_node is required"
|
||||
client.migrate_vm(node, vmid, target_node, online=online)
|
||||
else:
|
||||
return False, f"Unknown action: {action}"
|
||||
|
||||
|
||||
return True, "OK"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
@@ -229,6 +239,7 @@ class TaskQueueProcessor:
|
||||
'bulk_restart': 'restart',
|
||||
'bulk_shutdown': 'shutdown',
|
||||
'bulk_delete': 'delete',
|
||||
'bulk_migrate': 'migrate',
|
||||
}
|
||||
action = action_map.get(task.task_type)
|
||||
|
||||
@@ -248,7 +259,9 @@ class TaskQueueProcessor:
|
||||
vm_type = item.get('vm_type', 'qemu')
|
||||
node = item.get('node', '')
|
||||
name = item.get('name', f'{vm_type.upper()}-{vmid}')
|
||||
|
||||
target_node = item.get('target_node')
|
||||
online = bool(item.get('online'))
|
||||
|
||||
client = self._get_proxmox_client(db, server_id)
|
||||
if not client:
|
||||
results.append({
|
||||
@@ -260,7 +273,8 @@ class TaskQueueProcessor:
|
||||
})
|
||||
task.failed_items += 1
|
||||
else:
|
||||
success, message = self._execute_vm_action(client, action, vmid, vm_type, node)
|
||||
success, message = self._execute_vm_action(
|
||||
client, action, vmid, vm_type, node, target_node, online)
|
||||
results.append({
|
||||
'server_id': server_id,
|
||||
'vmid': vmid,
|
||||
|
||||
97
frontend/src/features/instances/BulkMigrateDialog.tsx
Normal file
97
frontend/src/features/instances/BulkMigrateDialog.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRightLeft } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useNodes } from '@/hooks/use-nodes';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
serverId: number;
|
||||
/** Ноды, на которых сейчас находятся выбранные инстансы (для исключения). */
|
||||
sourceNodes: string[];
|
||||
count: number;
|
||||
onConfirm: (targetNode: string, online: boolean) => void;
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Диалог массовой миграции: выбор целевой ноды кластера и режима online.
|
||||
* Целевая нода не может совпадать с единственной исходной. Инстансы, уже
|
||||
* находящиеся на выбранной ноде, родитель отфильтровывает при отправке.
|
||||
*/
|
||||
export default function BulkMigrateDialog({
|
||||
open, onOpenChange, serverId, sourceNodes, count, onConfirm, pending,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: nodesData } = useNodes(serverId);
|
||||
const [target, setTarget] = useState('');
|
||||
const [online, setOnline] = useState(true);
|
||||
|
||||
const nodeNames = useMemo(() => (nodesData?.nodes ?? []).map((n) => n.node), [nodesData]);
|
||||
const singleSource = sourceNodes.length === 1 ? sourceNodes[0] : null;
|
||||
|
||||
// Автовыбор первой подходящей ноды при открытии.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const candidates = nodeNames.filter((n) => n !== singleSource);
|
||||
if (!target || !candidates.includes(target)) setTarget(candidates[0] ?? '');
|
||||
}, [open, nodeNames, singleSource]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ArrowRightLeft className="h-5 w-5" />
|
||||
{t('instances.bulk_migrate_title', 'Массовая миграция ({{count}})', { count })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('instances.bulk_migrate_desc', 'Выберите целевую ноду кластера. Инстансы, уже находящиеся на ней, будут пропущены.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t('instances.target_node', 'Целевая нода')}</label>
|
||||
<Select value={target} onValueChange={(v) => { if (v) setTarget(v); }}>
|
||||
<SelectTrigger><SelectValue placeholder={t('instances.select_node', 'Выберите ноду')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{nodeNames.filter((n) => n !== singleSource).map((n) => (
|
||||
<SelectItem key={n} value={n}>{n}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={online}
|
||||
onChange={(e) => setOnline(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
{t('instances.online_migration', 'Онлайн-миграция запущенных (live)')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel', 'Отмена')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onConfirm(target, online)}
|
||||
disabled={!target || pending}
|
||||
>
|
||||
<ArrowRightLeft className="mr-1 h-4 w-4" />
|
||||
{t('instances.migrate', 'Мигрировать')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -41,8 +41,10 @@ import {
|
||||
ArrowUp,
|
||||
HardDrive,
|
||||
Lock,
|
||||
ArrowRightLeft,
|
||||
} from 'lucide-react';
|
||||
import InstanceActionDialogs, { PowerConfirmDialog, type InstanceAction, type PowerAction } from './InstanceActionDialogs';
|
||||
import BulkMigrateDialog from './BulkMigrateDialog';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -237,6 +239,7 @@ export default function InstancesPage() {
|
||||
const bulkOp = useBulkOperation();
|
||||
const bulkTasks = useBulkTasksStore((s) => s.tasks);
|
||||
const addBulkTask = useBulkTasksStore((s) => s.addTask);
|
||||
const [migrateOpen, setMigrateOpen] = useState(false);
|
||||
|
||||
// Human-readable label for an in-flight bulk action (status overlay / progress).
|
||||
const bulkActionLabel = useCallback((action: string) => {
|
||||
@@ -246,6 +249,7 @@ export default function InstancesPage() {
|
||||
case 'restart': return t('instances.status_restarting', 'Перезагрузка');
|
||||
case 'shutdown': return t('instances.status_shutting_down', 'Выключение');
|
||||
case 'delete': return t('instances.status_deleting', 'Удаление');
|
||||
case 'migrate': return t('instances.status_migrating', 'Миграция');
|
||||
default: return action;
|
||||
}
|
||||
}, [t]);
|
||||
@@ -531,6 +535,58 @@ export default function InstancesPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// Миграция возможна только внутри одного сервера/кластера.
|
||||
const bulkServerId = useMemo(() => {
|
||||
const ids = new Set(selectedVMs.map((vm) => vm.server_id));
|
||||
return ids.size === 1 ? selectedVMs[0]?.server_id ?? null : null;
|
||||
}, [selectedVMs]);
|
||||
const bulkSourceNodes = useMemo(
|
||||
() => Array.from(new Set(selectedVMs.map((vm) => vm.node))),
|
||||
[selectedVMs],
|
||||
);
|
||||
|
||||
const handleBulkMigrate = (targetNode: string, online: boolean) => {
|
||||
// Пропускаем инстансы, уже находящиеся на целевой ноде.
|
||||
const toMigrate = selectedVMs.filter((vm) => vm.node !== targetNode);
|
||||
if (!toMigrate.length) {
|
||||
toast.info(t('instances.nothing_to_migrate', 'Все выбранные инстансы уже на целевой ноде'));
|
||||
setMigrateOpen(false);
|
||||
return;
|
||||
}
|
||||
const items = toMigrate.map((vm) => ({
|
||||
server_id: vm.server_id!,
|
||||
vmid: vm.vmid,
|
||||
vm_type: vm.type,
|
||||
name: vm.name || String(vm.vmid),
|
||||
node: vm.node,
|
||||
target_node: targetNode,
|
||||
online: online && vm.status === 'running',
|
||||
}));
|
||||
bulkOp.mutate(
|
||||
{ action: 'migrate', items },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (data?.task_id) {
|
||||
addBulkTask({
|
||||
id: data.task_id,
|
||||
action: 'migrate',
|
||||
items: items.map((i) => ({ server_id: i.server_id, vmid: i.vmid, name: i.name })),
|
||||
status: 'pending',
|
||||
total: items.length,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
results: [],
|
||||
});
|
||||
}
|
||||
toast.success(`${bulkActionLabel('migrate')}: ${items.length}`);
|
||||
setMigrateOpen(false);
|
||||
setRowSelection({});
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== Table Columns ====================
|
||||
const columns = useMemo<ColumnDef<VMInstance>[]>(
|
||||
() => [
|
||||
@@ -1028,6 +1084,14 @@ export default function InstancesPage() {
|
||||
<Button variant="outline" size="sm" onClick={() => handleBulk('stop')} disabled={bulkOp.isPending}>
|
||||
<Square className="mr-1 h-3 w-3" /> {t('common.stop', 'Остановить')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => setMigrateOpen(true)}
|
||||
disabled={bulkOp.isPending || !bulkServerId}
|
||||
title={!bulkServerId ? t('instances.migrate_same_server', 'Выберите инстансы одного сервера') : undefined}
|
||||
>
|
||||
<ArrowRightLeft className="mr-1 h-3 w-3" /> {t('instances.migrate', 'Мигрировать')}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => handleBulk('delete')} disabled={bulkOp.isPending}>
|
||||
<Trash2 className="mr-1 h-3 w-3" /> {t('common.delete', 'Удалить')}
|
||||
</Button>
|
||||
@@ -1037,6 +1101,18 @@ export default function InstancesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bulkServerId && (
|
||||
<BulkMigrateDialog
|
||||
open={migrateOpen}
|
||||
onOpenChange={setMigrateOpen}
|
||||
serverId={bulkServerId}
|
||||
sourceNodes={bulkSourceNodes}
|
||||
count={selectedVMs.length}
|
||||
onConfirm={handleBulkMigrate}
|
||||
pending={bulkOp.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk operation progress (tracked like instance creation) */}
|
||||
{bulkTasks.map((task) => {
|
||||
const processed = task.completed + task.failed;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { toast } from 'sonner';
|
||||
import NodeSyslog from './NodeSyslog';
|
||||
import NodeTasks from './NodeTasks';
|
||||
import NodeSystemConfig from './NodeSystemConfig';
|
||||
import NodeCertificates from './NodeCertificates';
|
||||
|
||||
function stateVariant(s?: string): 'default' | 'secondary' | 'destructive' {
|
||||
if (!s) return 'secondary';
|
||||
@@ -90,6 +91,7 @@ export default function NodeAdminManager({ serverId, nodeNames }: { serverId: nu
|
||||
<TabsTrigger value="services">{t('nodeadm.services', 'Сервисы')} ({services.length})</TabsTrigger>
|
||||
<TabsTrigger value="updates">{t('nodeadm.updates', 'Обновления')}</TabsTrigger>
|
||||
<TabsTrigger value="repos">{t('nodeadm.repos', 'Репозитории')}</TabsTrigger>
|
||||
<TabsTrigger value="certs">{t('nodeadm.certs', 'Сертификаты')}</TabsTrigger>
|
||||
<TabsTrigger value="syslog">{t('nodeadm.syslog', 'Журнал')}</TabsTrigger>
|
||||
<TabsTrigger value="tasks">{t('nodeadm.tasks', 'Задачи')}</TabsTrigger>
|
||||
<TabsTrigger value="system">{t('nodeadm.system', 'Система')}</TabsTrigger>
|
||||
@@ -195,6 +197,11 @@ export default function NodeAdminManager({ serverId, nodeNames }: { serverId: nu
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
{/* TLS certificates */}
|
||||
<TabsContent value="certs" className="mt-4">
|
||||
{tab === 'certs' && <NodeCertificates serverId={serverId} node={node} />}
|
||||
</TabsContent>
|
||||
|
||||
{/* Syslog */}
|
||||
<TabsContent value="syslog" className="mt-4">
|
||||
{tab === 'syslog' && <NodeSyslog serverId={serverId} node={node} />}
|
||||
|
||||
188
frontend/src/features/nodes/NodeCertificates.tsx
Normal file
188
frontend/src/features/nodes/NodeCertificates.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ShieldCheck, Upload, Trash2, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
useNodeCertificates, useUploadNodeCertificate, useDeleteNodeCertificate,
|
||||
type NodeCertificate,
|
||||
} from '@/hooks/use-node-admin';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
/** Форматирование unix-времени (сек) в локальную дату; пустое — прочерк. */
|
||||
function fmtDate(ts?: number): string {
|
||||
if (!ts) return '—';
|
||||
try {
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
/** Дней до истечения (может быть отрицательным — уже просрочен). */
|
||||
function daysLeft(notafter?: number): number | null {
|
||||
if (!notafter) return null;
|
||||
return Math.floor((notafter * 1000 - Date.now()) / 86_400_000);
|
||||
}
|
||||
|
||||
function ExpiryBadge({ notafter }: { notafter?: number }) {
|
||||
const { t } = useTranslation();
|
||||
const d = daysLeft(notafter);
|
||||
if (d === null) return null;
|
||||
if (d < 0) return <Badge variant="destructive">{t('certs.expired', 'Просрочен')}</Badge>;
|
||||
if (d < 30) return <Badge variant="destructive">{t('certs.days_left', '{{d}} дн.', { d })}</Badge>;
|
||||
return <Badge variant="secondary">{t('certs.days_left', '{{d}} дн.', { d })}</Badge>;
|
||||
}
|
||||
|
||||
export default function NodeCertificates({ serverId, node }: { serverId: number; node: string }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const canManage = !!useAuthStore((s) => s.user?.permissions?.['node:manage']);
|
||||
|
||||
const { data, isFetching, refetch } = useNodeCertificates(serverId, node);
|
||||
const upload = useUploadNodeCertificate(serverId, node);
|
||||
const remove = useDeleteNodeCertificate(serverId, node);
|
||||
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [cert, setCert] = useState('');
|
||||
const [key, setKey] = useState('');
|
||||
|
||||
const certs = data?.certificates ?? [];
|
||||
|
||||
const doUpload = () => {
|
||||
if (!cert.trim()) {
|
||||
toast.error(t('certs.cert_required', 'Вставьте сертификат в формате PEM'));
|
||||
return;
|
||||
}
|
||||
upload.mutate(
|
||||
{ certificates: cert, key: key.trim() || undefined, force: true, restart: true },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t('certs.uploaded', 'Сертификат загружен, pveproxy перезапущен'));
|
||||
setShowUpload(false);
|
||||
setCert('');
|
||||
setKey('');
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
const ok = await confirm(
|
||||
t('certs.confirm_delete', 'Удалить пользовательский сертификат ноды «{{node}}»? Будет восстановлен самоподписанный, pveproxy перезапустится.', { node }),
|
||||
{ title: t('certs.delete', 'Удалить сертификат'), variant: 'destructive' },
|
||||
);
|
||||
if (!ok) return;
|
||||
remove.mutate(true, {
|
||||
onSuccess: () => toast.success(t('certs.deleted', 'Сертификат удалён, восстановлен самоподписанный')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||||
<ShieldCheck className="h-4 w-4" />{t('certs.title', 'TLS-сертификаты')}
|
||||
</h3>
|
||||
<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>
|
||||
{canManage && (
|
||||
<Button size="sm" onClick={() => setShowUpload((v) => !v)}>
|
||||
<Upload className="mr-1 h-4 w-4" />{t('certs.upload', 'Загрузить сертификат')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showUpload && canManage && (
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">{t('certs.pem_cert', 'Сертификат (PEM, с цепочкой)')}</label>
|
||||
<textarea
|
||||
value={cert}
|
||||
onChange={(e) => setCert(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="-----BEGIN CERTIFICATE-----"
|
||||
className="h-40 w-full rounded-md border bg-background p-3 font-mono text-xs leading-relaxed outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">{t('certs.pem_key', 'Приватный ключ (PEM, опционально)')}</label>
|
||||
<textarea
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="-----BEGIN PRIVATE KEY-----"
|
||||
className="h-32 w-full rounded-md border bg-background p-3 font-mono text-xs leading-relaxed outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={doUpload} disabled={upload.isPending}>
|
||||
<Upload className="mr-1 h-4 w-4" />{t('certs.apply', 'Применить')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setShowUpload(false)}>
|
||||
{t('common.cancel', 'Отмена')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{certs.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{certs.map((c: NodeCertificate) => {
|
||||
const isCustom = c.filename === 'pveproxy-ssl.pem';
|
||||
return (
|
||||
<div key={c.filename || c.fingerprint} className="space-y-2 rounded-lg border p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-sm font-medium">{c.filename || '—'}</span>
|
||||
<Badge variant={isCustom ? 'default' : 'secondary'}>
|
||||
{isCustom ? t('certs.custom', 'Пользовательский') : t('certs.builtin', 'Самоподписанный')}
|
||||
</Badge>
|
||||
<ExpiryBadge notafter={c.notafter} />
|
||||
{canManage && isCustom && (
|
||||
<Button
|
||||
size="sm" variant="outline"
|
||||
className="ml-auto h-7 text-destructive"
|
||||
onClick={doDelete} disabled={remove.isPending}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />{t('certs.delete', 'Удалить')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-1 text-xs sm:grid-cols-2">
|
||||
<Row label={t('certs.subject', 'Subject')} value={c.subject} mono />
|
||||
<Row label={t('certs.issuer', 'Issuer')} value={c.issuer} mono />
|
||||
<Row label={t('certs.notbefore', 'Действителен с')} value={fmtDate(c.notbefore)} />
|
||||
<Row label={t('certs.notafter', 'Действителен до')} value={fmtDate(c.notafter)} />
|
||||
<Row label={t('certs.pubkey', 'Ключ')} value={c['public-key-type'] ? `${c['public-key-type']} ${c['public-key-bits'] || ''}` : undefined} />
|
||||
<Row label={t('certs.fingerprint', 'Отпечаток')} value={c.fingerprint} mono />
|
||||
{c.san && c.san.length > 0 && (
|
||||
<Row label="SAN" value={c.san.join(', ')} mono />
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, mono }: { label: string; value?: string; mono?: boolean }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<dt className="shrink-0 text-muted-foreground">{label}:</dt>
|
||||
<dd className={`min-w-0 break-all ${mono ? 'font-mono' : ''}`}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -487,6 +487,8 @@ interface BulkItem {
|
||||
vm_type: string;
|
||||
name: string;
|
||||
node: string;
|
||||
target_node?: string; // для action='migrate'
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
interface BulkOperationResponse {
|
||||
|
||||
@@ -75,6 +75,19 @@ export interface NodeTime {
|
||||
localtime?: number;
|
||||
}
|
||||
|
||||
export interface NodeCertificate {
|
||||
filename?: string;
|
||||
fingerprint?: string;
|
||||
subject?: string;
|
||||
issuer?: string;
|
||||
notbefore?: number;
|
||||
notafter?: number;
|
||||
san?: string[];
|
||||
pem?: string;
|
||||
'public-key-type'?: string;
|
||||
'public-key-bits'?: number;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -85,6 +98,7 @@ export const nodeAdminKeys = {
|
||||
dns: (s: number, n: string) => ['node-dns', s, n] as const,
|
||||
hosts: (s: number, n: string) => ['node-hosts', s, n] as const,
|
||||
time: (s: number, n: string) => ['node-time', s, n] as const,
|
||||
certificates: (s: number, n: string) => ['node-certificates', s, n] as const,
|
||||
};
|
||||
|
||||
const base = (s: number, n: string) => `/proxmox/api/servers/${s}/nodes/${n}`;
|
||||
@@ -225,3 +239,30 @@ export function useSetAptRepository(serverId: number, node: string) {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: nodeAdminKeys.repos(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- TLS certificates ----------
|
||||
export function useNodeCertificates(serverId: number, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: nodeAdminKeys.certificates(serverId, node),
|
||||
queryFn: () => apiClient.get<{ certificates: NodeCertificate[] }>(`${base(serverId, node)}/certificates`),
|
||||
enabled: serverId > 0 && !!node && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadNodeCertificate(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { certificates: string; key?: string; force?: boolean; restart?: boolean }) =>
|
||||
apiClient.post(`${base(serverId, node)}/certificates`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: nodeAdminKeys.certificates(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteNodeCertificate(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<unknown, Error, boolean>({
|
||||
mutationFn: (restart) =>
|
||||
apiClient.delete(`${base(serverId, node)}/certificates?restart=${restart ? 'true' : 'false'}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: nodeAdminKeys.certificates(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user