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