Files
debian-manager/app/ui/pages/proxmox_page.py
T
dinlo f624fee5e0 Initial commit: Debian Manager — управление Debian-серверами
- SSH/SFTP управление без агентов
- Пакеты (apt), Docker, файлы, systemd, ufw, cron
- Плейбуки, групповые операции, планировщик
- Бэкапы, отчёты, Portainer, Proxmox VE, Gitea Deploy
- Терминал, Telegram-уведомления, тёмная/светлая тема
2026-09-05 19:46:45 +08:00

244 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Proxmox VE: ноды, VM/LXC, управление питанием, добавление в серверы."""
import flet as ft
from app.core import proxmox as pve_core
from app.core import servers as servers_core
from app.ui.components import confirm_dialog, snack
class ProxmoxPage(ft.Column):
def __init__(self, page: ft.Page, go):
super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12)
self._page = page
self.go = go
cfg = pve_core.get_config()
self.host_tf = ft.TextField(
label="Хост (IP или FQDN)", value=cfg["host"],
width=280, hint_text="192.168.1.100 — без http://",
)
self.user_tf = ft.TextField(label="Пользователь", value=cfg["user"], width=200)
self.token_name_tf = ft.TextField(label="Token ID", value=cfg["token_name"],
width=200, hint_text="dm-manager")
self.token_val_tf = ft.TextField(label="Token Secret", value=cfg["token_value"],
password=True, width=350)
self.ssl_cb = ft.Checkbox(label="Проверять SSL", value=cfg["verify_ssl"])
self.conn_status = ft.Text("", size=12)
# подсказка про права токена
self.perm_hint = ft.Container(
visible=False,
content=ft.Column([
ft.Text("403 Forbidden — у API-токена нет прав.", weight=ft.FontWeight.BOLD,
color=ft.Colors.ORANGE_300, size=13),
ft.Text(
"В Proxmox: Datacenter → Permissions → API Tokens → ваш токен → "
"убедитесь, что у токена есть роль PVEAuditor (или PVEAdmin) на пути /.\n"
"Если используете «Privilege Separation» — добавьте права отдельно: "
"Permissions → Add → API Token Permission.",
size=12, color=ft.Colors.GREY_400),
], spacing=4),
padding=10, border=ft.Border.all(1, ft.Colors.ORANGE_800), border_radius=6,
)
self.nodes_col = ft.Column(spacing=12)
self.controls = [
ft.Text("Proxmox VE", size=28, weight=ft.FontWeight.BOLD),
ft.Card(content=ft.Container(padding=14, content=ft.Column([
ft.Text("Подключение", weight=ft.FontWeight.BOLD),
ft.Row([self.host_tf, self.user_tf]),
ft.Row([self.token_name_tf, self.token_val_tf]),
ft.Row([
self.ssl_cb,
ft.FilledButton("Сохранить и проверить", icon=ft.Icons.CHECK,
on_click=self._save_cfg),
ft.OutlinedButton("Обновить список", icon=ft.Icons.REFRESH,
on_click=lambda e: self._reload()),
]),
self.conn_status,
self.perm_hint,
], spacing=8))),
self.nodes_col,
]
def _save_cfg(self, e):
pve_core.save_config(self.host_tf.value, self.user_tf.value,
self.token_name_tf.value, self.token_val_tf.value,
self.ssl_cb.value)
async def _t():
ok, msg = pve_core.test_connection()
self.conn_status.value = ("✅ " if ok else "❌ ") + msg
self.conn_status.color = ft.Colors.GREEN_300 if ok else ft.Colors.RED_300
self._page.update()
if ok:
self._reload()
self._page.run_task(_t)
def _reload(self):
self.nodes_col.controls = [ft.ProgressBar()]
self._page.update()
self._page.run_task(self._load_nodes)
async def _load_nodes(self):
try:
nodes = pve_core.nodes()
except Exception as e:
err = str(e)
self.nodes_col.controls = [ft.Text(f"❌ {err}", color=ft.Colors.RED_300)]
# показать подсказку при 403
if "403" in err or "Permission check failed" in err:
self.perm_hint.visible = True
self._page.update()
return
self.nodes_col.controls = []
for node in nodes:
name = node["node"]
self.nodes_col.controls.append(self._node_card(name, node))
self._page.update()
# загрузить VM каждой ноды
for i, node in enumerate(nodes):
self._page.run_task(self._fill_node, node["node"], i)
def _node_card(self, name, node):
return ft.Card(content=ft.Container(
padding=14,
content=ft.Column([
ft.Row([
ft.Icon(ft.Icons.COMPUTER, size=20),
ft.Text(name, size=18, weight=ft.FontWeight.BOLD),
ft.Text(f"uptime: {node.get('uptime', 0)//3600}ч", size=11,
color=ft.Colors.GREY_500),
]),
ft.ProgressBar(height=3),
]),
))
async def _fill_node(self, node_name, card_idx):
try:
st = pve_core.node_status(node_name)
vms = pve_core.vms(node_name)
except Exception as e:
self.nodes_col.controls[card_idx].content.content.controls[1] = \
ft.Text(f"ошибка: {e}", size=12, color=ft.Colors.RED_300)
self._page.update()
return
cpu_pct = (st.get("cpu", 0) or 0) * 100
mem_used = st.get("memory", {}).get("used", 0) / 1073741824
mem_total = st.get("memory", {}).get("total", 1) / 1073741824
def bar(label, pct, color):
return ft.Row([
ft.Text(label, size=11, width=40),
ft.ProgressBar(value=min(pct / 100, 1.0), height=6, expand=True,
color=color, bgcolor=ft.Colors.GREY_800),
ft.Text(f"{pct:.0f}%", size=11, width=38, text_align=ft.TextAlign.RIGHT),
])
rows = [
bar("CPU", cpu_pct, ft.Colors.BLUE_400),
bar("RAM", mem_used * 100 / mem_total, ft.Colors.PURPLE_300),
ft.Text(f"RAM {mem_used:.1f}/{mem_total:.1f} ГБ", size=11,
color=ft.Colors.GREY_500),
ft.Divider(),
ft.Text(f"Виртуальные машины ({len(vms)})", weight=ft.FontWeight.BOLD),
]
for vm in vms:
rows.append(self._vm_row(node_name, vm))
self.nodes_col.controls[card_idx].content.content.controls[1] = \
ft.Column(rows, spacing=4)
self._page.update()
def _vm_row(self, node, vm):
vmid = vm["vmid"]
vm_type = vm["type"]
name = vm.get("name", f"vm-{vmid}")
running = vm.get("status") == "running"
cpu = (vm.get("cpu", 0) or 0) * 100
mem = (vm.get("mem", 0) or 0) / 1073741824
maxmem = (vm.get("maxmem", 1) or 1) / 1073741824
icon = ft.Icons.MONITOR if vm_type == "qemu" else ft.Icons.INVENTORY
status_color = ft.Colors.GREEN_400 if running else ft.Colors.GREY_500
return ft.Container(
border=ft.Border.all(1, ft.Colors.GREY_800), border_radius=6, padding=8,
content=ft.Column([
ft.Row([
ft.Icon(icon, size=16, color=status_color),
ft.Text(f"{vmid} {name}", weight=ft.FontWeight.BOLD, size=13),
ft.Text(f"{'VM' if vm_type == 'qemu' else 'LXC'} • "
f"{vm.get('status', '?')}", size=11, color=status_color),
ft.Container(expand=True),
ft.IconButton(ft.Icons.PLAY_ARROW, icon_size=16, tooltip="start",
visible=not running,
on_click=lambda e: self._vm_act(node, vmid, vm_type, "start")),
ft.IconButton(ft.Icons.RESTART_ALT, icon_size=16, tooltip="reboot",
visible=running,
on_click=lambda e, n=name: self._vm_confirm(node, vmid, vm_type, "reboot", n)),
ft.IconButton(ft.Icons.STOP, icon_size=16, tooltip="shutdown",
visible=running,
on_click=lambda e, n=name: self._vm_confirm(node, vmid, vm_type, "shutdown", n)),
ft.IconButton(ft.Icons.POWER_SETTINGS_NEW, icon_size=16, tooltip="stop (жёстко)",
visible=running,
on_click=lambda e, n=name: self._vm_confirm(node, vmid, vm_type, "stop", n)),
ft.IconButton(ft.Icons.ADD_CIRCLE_OUTLINE, icon_size=16,
tooltip="Добавить как сервер",
on_click=lambda e: self._add_as_server(node, vmid, vm_type, name)),
]),
ft.Text(f"CPU {cpu:.1f}% • RAM {mem:.1f}/{maxmem:.1f} ГБ",
size=10, color=ft.Colors.GREY_500),
], spacing=2),
)
def _vm_act(self, node, vmid, vm_type, action):
async def _do():
try:
pve_core.vm_action(node, vmid, vm_type, action)
snack(self._page, f"{action} отправлен")
self._reload()
except Exception as e:
snack(self._page, f"ошибка: {e}", error=True)
self._page.run_task(_do)
def _vm_confirm(self, node, vmid, vm_type, action, name):
def do():
self._vm_act(node, vmid, vm_type, action)
confirm_dialog(self._page, f"{action} {name}?",
f"VMID {vmid} на ноде {node}", do)
def _add_as_server(self, node, vmid, vm_type, name):
async def _do():
ip = pve_core.get_vm_ip(node, vmid, vm_type)
if not ip:
snack(self._page,
"Не удалось определить IP (установите qemu-guest-agent в VM)",
error=True)
return
# диалог с предзаполненным IP
user_tf = ft.TextField(label="Пользователь SSH", value="root", width=250)
pass_tf = ft.TextField(label="Пароль", password=True, width=250)
port_tf = ft.TextField(label="Порт SSH", value="22", width=100)
def save(e):
servers_core.add_server(
name=f"{name} (pve:{node})", host=ip,
port=int(port_tf.value or 22), username=user_tf.value,
password=pass_tf.value or None, tags="proxmox",
)
self._page.pop_dialog()
snack(self._page, f"Сервер {name} ({ip}) добавлен")
dlg = ft.AlertDialog(
modal=True, title=ft.Text(f"Добавить {name} ({ip})"),
content=ft.Column([user_tf, pass_tf, port_tf], tight=True),
actions=[
ft.TextButton("Отмена", on_click=lambda e: self._page.pop_dialog()),
ft.FilledButton("Добавить", on_click=save),
],
)
self._page.show_dialog(dlg)
self._page.run_task(_do)