Initial commit: Debian Manager — управление Debian-серверами

- SSH/SFTP управление без агентов
- Пакеты (apt), Docker, файлы, systemd, ufw, cron
- Плейбуки, групповые операции, планировщик
- Бэкапы, отчёты, Portainer, Proxmox VE, Gitea Deploy
- Терминал, Telegram-уведомления, тёмная/светлая тема
This commit is contained in:
dinlo
2026-09-05 19:46:45 +08:00
commit f624fee5e0
65 changed files with 6981 additions and 0 deletions
+243
View File
@@ -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)