2 Commits

Author SHA1 Message Date
f18ba4d597 revert(nodes): remove server auto-setup feature
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 1m35s
Deploy to Production / Trigger production update (push) Successful in 8s
Drop the /api/servers/auto-setup endpoint, the AutoSetupDialog UI and the
useAutoSetupServer hook. Rename the auto_setup locale strings to
auto_create_token. Also normalize the storages response to an array.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 22:18:38 +05:00
493814f44f fix(dashboard): populate recent-activity feed from audit logs
Align the frontend with the /logs API contract: the endpoint returns
{ logs: [...] } with a created_at field, not { items: [...] } / timestamp.
Add a Vite dev-proxy rule for /logs so the request reaches the backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 22:18:30 +05:00
8 changed files with 29 additions and 388 deletions

View File

@@ -146,284 +146,6 @@ def create_proxmox_server(
return server
@router.post("/api/servers/auto-setup", status_code=status.HTTP_201_CREATED)
async def auto_setup_proxmox_server(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.servers.add"))
):
"""
Автоматическая настройка Proxmox сервера:
1. Проверяем подключение по логину/паролю
2. Создаем API Token автоматически
3. Сохраняем сервер в базу с токеном и паролем (для VNC)
"""
import requests as http_requests
import uuid
# Получаем данные из body
data = await request.json()
name = data.get('name')
hostname = data.get('hostname')
ip_address = data.get('ip_address')
port = data.get('port', 8006)
api_user = data.get('api_user', 'root@pam')
password = data.get('password')
verify_ssl = data.get('verify_ssl', True)
description = data.get('description')
if not all([name, ip_address, password]):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Необходимо указать: name, ip_address, password"
)
# Разрешаем несколько серверов с одинаковым IP
# Проверяем только дубликаты имён
existing = db.query(ProxmoxServer).filter(ProxmoxServer.name == name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Сервер с именем '{name}' уже существует"
)
base_url = f"https://{ip_address}:{port}"
# 1. Получить auth ticket
logger.info(f"Attempting to connect to Proxmox at {base_url}")
try:
auth_response = await _run_in_executor(
http_requests.post,
f"{base_url}/api2/json/access/ticket",
data={"username": api_user, "password": password},
verify=verify_ssl,
timeout=15
)
if auth_response.status_code != 200:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Ошибка авторизации в Proxmox: неверный логин или пароль"
)
auth_data = auth_response.json().get("data", {})
ticket = auth_data.get("ticket")
csrf_token = auth_data.get("CSRFPreventionToken")
if not ticket:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Не удалось получить auth ticket от Proxmox"
)
logger.info("Successfully authenticated to Proxmox")
except http_requests.exceptions.RequestException as e:
logger.error(f"Connection error to Proxmox: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Не удалось подключиться к Proxmox: {str(e)}"
)
# 2. Создать API Token
# Извлекаем username из api_user (root@pam -> root)
username_part = api_user.split('@')[0] if '@' in api_user else api_user
realm = api_user.split('@')[1] if '@' in api_user else 'pam'
token_name = f"panel-{uuid.uuid4().hex[:8]}"
headers = {
"Cookie": f"PVEAuthCookie={ticket}",
"CSRFPreventionToken": csrf_token
}
try:
# Создаем токен через API
token_response = await _run_in_executor(
http_requests.post,
f"{base_url}/api2/json/access/users/{api_user}/token/{token_name}",
headers=headers,
data={"privsep": "0"}, # Полные права как у пользователя
verify=verify_ssl,
timeout=15
)
if token_response.status_code not in [200, 201]:
logger.warning(f"Failed to create token: {token_response.status_code} - {token_response.text}")
# Если не удалось создать токен, работаем только с паролем
token_value = None
token_name = None
logger.info("Will use password authentication only")
else:
token_data = token_response.json().get("data", {})
token_value = token_data.get("value")
logger.info("Created API token successfully")
except Exception as e:
logger.warning(f"Error creating token: {e}, will use password auth")
token_value = None
token_name = None
# 3. Получить список нод кластера и их IP адреса
cluster_nodes = []
node_ips = {} # node_name -> ip_address
try:
# Получаем список нод
nodes_response = await _run_in_executor(
http_requests.get,
f"{base_url}/api2/json/nodes",
headers=headers,
cookies={"PVEAuthCookie": ticket},
verify=verify_ssl,
timeout=15
)
if nodes_response.status_code == 200:
nodes_data = nodes_response.json().get("data", [])
cluster_nodes = [n for n in nodes_data if n.get("node")]
logger.info(f"Found {len(cluster_nodes)} nodes in cluster")
# Получаем IP адреса нод из cluster/status
if len(cluster_nodes) > 1:
cluster_status_response = await _run_in_executor(
http_requests.get,
f"{base_url}/api2/json/cluster/status",
headers=headers,
cookies={"PVEAuthCookie": ticket},
verify=verify_ssl,
timeout=15
)
if cluster_status_response.status_code == 200:
cluster_status = cluster_status_response.json().get("data", [])
for item in cluster_status:
if item.get("type") == "node" and item.get("name") and item.get("ip"):
node_ips[item.get("name")] = item.get("ip")
logger.info(f"Node {item.get('name')} IP: {item.get('ip')}")
except Exception as e:
logger.warning(f"Could not get cluster nodes: {e}")
# 4. Сохранить серверы в базу
created_servers = []
if len(cluster_nodes) > 1:
# Это кластер - добавляем все ноды
for node_info in cluster_nodes:
node_name = node_info.get("node")
# Используем IP ноды из cluster/status, или IP через который подключились
node_ip = node_ips.get(node_name, ip_address)
# Имя сервера: "BaseName - NodeName" или просто NodeName
if name:
server_name = f"{name} - {node_name}"
else:
server_name = node_name
# Проверяем дубликаты
existing = db.query(ProxmoxServer).filter(ProxmoxServer.name == server_name).first()
if existing:
logger.info(f"Server '{server_name}' already exists, skipping")
continue
# Для каждой ноды создаём токен (используем тот же, так как токен кластерный)
server = ProxmoxServer(
name=server_name,
hostname=node_name,
ip_address=node_ip, # Используем реальный IP ноды
port=port,
api_user=api_user,
api_token_name=token_name,
api_token_value=token_value,
use_password=token_value is None,
password=password,
verify_ssl=verify_ssl,
description=f"{description or ''} (Node: {node_name})".strip(),
is_online=node_info.get("status") == "online"
)
db.add(server)
created_servers.append({
"name": server_name,
"node": node_name,
"ip": node_ip,
"status": node_info.get("status", "unknown")
})
db.commit()
logger.info(f"User {current_user.username} auto-setup Proxmox cluster with {len(created_servers)} nodes")
# Мгновенный sync VM-кэша + WS-broadcast для real-time обновления UI
try:
from ...workers.monitoring_worker import monitoring_worker
monitoring_worker.sync_vm_cache()
except Exception as _e:
logger.debug(f"VM sync after auto-setup failed: {_e}")
try:
from ...websocket_manager import broadcast_event
broadcast_event("server_added", cluster=True, count=len(created_servers))
except Exception as _e:
logger.debug(f"Failed to broadcast server_added (cluster): {_e}")
return {
"cluster": True,
"nodes_count": len(created_servers),
"servers": created_servers,
"message": f"Добавлено {len(created_servers)} нод кластера"
}
else:
# Одиночный сервер
server = ProxmoxServer(
name=name,
hostname=hostname or ip_address,
ip_address=ip_address,
port=port,
api_user=api_user,
api_token_name=token_name,
api_token_value=token_value,
use_password=token_value is None,
password=password,
verify_ssl=verify_ssl,
description=description,
is_online=True
)
db.add(server)
db.commit()
db.refresh(server)
logger.info(f"User {current_user.username} auto-setup Proxmox server: {server.name}")
# Мгновенный sync VM-кэша + WS-broadcast для real-time обновления UI
try:
from ...workers.monitoring_worker import monitoring_worker
monitoring_worker.sync_vm_cache()
except Exception as _e:
logger.debug(f"VM sync after auto-setup failed: {_e}")
try:
from ...websocket_manager import broadcast_event
broadcast_event(
"server_added",
server_id=server.id,
name=server.name,
is_online=bool(server.is_online),
)
except Exception as _e:
logger.debug(f"Failed to broadcast server_added: {_e}")
return {
"cluster": False,
"id": server.id,
"name": server.name,
"hostname": server.hostname,
"ip_address": server.ip_address,
"port": server.port,
"api_user": server.api_user,
"api_token_name": token_name or "(пароль)",
"use_password": server.use_password,
"verify_ssl": server.verify_ssl,
"description": server.description,
"is_online": server.is_online
}
@router.get("/api/servers/{server_id}", response_model=ProxmoxServerResponse)

