feat(nodes): управление нодой как в Proxmox — питание, консоль, журнал/задачи/система
Ядро:
- Reboot/Shutdown ноды (POST /nodes/{node}/status) под новым правом node:power
с подтверждением в UI; меню действий на карточке ноды + пункт «Консоль (Shell)».
Доп. вкладки в «Администрировании ноды»:
- Журнал (syslog ноды), История задач (tasks + лог задачи),
Система (DNS / /etc/hosts / часовой пояс).
Бэкенд: методы node_power_action/get_node_syslog/get_node_tasks/get_node_task_log/
get_node_task_status/DNS/hosts/time в NodeAdminMixin + эндпоинты в node_admin.py.
RBAC: право node:power (admin + backfill server:manage).
Проверено: py_compile, tsc --noEmit, vite build; live — маршруты в openapi,
403 без токена, node:power засеян роли admin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,210 @@ def _ok_or_400(result: dict):
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Operation failed"))
|
||||
|
||||
|
||||
# ==================== Power: reboot / shutdown ====================
|
||||
|
||||
@router.post("/api/servers/{server_id}/nodes/{node}/status")
|
||||
async def node_power_action(
|
||||
server_id: int,
|
||||
node: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("node:power")),
|
||||
):
|
||||
"""Управление питанием ноды: reboot | shutdown (как в UI Proxmox)."""
|
||||
data = await request.json()
|
||||
command = data.get("command")
|
||||
if command not in ("reboot", "shutdown"):
|
||||
raise HTTPException(status_code=400, detail="command must be reboot or shutdown")
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.node_power_action(node, command)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error {command} node {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== Syslog ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/syslog")
|
||||
async def node_syslog(
|
||||
server_id: int,
|
||||
node: str,
|
||||
limit: int = 500,
|
||||
start: int = 0,
|
||||
service: str = None,
|
||||
since: str = None,
|
||||
until: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("node:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"syslog": await _run_in_executor(
|
||||
lambda: client.get_node_syslog(node, limit, start, service, since, until))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting syslog on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== Task history ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/tasks")
|
||||
async def node_tasks(
|
||||
server_id: int,
|
||||
node: str,
|
||||
limit: int = 100,
|
||||
start: int = 0,
|
||||
errors: bool = False,
|
||||
typefilter: str = None,
|
||||
userfilter: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("node:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"tasks": await _run_in_executor(
|
||||
lambda: client.get_node_tasks(node, limit, start, errors, typefilter, userfilter))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting tasks on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/tasks/{upid}/log")
|
||||
async def node_task_log(
|
||||
server_id: int,
|
||||
node: str,
|
||||
upid: str,
|
||||
limit: int = 500,
|
||||
start: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("node:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={
|
||||
"log": await _run_in_executor(lambda: client.get_node_task_log(node, upid, limit, start)),
|
||||
"status": await _run_in_executor(lambda: client.get_node_task_status(node, upid)),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting task log {upid} on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== System: DNS / hosts / time ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/dns")
|
||||
async def get_node_dns(
|
||||
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={"dns": await _run_in_executor(lambda: client.get_node_dns(node))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting DNS on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/nodes/{node}/dns")
|
||||
async def set_node_dns(
|
||||
server_id: int,
|
||||
node: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("node:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.set_node_dns(
|
||||
node, data.get("search"), data.get("dns1"), data.get("dns2"), data.get("dns3"))))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting DNS on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/hosts")
|
||||
async def get_node_hosts(
|
||||
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={"hosts": await _run_in_executor(lambda: client.get_node_hosts(node))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting hosts on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/nodes/{node}/hosts")
|
||||
async def set_node_hosts(
|
||||
server_id: int,
|
||||
node: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("node:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
hosts_data = data.get("data")
|
||||
if hosts_data is None:
|
||||
raise HTTPException(status_code=400, detail="data is required")
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(
|
||||
lambda: client.set_node_hosts(node, hosts_data, data.get("digest"))))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting hosts on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/time")
|
||||
async def get_node_time(
|
||||
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={"time": await _run_in_executor(lambda: client.get_node_time(node))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting time on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/nodes/{node}/time")
|
||||
async def set_node_timezone(
|
||||
server_id: int,
|
||||
node: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("node:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
timezone = data.get("timezone")
|
||||
if not timezone:
|
||||
raise HTTPException(status_code=400, detail="timezone is required")
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.set_node_timezone(node, timezone)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting timezone on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== Services ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/services")
|
||||
|
||||
@@ -4,10 +4,168 @@ from loguru import logger
|
||||
|
||||
class NodeAdminMixin:
|
||||
"""
|
||||
Администрирование ноды: systemd-сервисы (/nodes/{node}/services)
|
||||
и APT (обновления и репозитории, /nodes/{node}/apt).
|
||||
Администрирование ноды: питание (reboot/shutdown), systemd-сервисы
|
||||
(/nodes/{node}/services), APT (обновления и репозитории, /nodes/{node}/apt),
|
||||
журналы (syslog), история задач и системная конфигурация (DNS/hosts/time).
|
||||
"""
|
||||
|
||||
# ---------- Power (reboot / shutdown) ----------
|
||||
|
||||
def node_power_action(self, node: str, command: str) -> Dict:
|
||||
"""
|
||||
Управление питанием ноды: reboot | shutdown.
|
||||
Проксирует POST /nodes/{node}/status?command=... как в UI Proxmox.
|
||||
"""
|
||||
if command not in ("reboot", "shutdown"):
|
||||
return {"success": False, "error": "command must be reboot or shutdown"}
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.nodes(node).status.post(command=command)
|
||||
return {"success": True, "command": command}
|
||||
except Exception as e:
|
||||
logger.error(f"Error {command} node {node}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ---------- Syslog ----------
|
||||
|
||||
def get_node_syslog(self, node: str, limit: int = 500, start: int = 0,
|
||||
service: str = None, since: str = None, until: str = None) -> List[Dict]:
|
||||
"""Системный журнал ноды: [{n, t}] (номер строки и текст)."""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
params = {"limit": limit, "start": start}
|
||||
if service:
|
||||
params["service"] = service
|
||||
if since:
|
||||
params["since"] = since
|
||||
if until:
|
||||
params["until"] = until
|
||||
try:
|
||||
return list(self.proxmox.nodes(node).syslog.get(**params) or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting syslog for {node}: {e}")
|
||||
return []
|
||||
|
||||
# ---------- Task history ----------
|
||||
|
||||
def get_node_tasks(self, node: str, limit: int = 100, start: int = 0,
|
||||
errors: bool = False, typefilter: str = None,
|
||||
userfilter: str = None) -> List[Dict]:
|
||||
"""История задач ноды: [{upid, type, id, user, status, starttime, endtime}]."""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
params = {"limit": limit, "start": start}
|
||||
if errors:
|
||||
params["errors"] = 1
|
||||
if typefilter:
|
||||
params["typefilter"] = typefilter
|
||||
if userfilter:
|
||||
params["userfilter"] = userfilter
|
||||
try:
|
||||
return list(self.proxmox.nodes(node).tasks.get(**params) or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting tasks for {node}: {e}")
|
||||
return []
|
||||
|
||||
def get_node_task_log(self, node: str, upid: str, limit: int = 500, start: int = 0) -> List[Dict]:
|
||||
"""Лог конкретной задачи: [{n, t}]."""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.nodes(node).tasks(upid).log.get(limit=limit, start=start) or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting task log {upid} on {node}: {e}")
|
||||
return []
|
||||
|
||||
def get_node_task_status(self, node: str, upid: str) -> Dict:
|
||||
"""Статус конкретной задачи: {status, exitstatus, type, user, ...}."""
|
||||
if not self.proxmox:
|
||||
return {}
|
||||
try:
|
||||
return dict(self.proxmox.nodes(node).tasks(upid).status.get() or {})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting task status {upid} on {node}: {e}")
|
||||
return {}
|
||||
|
||||
# ---------- System config: DNS / hosts / time ----------
|
||||
|
||||
def get_node_dns(self, node: str) -> Dict:
|
||||
"""DNS ноды: {search, dns1, dns2, dns3}."""
|
||||
if not self.proxmox:
|
||||
return {}
|
||||
try:
|
||||
return dict(self.proxmox.nodes(node).dns.get() or {})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting DNS for {node}: {e}")
|
||||
return {}
|
||||
|
||||
def set_node_dns(self, node: str, search: str = None, dns1: str = None,
|
||||
dns2: str = None, dns3: str = None) -> Dict:
|
||||
"""Настроить DNS ноды (search обязателен для Proxmox)."""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
params = {}
|
||||
if search is not None:
|
||||
params["search"] = search
|
||||
if dns1 is not None:
|
||||
params["dns1"] = dns1
|
||||
if dns2 is not None:
|
||||
params["dns2"] = dns2
|
||||
if dns3 is not None:
|
||||
params["dns3"] = dns3
|
||||
try:
|
||||
self.proxmox.nodes(node).dns.put(**params)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting DNS on {node}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_node_hosts(self, node: str) -> Dict:
|
||||
"""Содержимое /etc/hosts ноды: {data, digest}."""
|
||||
if not self.proxmox:
|
||||
return {}
|
||||
try:
|
||||
return dict(self.proxmox.nodes(node).hosts.get() or {})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting hosts for {node}: {e}")
|
||||
return {}
|
||||
|
||||
def set_node_hosts(self, node: str, data: str, digest: str = None) -> Dict:
|
||||
"""Записать /etc/hosts ноды (digest — защита от гонок, опционально)."""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
params = {"data": data}
|
||||
if digest:
|
||||
params["digest"] = digest
|
||||
try:
|
||||
self.proxmox.nodes(node).hosts.post(**params)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting hosts on {node}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_node_time(self, node: str) -> Dict:
|
||||
"""Время ноды: {timezone, time, localtime}."""
|
||||
if not self.proxmox:
|
||||
return {}
|
||||
try:
|
||||
return dict(self.proxmox.nodes(node).time.get() or {})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting time for {node}: {e}")
|
||||
return {}
|
||||
|
||||
def set_node_timezone(self, node: str, timezone: str) -> Dict:
|
||||
"""Установить часовой пояс ноды."""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.nodes(node).time.put(timezone=timezone)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting timezone on {node}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ---------- Services ----------
|
||||
|
||||
def get_node_services(self, node: str) -> List[Dict]:
|
||||
|
||||
@@ -64,10 +64,11 @@ NEW_DEFAULT_ROLES = [
|
||||
# Networking (interfaces + SDN)
|
||||
"network:view": True,
|
||||
"network:manage": True,
|
||||
# Node administration (services, APT, upgrade)
|
||||
# Node administration (services, APT, upgrade, power)
|
||||
"node:view": True,
|
||||
"node:manage": True,
|
||||
"node:upgrade": True,
|
||||
"node:power": True,
|
||||
# Resource pools
|
||||
"pool:view": True,
|
||||
"pool:manage": True,
|
||||
@@ -490,7 +491,7 @@ GRANULAR_BACKFILL = [
|
||||
("server:manage", [
|
||||
"firewall:view", "firewall:manage",
|
||||
"network:view", "network:manage",
|
||||
"node:view", "node:manage", "node:upgrade",
|
||||
"node:view", "node:manage", "node:upgrade", "node:power",
|
||||
"storage:view", "storage:manage",
|
||||
"pool:view", "pool:manage",
|
||||
]),
|
||||
|
||||
@@ -690,6 +690,13 @@ def _create_permissions() -> PermissionRegistry:
|
||||
category="Node Administration",
|
||||
requires=["node:view"]
|
||||
))
|
||||
registry.register(Permission(
|
||||
resource="node", action="power",
|
||||
display_name="Power Control Node",
|
||||
description="Reboot or shut down the physical node",
|
||||
category="Node Administration",
|
||||
requires=["node:view"]
|
||||
))
|
||||
|
||||
# Resource pool permissions
|
||||
registry.register(Permission(
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
useAptUpdates, useAptRefresh, useAptRepositories, useSetAptRepository,
|
||||
} from '@/hooks/use-node-admin';
|
||||
import { toast } from 'sonner';
|
||||
import NodeSyslog from './NodeSyslog';
|
||||
import NodeTasks from './NodeTasks';
|
||||
import NodeSystemConfig from './NodeSystemConfig';
|
||||
|
||||
function stateVariant(s?: string): 'default' | 'secondary' | 'destructive' {
|
||||
if (!s) return 'secondary';
|
||||
@@ -29,6 +32,7 @@ export default function NodeAdminManager({ serverId, nodeNames }: { serverId: nu
|
||||
|
||||
const { data: servicesData, refetch, isFetching } = useNodeServices(serverId, node);
|
||||
const serviceAction = useServiceAction(serverId, node);
|
||||
const [tab, setTab] = useState('services');
|
||||
const [aptOpen, setAptOpen] = useState(false);
|
||||
const [reposOpen, setReposOpen] = useState(false);
|
||||
const { data: updatesData } = useAptUpdates(serverId, node, aptOpen);
|
||||
@@ -81,11 +85,14 @@ export default function NodeAdminManager({ serverId, nodeNames }: { serverId: nu
|
||||
{!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>
|
||||
<Tabs value={tab} onValueChange={(v) => { setTab(v); setAptOpen(v === 'updates'); setReposOpen(v === 'repos'); }}>
|
||||
<TabsList className="flex-wrap h-auto">
|
||||
<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="syslog">{t('nodeadm.syslog', 'Журнал')}</TabsTrigger>
|
||||
<TabsTrigger value="tasks">{t('nodeadm.tasks', 'Задачи')}</TabsTrigger>
|
||||
<TabsTrigger value="system">{t('nodeadm.system', 'Система')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Services */}
|
||||
@@ -187,6 +194,21 @@ export default function NodeAdminManager({ serverId, nodeNames }: { serverId: nu
|
||||
</div>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
{/* Syslog */}
|
||||
<TabsContent value="syslog" className="mt-4">
|
||||
{tab === 'syslog' && <NodeSyslog serverId={serverId} node={node} />}
|
||||
</TabsContent>
|
||||
|
||||
{/* Task history */}
|
||||
<TabsContent value="tasks" className="mt-4">
|
||||
{tab === 'tasks' && <NodeTasks serverId={serverId} node={node} />}
|
||||
</TabsContent>
|
||||
|
||||
{/* System config: DNS / hosts / time */}
|
||||
<TabsContent value="system" className="mt-4">
|
||||
{tab === 'system' && <NodeSystemConfig serverId={serverId} node={node} />}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,14 +1,71 @@
|
||||
import { useParams, useSearchParams, Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, Cpu, MemoryStick, HardDrive, Clock } from 'lucide-react';
|
||||
import { ArrowLeft, Cpu, MemoryStick, HardDrive, Clock, MoreVertical, TerminalSquare, RotateCw, Power } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useServer, useNodes, useClusterInfo } from '@/hooks/use-nodes';
|
||||
import { useVirtualMachines } from '@/hooks/use-instances';
|
||||
import { useNodePower } from '@/hooks/use-node-admin';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { StatusDot } from '@/components/shared/status-dot';
|
||||
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
|
||||
|
||||
/** Меню действий питания/консоли для одной ноды (Shell / Reboot / Shutdown). */
|
||||
function NodeActionsMenu({ serverId, node }: { serverId: number; node: string }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const power = useNodePower(serverId, node);
|
||||
const canPower = !!useAuthStore((s) => s.user?.permissions?.['node:power']);
|
||||
|
||||
const doPower = async (command: 'reboot' | 'shutdown') => {
|
||||
const ok = await confirm(
|
||||
command === 'reboot'
|
||||
? t('nodes.confirm_reboot', 'Перезагрузить ноду «{{node}}»? Все ВМ и контейнеры на ней будут остановлены.', { node })
|
||||
: t('nodes.confirm_shutdown', 'Выключить ноду «{{node}}»? Доступ к ней будет потерян до ручного включения.', { node }),
|
||||
{ title: command === 'reboot' ? t('nodes.reboot', 'Перезагрузка') : t('nodes.shutdown', 'Выключение'), variant: 'destructive' },
|
||||
);
|
||||
if (!ok) return;
|
||||
power.mutate(command, {
|
||||
onSuccess: () => toast.success(
|
||||
command === 'reboot'
|
||||
? t('nodes.reboot_started', 'Перезагрузка ноды запущена')
|
||||
: t('nodes.shutdown_started', 'Выключение ноды запущено')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" className="h-7 w-7" />}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem
|
||||
render={<Link to={`/console/node/${serverId}/${node}?cmd=login`} target="_blank" rel="noopener noreferrer" />}>
|
||||
<TerminalSquare className="mr-2 h-4 w-4" />{t('nodes.shell', 'Консоль (Shell)')}
|
||||
</DropdownMenuItem>
|
||||
{canPower && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => doPower('reboot')}>
|
||||
<RotateCw className="mr-2 h-4 w-4" />{t('nodes.reboot', 'Перезагрузка')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => doPower('shutdown')}>
|
||||
<Power className="mr-2 h-4 w-4" />{t('nodes.shutdown', 'Выключение')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
import NodeNetworks from './NodeNetworks';
|
||||
import HAResources from './HAResources';
|
||||
import SDNManager from './SDNManager';
|
||||
@@ -89,8 +146,11 @@ export default function NodeDetailPage() {
|
||||
<Card key={node.node}>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{node.node}</span>
|
||||
<StatusDot status={node.status} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{node.node}</span>
|
||||
<StatusDot status={node.status} />
|
||||
</div>
|
||||
<NodeActionsMenu serverId={sid} node={node.node} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1"><Cpu className="h-3 w-3" />{formatPercent(node.cpu || 0)} CPU</div>
|
||||
|
||||
34
frontend/src/features/nodes/NodeSyslog.tsx
Normal file
34
frontend/src/features/nodes/NodeSyslog.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNodeSyslog } from '@/hooks/use-node-admin';
|
||||
|
||||
/** Просмотр системного журнала ноды (аналог Syslog в Proxmox). */
|
||||
export default function NodeSyslog({ serverId, node }: { serverId: number; node: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, refetch, isFetching } = useNodeSyslog(serverId, node);
|
||||
const lines = data?.syslog ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('nodeadm.syslog_hint', 'Последние записи системного журнала ноды')}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={`mr-1 h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
{t('common.refresh', 'Обновить')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-[420px] overflow-auto rounded-lg border bg-[#09090B] p-3 font-mono text-xs leading-relaxed text-[#e0e0e0]">
|
||||
{lines.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
lines.map((l) => (
|
||||
<div key={l.n} className="whitespace-pre-wrap break-all">{l.t}</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/features/nodes/NodeSystemConfig.tsx
Normal file
107
frontend/src/features/nodes/NodeSystemConfig.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Save, Globe, Clock, FileText } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
useNodeDns, useSetNodeDns, useNodeHosts, useSetNodeHosts, useNodeTime, useSetNodeTimezone,
|
||||
} from '@/hooks/use-node-admin';
|
||||
|
||||
/** Системная конфигурация ноды: DNS, /etc/hosts, часовой пояс. */
|
||||
export default function NodeSystemConfig({ serverId, node }: { serverId: number; node: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// --- DNS ---
|
||||
const { data: dnsData } = useNodeDns(serverId, node);
|
||||
const setDns = useSetNodeDns(serverId, node);
|
||||
const [dns, setDnsForm] = useState({ search: '', dns1: '', dns2: '', dns3: '' });
|
||||
useEffect(() => {
|
||||
if (dnsData?.dns) {
|
||||
setDnsForm({
|
||||
search: dnsData.dns.search ?? '',
|
||||
dns1: dnsData.dns.dns1 ?? '',
|
||||
dns2: dnsData.dns.dns2 ?? '',
|
||||
dns3: dnsData.dns.dns3 ?? '',
|
||||
});
|
||||
}
|
||||
}, [dnsData]);
|
||||
const saveDns = () => setDns.mutate(dns, {
|
||||
onSuccess: () => toast.success(t('nodeadm.dns_saved', 'DNS сохранён')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
// --- Time ---
|
||||
const { data: timeData } = useNodeTime(serverId, node);
|
||||
const setTz = useSetNodeTimezone(serverId, node);
|
||||
const [tz, setTz2] = useState('');
|
||||
useEffect(() => { if (timeData?.time?.timezone) setTz2(timeData.time.timezone); }, [timeData]);
|
||||
const saveTz = () => setTz.mutate(tz, {
|
||||
onSuccess: () => toast.success(t('nodeadm.tz_saved', 'Часовой пояс сохранён')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
// --- Hosts ---
|
||||
const { data: hostsData } = useNodeHosts(serverId, node);
|
||||
const setHosts = useSetNodeHosts(serverId, node);
|
||||
const [hosts, setHostsForm] = useState('');
|
||||
useEffect(() => { if (hostsData?.hosts?.data != null) setHostsForm(hostsData.hosts.data); }, [hostsData]);
|
||||
const saveHosts = () => setHosts.mutate({ data: hosts, digest: hostsData?.hosts?.digest }, {
|
||||
onSuccess: () => toast.success(t('nodeadm.hosts_saved', '/etc/hosts сохранён')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const nodeTime = timeData?.time?.localtime ? new Date(timeData.time.localtime * 1000).toLocaleString() : '—';
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* DNS */}
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold"><Globe className="h-4 w-4" />DNS</h3>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{t('nodeadm.dns_search', 'Search domain')}</Label>
|
||||
<Input value={dns.search} onChange={(e) => setDnsForm({ ...dns, search: e.target.value })} className="h-8" />
|
||||
<Label className="text-xs">DNS 1</Label>
|
||||
<Input value={dns.dns1} onChange={(e) => setDnsForm({ ...dns, dns1: e.target.value })} className="h-8 font-mono" />
|
||||
<Label className="text-xs">DNS 2</Label>
|
||||
<Input value={dns.dns2} onChange={(e) => setDnsForm({ ...dns, dns2: e.target.value })} className="h-8 font-mono" />
|
||||
<Label className="text-xs">DNS 3</Label>
|
||||
<Input value={dns.dns3} onChange={(e) => setDnsForm({ ...dns, dns3: e.target.value })} className="h-8 font-mono" />
|
||||
</div>
|
||||
<Button size="sm" onClick={saveDns} disabled={setDns.isPending}>
|
||||
<Save className="mr-1 h-4 w-4" />{t('common.save', 'Сохранить')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold"><Clock className="h-4 w-4" />{t('nodeadm.time', 'Время')}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('nodeadm.node_time', 'Локальное время ноды')}: <span className="font-mono">{nodeTime}</span>
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{t('nodeadm.timezone', 'Часовой пояс')}</Label>
|
||||
<Input value={tz} onChange={(e) => setTz2(e.target.value)} className="h-8 font-mono" placeholder="Europe/Moscow" />
|
||||
</div>
|
||||
<Button size="sm" onClick={saveTz} disabled={setTz.isPending}>
|
||||
<Save className="mr-1 h-4 w-4" />{t('common.save', 'Сохранить')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Hosts */}
|
||||
<div className="space-y-3 rounded-lg border p-4 lg:col-span-2">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold"><FileText className="h-4 w-4" />/etc/hosts</h3>
|
||||
<textarea
|
||||
value={hosts}
|
||||
onChange={(e) => setHostsForm(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="h-56 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"
|
||||
/>
|
||||
<Button size="sm" onClick={saveHosts} disabled={setHosts.isPending}>
|
||||
<Save className="mr-1 h-4 w-4" />{t('common.save', 'Сохранить')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
frontend/src/features/nodes/NodeTasks.tsx
Normal file
94
frontend/src/features/nodes/NodeTasks.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useNodeTasks, useNodeTaskLog, type NodeTask } from '@/hooks/use-node-admin';
|
||||
|
||||
function fmtTime(ts?: number): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function statusVariant(status?: string): 'default' | 'secondary' | 'destructive' {
|
||||
if (!status) return 'secondary'; // running
|
||||
if (status === 'OK') return 'default';
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
/** История задач ноды (аналог Task History в Proxmox) + просмотр лога задачи. */
|
||||
export default function NodeTasks({ serverId, node }: { serverId: number; node: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, refetch, isFetching } = useNodeTasks(serverId, node);
|
||||
const [upid, setUpid] = useState<string | null>(null);
|
||||
const { data: logData } = useNodeTaskLog(serverId, node, upid);
|
||||
|
||||
const tasks = data?.tasks ?? [];
|
||||
const logLines = logData?.log ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('nodeadm.tasks_hint', 'Задачи Proxmox, выполненные на ноде')}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={`mr-1 h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
{t('common.refresh', 'Обновить')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||
) : (
|
||||
<div className="max-h-[420px] overflow-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead>{t('nodeadm.task_start', 'Начало')}</TableHead>
|
||||
<TableHead>{t('nodeadm.task_type', 'Тип')}</TableHead>
|
||||
<TableHead>{t('nodeadm.task_id', 'Объект')}</TableHead>
|
||||
<TableHead>{t('nodeadm.task_user', 'Пользователь')}</TableHead>
|
||||
<TableHead>{t('common.status', 'Статус')}</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map((task: NodeTask) => (
|
||||
<TableRow key={task.upid} className="cursor-pointer" onClick={() => setUpid(task.upid)}>
|
||||
<TableCell className="text-xs whitespace-nowrap">{fmtTime(task.starttime)}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{task.type || '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{task.id || '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{task.user || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(task.status)} className="text-2xs">
|
||||
{task.status ?? t('nodeadm.task_running', 'выполняется')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={!!upid} onOpenChange={(open) => { if (!open) setUpid(null); }}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono text-xs break-all">{upid}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="h-[400px] overflow-auto rounded-lg border bg-[#09090B] p-3 font-mono text-xs leading-relaxed text-[#e0e0e0]">
|
||||
{logLines.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||
) : (
|
||||
logLines.map((l) => (
|
||||
<div key={l.n} className="whitespace-pre-wrap break-all">{l.t}</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,14 +40,140 @@ export interface AptRepositories {
|
||||
errors?: unknown[];
|
||||
}
|
||||
|
||||
export interface NodeTask {
|
||||
upid: string;
|
||||
type?: string;
|
||||
id?: string;
|
||||
user?: string;
|
||||
status?: string; // stopped | running | ...
|
||||
starttime?: number;
|
||||
endtime?: number;
|
||||
pid?: number;
|
||||
node?: string;
|
||||
}
|
||||
|
||||
export interface LogLine {
|
||||
n: number;
|
||||
t: string;
|
||||
}
|
||||
|
||||
export interface NodeDns {
|
||||
search?: string;
|
||||
dns1?: string;
|
||||
dns2?: string;
|
||||
dns3?: string;
|
||||
}
|
||||
|
||||
export interface NodeHosts {
|
||||
data?: string;
|
||||
digest?: string;
|
||||
}
|
||||
|
||||
export interface NodeTime {
|
||||
timezone?: string;
|
||||
time?: number;
|
||||
localtime?: 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,
|
||||
repos: (s: number, n: string) => ['apt-repos', s, n] as const,
|
||||
syslog: (s: number, n: string) => ['node-syslog', s, n] as const,
|
||||
tasks: (s: number, n: string) => ['node-tasks', s, n] as const,
|
||||
taskLog: (s: number, n: string, upid: string) => ['node-task-log', s, n, upid] as const,
|
||||
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,
|
||||
};
|
||||
|
||||
const base = (s: number, n: string) => `/proxmox/api/servers/${s}/nodes/${n}`;
|
||||
|
||||
// ---------- Power (reboot / shutdown) ----------
|
||||
export function useNodePower(serverId: number, node: string) {
|
||||
return useMutation({
|
||||
mutationFn: (command: 'reboot' | 'shutdown') =>
|
||||
apiClient.post(`${base(serverId, node)}/status`, { command }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Syslog ----------
|
||||
export function useNodeSyslog(serverId: number, node: string, enabled = true, limit = 500) {
|
||||
return useQuery({
|
||||
queryKey: nodeAdminKeys.syslog(serverId, node),
|
||||
queryFn: () => apiClient.get<{ syslog: LogLine[] }>(`${base(serverId, node)}/syslog?limit=${limit}`),
|
||||
enabled: serverId > 0 && !!node && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Task history ----------
|
||||
export function useNodeTasks(serverId: number, node: string, enabled = true, limit = 100) {
|
||||
return useQuery({
|
||||
queryKey: nodeAdminKeys.tasks(serverId, node),
|
||||
queryFn: () => apiClient.get<{ tasks: NodeTask[] }>(`${base(serverId, node)}/tasks?limit=${limit}`),
|
||||
enabled: serverId > 0 && !!node && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNodeTaskLog(serverId: number, node: string, upid: string | null) {
|
||||
return useQuery({
|
||||
queryKey: nodeAdminKeys.taskLog(serverId, node, upid ?? ''),
|
||||
queryFn: () => apiClient.get<{ log: LogLine[]; status: Record<string, unknown> }>(
|
||||
`${base(serverId, node)}/tasks/${encodeURIComponent(upid ?? '')}/log`),
|
||||
enabled: serverId > 0 && !!node && !!upid,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- System: DNS / hosts / time ----------
|
||||
export function useNodeDns(serverId: number, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: nodeAdminKeys.dns(serverId, node),
|
||||
queryFn: () => apiClient.get<{ dns: NodeDns }>(`${base(serverId, node)}/dns`),
|
||||
enabled: serverId > 0 && !!node && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetNodeDns(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: NodeDns) => apiClient.put(`${base(serverId, node)}/dns`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: nodeAdminKeys.dns(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useNodeHosts(serverId: number, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: nodeAdminKeys.hosts(serverId, node),
|
||||
queryFn: () => apiClient.get<{ hosts: NodeHosts }>(`${base(serverId, node)}/hosts`),
|
||||
enabled: serverId > 0 && !!node && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetNodeHosts(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { data: string; digest?: string }) =>
|
||||
apiClient.post(`${base(serverId, node)}/hosts`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: nodeAdminKeys.hosts(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useNodeTime(serverId: number, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: nodeAdminKeys.time(serverId, node),
|
||||
queryFn: () => apiClient.get<{ time: NodeTime }>(`${base(serverId, node)}/time`),
|
||||
enabled: serverId > 0 && !!node && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetNodeTimezone(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (timezone: string) => apiClient.put(`${base(serverId, node)}/time`, { timezone }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: nodeAdminKeys.time(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Services ----------
|
||||
export function useNodeServices(serverId: number, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
|
||||
Reference in New Issue
Block a user