feat(nodes): управление нодой как в Proxmox — питание, консоль, журнал/задачи/система
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 58s
Deploy to Production / Trigger production update (push) Successful in 7s

Ядро:
- 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:
markmorado
2026-07-09 02:19:07 +05:00
parent 629357a0ee
commit afde2bf569
10 changed files with 822 additions and 9 deletions

View File

@@ -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")

View File

@@ -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]:

View File

@@ -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",
]),

View File

@@ -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(

View File

@@ -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>

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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({