View File

@@ -54,7 +54,7 @@ interface ResourceCardProps {
function ResourceCard({ label, percent, used, total, icon: Icon }: ResourceCardProps) {
const barColor =
percent >= 90 ? 'bg-red' : percent >= 70 ? 'bg-amber' : 'bg-green';
percent >= 90 ? 'bg-red-500' : percent >= 70 ? 'bg-amber-500' : 'bg-green-500';
return (
<Card>
@@ -66,7 +66,7 @@ function ResourceCard({ label, percent, used, total, icon: Icon }: ResourceCardP
</span>
</div>
<p className="mb-2 text-2xl font-bold tabular-nums">{percent.toFixed(1)}%</p>
<div className="mb-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div className="mb-2 h-2 overflow-hidden rounded-full bg-white/10">
<div
className={`h-full rounded-full transition-all ${barColor}`}
style={{ width: `${Math.min(percent, 100)}%` }}
@@ -92,10 +92,10 @@ interface ActivityItem {
function ActivityFeed({ items }: { items: ActivityItem[] }) {
const { t } = useTranslation();
const typeColor: Record<string, string> = {
success: 'bg-green/20 text-green',
error: 'bg-red/20 text-red',
warning: 'bg-amber/20 text-amber',
info: 'bg-blue/20 text-blue',
success: 'bg-green-500/20 text-green-500',
error: 'bg-red-500/20 text-red-500',
warning: 'bg-amber-500/20 text-amber-500',
info: 'bg-blue-500/20 text-blue-500',
};
return (
@@ -163,7 +163,7 @@ export default function DashboardPage() {
// Fetch recent activity from audit logs
const { data: recentLogs } = useQuery({
queryKey: ['dashboard-activity'],
queryFn: () => apiClient.get<{ items: Array<{ timestamp: string; username?: string; message: string; category?: string; level?: string }> }>('/logs/api/logs?limit=10'),
queryFn: () => apiClient.get<{ logs: Array<{ created_at: string; username?: string; message: string; category?: string; level?: string }> }>('/logs/api/logs?limit=10'),
refetchInterval: 30000,
});
@@ -309,7 +309,7 @@ export default function DashboardPage() {
<div key={i} className="flex items-center gap-3 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50">
<span
className={`h-2 w-2 shrink-0 rounded-full ${
vm.status === 'running' ? 'bg-green' : 'bg-muted-foreground'
vm.status === 'running' ? 'bg-green-500' : 'bg-muted-foreground'
}`}
/>
<span className="min-w-0 flex-1 truncate font-medium">
@@ -329,8 +329,8 @@ export default function DashboardPage() {
</Card>
{/* Activity */}
<ActivityFeed items={(recentLogs?.items ?? []).map((log) => {
const d = new Date(log.timestamp);
<ActivityFeed items={(recentLogs?.logs ?? []).map((log) => {
const d = new Date(log.created_at);
return {
time: `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`,
user: log.username,

View File

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

View File

@@ -105,7 +105,9 @@ export function DownloadCTDialog({ open, onClose }: { open: boolean; onClose: ()
// Storages that accept CT templates (vztmpl content).
const storages = useMemo(
() => (storagesRaw as { storage: string; content?: string }[]).filter(s => (s.content || '').split(',').includes('vztmpl')),
() => (storagesRaw as { storage: string; content?: string }[]).filter(
s => (s.content || '').split(',').includes('vztmpl')
),
[storagesRaw],
);

View File

@@ -68,7 +68,9 @@ export function useClusterTopology() {
export function useStorages(serverId: number, node: string) {
return useQuery({
queryKey: nodeKeys.storages(serverId, node),
queryFn: () => apiClient.get<unknown[]>(`/proxmox/api/${serverId}/storages?node=${node}`),
queryFn: () => apiClient
.get<{ storages: unknown[] } | unknown[]>(`/proxmox/api/${serverId}/storages?node=${node}`)
.then(r => Array.isArray(r) ? r : (r as { storages: unknown[] }).storages),
enabled: serverId > 0 && !!node,
});
}
@@ -146,38 +148,6 @@ export function useClusterInfo(serverId: number) {
});
}
export interface AutoSetupArgs {
name: string;
hostname?: string;
ip_address: string;
port?: number;
api_user?: string;
password: string;
verify_ssl?: boolean;
description?: string;
}
export interface AutoSetupResult {
cluster?: boolean;
nodes_count?: number;
servers?: Array<{ name: string; node: string; ip: string; status: string }>;
message?: string;
}
// Add a server by password — the backend auto-creates an API token.
export function useAutoSetupServer() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: AutoSetupArgs) => apiClient.post<AutoSetupResult>('/proxmox/api/servers/auto-setup', data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: nodeKeys.servers });
qc.invalidateQueries({ queryKey: nodeKeys.topology });
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
},
});
}
// ==================== Node network interfaces ====================
export function useNodeNetworks(serverId: number, node: string) {

View File

@@ -304,8 +304,8 @@
"verify_ssl": "Verify SSL",
"connection_ok": "Connection successful",
"connection_failed": "Connection failed",
"auto_setup": "Auto-setup",
"auto_setup_hint": "Connect by password — an API token is created automatically. For a cluster, all nodes are added.",
"auto_create_token": "Create API token automatically",
"auto_create_token_hint": "An API token will be created on the Proxmox server automatically. For a cluster, all nodes will be added.",
"auto_fields_required": "Name, host and password are required",
"cluster_mode": "Cluster",
"standalone": "Standalone"

View File

@@ -304,8 +304,8 @@
"verify_ssl": "Проверять SSL",
"connection_ok": "Соединение успешно",
"connection_failed": "Ошибка соединения",
"auto_setup": "Авто-настройка",
"auto_setup_hint": "Подключение по паролю — API-токен создаётся автоматически. Для кластера добавятся все ноды.",
"auto_create_token": "Создать API-токен автоматически",
"auto_create_token_hint": "API-токен будет создан на сервере Proxmox автоматически. Для кластера добавятся все ноды.",
"auto_fields_required": "Нужны имя, хост и пароль",
"cluster_mode": "Кластер",
"standalone": "Одиночный"

View File

@@ -28,6 +28,10 @@ export default defineConfig({
changeOrigin: true,
ws: true,
},
'/logs': {
target: backendUrl,
changeOrigin: true,
},
},
},
})