Initial commit: Debian Manager — управление Debian-серверами
- SSH/SFTP управление без агентов - Пакеты (apt), Docker, файлы, systemd, ufw, cron - Плейбуки, групповые операции, планировщик - Бэкапы, отчёты, Portainer, Proxmox VE, Gitea Deploy - Терминал, Telegram-уведомления, тёмная/светлая тема
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user