244 lines
11 KiB
Python
244 lines
11 KiB
Python
|
|
"""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)
|