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,108 @@
|
||||
"""Агент-режим: установка/удаление агента, приём метрик."""
|
||||
import flet as ft
|
||||
|
||||
from app.core import agent as ag
|
||||
from app.core import servers as servers_core
|
||||
from app.ui.components import confirm_dialog, snack
|
||||
|
||||
|
||||
class AgentPage(ft.Column):
|
||||
def __init__(self, page: ft.Page, go):
|
||||
super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12)
|
||||
self._page = page
|
||||
self.cards_col = ft.Column(spacing=10)
|
||||
self.server_status = ft.Text("…", size=13)
|
||||
|
||||
self.controls = [
|
||||
ft.Text("Агент-режим", size=28, weight=ft.FontWeight.BOLD),
|
||||
ft.Text("Агент на сервере шлёт метрики каждые 5 сек по HTTP — "
|
||||
"real-time графики без SSH-поллинга. Приложение слушает порт "
|
||||
f"{ag.get_agent_port()}.",
|
||||
size=13, color=ft.Colors.GREY_500),
|
||||
ft.Row([
|
||||
ft.FilledButton("Запустить приёмник", icon=ft.Icons.PLAY_ARROW,
|
||||
on_click=self._start_server),
|
||||
self.server_status,
|
||||
]),
|
||||
ft.Divider(),
|
||||
self.cards_col,
|
||||
]
|
||||
self._reload()
|
||||
self._update_server_status()
|
||||
|
||||
def _update_server_status(self):
|
||||
self.server_status.value = (
|
||||
f"✅ приёмник запущен на порту {ag.get_agent_port()}"
|
||||
if ag.is_running() else "⚪ приёмник не запущен"
|
||||
)
|
||||
self._page.update()
|
||||
|
||||
def _start_server(self, e):
|
||||
async def _s():
|
||||
await ag.start()
|
||||
self._update_server_status()
|
||||
self._page.run_task(_s)
|
||||
|
||||
def _reload(self):
|
||||
self.cards_col.controls = []
|
||||
for srv in servers_core.list_servers():
|
||||
self.cards_col.controls.append(self._card(srv))
|
||||
self._page.update()
|
||||
for srv, card in zip(servers_core.list_servers(), self.cards_col.controls):
|
||||
self._page.run_task(self._fill, srv, card)
|
||||
|
||||
def _card(self, srv):
|
||||
return ft.Card(content=ft.Container(
|
||||
padding=14, width=560,
|
||||
content=ft.Column([
|
||||
ft.Text(srv.name, weight=ft.FontWeight.BOLD, size=16),
|
||||
ft.ProgressBar(height=3),
|
||||
]),
|
||||
))
|
||||
|
||||
async def _fill(self, srv, card):
|
||||
status = await ag.agent_status(srv)
|
||||
metrics = ag.agent_metrics.get(srv.id)
|
||||
running = status == "active"
|
||||
|
||||
rows = [ft.Text(f"статус: {status}", size=12,
|
||||
color=ft.Colors.GREEN_300 if running else ft.Colors.GREY_500)]
|
||||
if metrics:
|
||||
import time
|
||||
age = time.time() - metrics.get("ts", 0)
|
||||
rows.append(ft.Text(
|
||||
f"CPU {metrics.get('cpu')}% • RAM {metrics.get('mem_pct')}% • "
|
||||
f"Диск {metrics.get('disk')}% • load {metrics.get('load')} • "
|
||||
f"обновлено {age:.0f} сек назад",
|
||||
size=11, color=ft.Colors.BLUE_300))
|
||||
btns = []
|
||||
if not running:
|
||||
btns.append(ft.FilledButton("Установить агента", icon=ft.Icons.DOWNLOAD,
|
||||
on_click=lambda e, s=srv: self._install(s)))
|
||||
else:
|
||||
btns.append(ft.OutlinedButton("Удалить агента",
|
||||
on_click=lambda e, s=srv: self._uninstall(s)))
|
||||
rows.append(ft.Row(btns))
|
||||
card.content.content.controls[1] = ft.Column(rows, spacing=4)
|
||||
self._page.update()
|
||||
|
||||
def _install(self, srv):
|
||||
from app.core.tasks import tasks
|
||||
import socket
|
||||
app_host = socket.gethostbyname(socket.gethostname())
|
||||
async def job(log):
|
||||
log(f"адрес приложения для агента: {app_host}:{ag.get_agent_port()}")
|
||||
ok = await ag.install_agent(srv, app_host, log)
|
||||
log("готово" if ok else "ошибка установки")
|
||||
tasks.start("agent", "установка агента", srv.id, job,
|
||||
on_done=lambda r: self._reload())
|
||||
snack(self._page, f"Установка агента на {srv.name}…")
|
||||
|
||||
def _uninstall(self, srv):
|
||||
def do():
|
||||
async def _d():
|
||||
await ag.uninstall_agent(srv)
|
||||
self._reload()
|
||||
self._page.run_task(_d)
|
||||
confirm_dialog(self._page, "Удалить агента?",
|
||||
f"dm-agent будет остановлен и удалён с «{srv.name}».", do)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Резервное копирование: запуск, история, скачивание."""
|
||||
import flet as ft
|
||||
|
||||
from app.core import backup as bk
|
||||
from app.core import servers as servers_core
|
||||
from app.core.tasks import tasks
|
||||
from app.ui.components import append_log, log_view, snack
|
||||
|
||||
|
||||
class BackupsPage(ft.Column):
|
||||
def __init__(self, page: ft.Page, go):
|
||||
super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12)
|
||||
self._page = page
|
||||
|
||||
self.srv_dd = ft.Dropdown(label="Сервер", width=250, options=[
|
||||
ft.dropdown.Option(str(s.id), s.name) for s in servers_core.list_servers()
|
||||
])
|
||||
self.paths_tf = ft.TextField(
|
||||
label="Пути (через пробел)", value="/etc", width=400,
|
||||
hint_text="/etc /home/user /var/www",
|
||||
)
|
||||
self.download_cb = ft.Checkbox(label="Скачать на этот ПК", value=True)
|
||||
self.log = log_view()
|
||||
self.log_container = ft.Container(
|
||||
height=180, border=ft.Border.all(1, ft.Colors.GREY_800),
|
||||
border_radius=8, padding=8, content=self.log, visible=False)
|
||||
self.table = ft.DataTable(columns=[
|
||||
ft.DataColumn(ft.Text("Дата")),
|
||||
ft.DataColumn(ft.Text("Сервер")),
|
||||
ft.DataColumn(ft.Text("Пути")),
|
||||
ft.DataColumn(ft.Text("Размер")),
|
||||
ft.DataColumn(ft.Text("Статус")),
|
||||
ft.DataColumn(ft.Text("Файл")),
|
||||
], rows=[])
|
||||
|
||||
count, size = bk.stats()
|
||||
self.stats_text = ft.Text(f"Всего бэкапов: {count}, суммарно {size/1048576:.1f} МБ",
|
||||
size=13, color=ft.Colors.GREY_500)
|
||||
|
||||
self.controls = [
|
||||
ft.Text("Резервное копирование", size=28, weight=ft.FontWeight.BOLD),
|
||||
ft.Row([self.srv_dd, self.paths_tf, self.download_cb,
|
||||
ft.FilledButton("Создать бэкап", icon=ft.Icons.BACKUP,
|
||||
on_click=self._run)]),
|
||||
self.stats_text,
|
||||
self.log_container,
|
||||
ft.Text("История", weight=ft.FontWeight.BOLD, size=16),
|
||||
ft.Card(content=ft.Container(padding=10, content=self.table)),
|
||||
]
|
||||
self._reload()
|
||||
|
||||
def _reload(self):
|
||||
servers = {s.id: s.name for s in servers_core.list_servers()}
|
||||
self.table.rows = []
|
||||
for r in bk.list_backups():
|
||||
color = ft.Colors.GREEN_300 if r.status == "success" else \
|
||||
ft.Colors.RED_300 if r.status == "error" else ft.Colors.GREY_500
|
||||
self.table.rows.append(ft.DataRow(cells=[
|
||||
ft.DataCell(ft.Text(f"{r.created_at:%d.%m %H:%M}", size=11)),
|
||||
ft.DataCell(ft.Text(servers.get(r.server_id, "?"), size=12)),
|
||||
ft.DataCell(ft.Text(r.paths, size=11)),
|
||||
ft.DataCell(ft.Text(f"{r.size/1048576:.1f} МБ" if r.size else "—", size=11)),
|
||||
ft.DataCell(ft.Text(r.status, size=11, color=color)),
|
||||
ft.DataCell(ft.Text(r.file or "—", size=10, color=ft.Colors.GREY_500)),
|
||||
]))
|
||||
self._page.update()
|
||||
|
||||
def _run(self, e):
|
||||
if not self.srv_dd.value:
|
||||
snack(self._page, "Выберите сервер", error=True)
|
||||
return
|
||||
srv = servers_core.get_server(int(self.srv_dd.value))
|
||||
paths = self.paths_tf.value.strip()
|
||||
if not paths:
|
||||
snack(self._page, "Укажите пути", error=True)
|
||||
return
|
||||
self.log_container.visible = True
|
||||
self._page.update()
|
||||
|
||||
async def job(log):
|
||||
rec = await bk.run_backup(srv, paths, self.download_cb.value, log)
|
||||
log(f"=== {rec.status} ===" if rec else "=== error ===")
|
||||
|
||||
tasks.start("backup", f"бэкап {paths}", srv.id, job,
|
||||
on_log=lambda l: (append_log(self.log, l), self._page.update()),
|
||||
on_done=lambda r: self._reload())
|
||||
snack(self._page, "Бэкап запущен")
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Дашборд: карточки серверов с health-check и метриками."""
|
||||
import asyncio
|
||||
|
||||
import flet as ft
|
||||
|
||||
from app.core import servers as servers_core
|
||||
from app.core import monitor
|
||||
from app.core.ssh import SSHClient, check_server
|
||||
from app.ui.components import fmt_mb, snack
|
||||
|
||||
|
||||
class DashboardPage(ft.Column):
|
||||
def __init__(self, page: ft.Page, go, group_filter: str = "Все"):
|
||||
super().__init__(expand=True, scroll=ft.ScrollMode.AUTO)
|
||||
self._page = page
|
||||
self.go = go
|
||||
self.group_filter = group_filter
|
||||
self.cards = ft.Row(wrap=True, spacing=16, run_spacing=16)
|
||||
self.controls = [
|
||||
ft.Row([
|
||||
ft.Text("Дашборд", size=28, weight=ft.FontWeight.BOLD),
|
||||
ft.IconButton(ft.Icons.REFRESH, tooltip="Обновить",
|
||||
on_click=lambda e: self.refresh()),
|
||||
]),
|
||||
self.cards,
|
||||
]
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
servers = servers_core.list_servers()
|
||||
if self.group_filter != "Все":
|
||||
servers = [s for s in servers
|
||||
if self.group_filter in [t.strip() for t in (s.tags or "").split(",")]]
|
||||
self.cards.controls = [self._placeholder(s) for s in servers]
|
||||
self._page.update()
|
||||
for srv, card in zip(servers, self.cards.controls):
|
||||
self._page.run_task(self._fill, srv, card)
|
||||
|
||||
def _placeholder(self, srv):
|
||||
note_preview = (srv.note[:60] + "…") if len(srv.note) > 60 else srv.note
|
||||
return ft.Card(
|
||||
content=ft.Container(
|
||||
width=340, padding=16,
|
||||
content=ft.Column([
|
||||
ft.Row([
|
||||
ft.Icon(ft.Icons.CIRCLE, color=ft.Colors.GREY, size=14),
|
||||
ft.Text(srv.name, size=18, weight=ft.FontWeight.BOLD),
|
||||
ft.Text(f"{srv.host}:{srv.port}", size=12,
|
||||
color=ft.Colors.GREY_500),
|
||||
ft.Container(expand=True),
|
||||
ft.IconButton(ft.Icons.NOTE_ALT_OUTLINED, icon_size=18,
|
||||
tooltip="Заметка",
|
||||
on_click=lambda e, s=srv: self._note_dialog(s)),
|
||||
]),
|
||||
ft.Text(note_preview, size=11, color=ft.Colors.GREY_500,
|
||||
visible=bool(srv.note)),
|
||||
ft.ProgressBar(height=3),
|
||||
]),
|
||||
on_click=lambda e: self.go("server_detail", srv.id),
|
||||
)
|
||||
)
|
||||
|
||||
def _note_dialog(self, srv):
|
||||
tf = ft.TextField(label="Заметка", value=srv.note, multiline=True,
|
||||
min_lines=3, max_lines=5, width=400, autofocus=True)
|
||||
def save(e):
|
||||
servers_core.update_server(srv.id, note=tf.value)
|
||||
self._page.pop_dialog()
|
||||
self.refresh()
|
||||
snack(self._page, "Заметка сохранена")
|
||||
dlg = ft.AlertDialog(
|
||||
modal=True, title=ft.Text(f"Заметка: {srv.name}"), content=tf,
|
||||
actions=[
|
||||
ft.TextButton("Отмена", on_click=lambda e: self._page.pop_dialog()),
|
||||
ft.FilledButton("Сохранить", on_click=save),
|
||||
],
|
||||
)
|
||||
self._page.show_dialog(dlg)
|
||||
|
||||
async def _fill(self, srv, card):
|
||||
info = await check_server(srv)
|
||||
if not info.get("online"):
|
||||
card.content.content.controls[1] = ft.Text(
|
||||
f"❌ {info.get('error', 'недоступен')}", color=ft.Colors.RED_400, size=13)
|
||||
card.content.content.controls[0].controls[0] = ft.Icon(
|
||||
ft.Icons.CIRCLE, color=ft.Colors.RED_400, size=14)
|
||||
self._page.update()
|
||||
return
|
||||
|
||||
# метрики
|
||||
snap = monitor.latest(srv.id)
|
||||
try:
|
||||
async with SSHClient(srv) as c:
|
||||
snap = await monitor.collect(c, srv.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def bar(label, pct, color):
|
||||
return ft.Column([
|
||||
ft.Row([
|
||||
ft.Text(label, size=12, width=45),
|
||||
ft.ProgressBar(value=pct / 100, height=6, expand=True,
|
||||
color=color, bgcolor=ft.Colors.GREY_800),
|
||||
ft.Text(f"{pct:.0f}%", size=12, width=40, text_align=ft.TextAlign.RIGHT),
|
||||
]),
|
||||
], spacing=2)
|
||||
|
||||
rows = [ft.Text(f"{info['os']} • {info['uptime']}", size=12,
|
||||
color=ft.Colors.GREY_400)]
|
||||
if snap:
|
||||
rows += [
|
||||
ft.Container(height=4),
|
||||
bar("CPU", snap.cpu_percent, ft.Colors.BLUE_400),
|
||||
bar("RAM", snap.mem_percent, ft.Colors.PURPLE_300),
|
||||
bar("Диск", snap.disk_percent,
|
||||
ft.Colors.RED_400 if snap.disk_percent > 85 else ft.Colors.GREEN_400),
|
||||
ft.Text(
|
||||
f"RAM {fmt_mb(snap.mem_used_mb)} / {fmt_mb(snap.mem_total_mb)}"
|
||||
f" • load {snap.load1:.2f}",
|
||||
size=11, color=ft.Colors.GREY_500),
|
||||
]
|
||||
card.content.content.controls[0].controls[0] = ft.Icon(
|
||||
ft.Icons.CIRCLE, color=ft.Colors.GREEN_400, size=14)
|
||||
# сохраняем заметку (controls[1]) — она идёт после заголовка
|
||||
note_ctrl = card.content.content.controls[1]
|
||||
card.content.content.controls[2:] = rows
|
||||
card.content.content.controls[1] = note_ctrl
|
||||
self._page.update()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Сравнение пакетов между серверами."""
|
||||
import flet as ft
|
||||
|
||||
from app.core import servers as servers_core
|
||||
from app.core.diff import diff_servers
|
||||
from app.core.ssh import SSHClient
|
||||
from app.ui.components import snack
|
||||
|
||||
|
||||
class DiffPage(ft.Column):
|
||||
def __init__(self, page: ft.Page, go):
|
||||
super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12)
|
||||
self._page = page
|
||||
servers = servers_core.list_servers()
|
||||
opts = [ft.dropdown.Option(str(s.id), s.name) for s in servers]
|
||||
|
||||
self.dd_a = ft.Dropdown(label="Сервер A", options=opts, width=250)
|
||||
self.dd_b = ft.Dropdown(label="Сервер B", options=opts, width=250)
|
||||
self.user_only = ft.Checkbox(label="только пользовательские пакеты", value=True)
|
||||
self.result = ft.Column(spacing=8)
|
||||
|
||||
self.controls = [
|
||||
ft.Text("Сравнение серверов", size=28, weight=ft.FontWeight.BOLD),
|
||||
ft.Row([
|
||||
self.dd_a, self.dd_b, self.user_only,
|
||||
ft.FilledButton("Сравнить", icon=ft.Icons.COMPARE_ARROWS,
|
||||
on_click=self._compare),
|
||||
]),
|
||||
self.result,
|
||||
]
|
||||
|
||||
def _compare(self, e):
|
||||
if not self.dd_a.value or not self.dd_b.value or self.dd_a.value == self.dd_b.value:
|
||||
snack(self._page, "Выберите два разных сервера", error=True)
|
||||
return
|
||||
self.result.controls = [ft.ProgressBar()]
|
||||
self._page.update()
|
||||
|
||||
async def _do():
|
||||
a = servers_core.get_server(int(self.dd_a.value))
|
||||
b = servers_core.get_server(int(self.dd_b.value))
|
||||
async with SSHClient(a) as ca, SSHClient(b) as cb:
|
||||
diff = await diff_servers(ca, cb, self.user_only.value)
|
||||
|
||||
self.result.controls = [
|
||||
ft.Row([
|
||||
self._section(f"Только на {a.name} ({len(diff.only_a)})",
|
||||
[f"{p.name} {p.version}" for p in diff.only_a],
|
||||
ft.Colors.BLUE_300),
|
||||
self._section(f"Только на {b.name} ({len(diff.only_b)})",
|
||||
[f"{p.name} {p.version}" for p in diff.only_b],
|
||||
ft.Colors.GREEN_300),
|
||||
], vertical_alignment=ft.CrossAxisAlignment.START),
|
||||
ft.Text(f"Различаются версии ({len(diff.version_mismatch)})",
|
||||
weight=ft.FontWeight.BOLD, size=16),
|
||||
ft.DataTable(columns=[
|
||||
ft.DataColumn(ft.Text("Пакет")),
|
||||
ft.DataColumn(ft.Text(a.name)),
|
||||
ft.DataColumn(ft.Text(b.name)),
|
||||
], rows=[
|
||||
ft.DataRow(cells=[
|
||||
ft.DataCell(ft.Text(m.name)),
|
||||
ft.DataCell(ft.Text(m.version_a, size=12)),
|
||||
ft.DataCell(ft.Text(m.version_b, size=12,
|
||||
color=ft.Colors.ORANGE_300)),
|
||||
]) for m in diff.version_mismatch[:100]
|
||||
]) if diff.version_mismatch else ft.Text("версии совпадают",
|
||||
color=ft.Colors.GREY_500),
|
||||
]
|
||||
self._page.update()
|
||||
self._page.run_task(_do)
|
||||
|
||||
def _section(self, title, items, color):
|
||||
return ft.Container(expand=True, content=ft.Column([
|
||||
ft.Text(title, weight=ft.FontWeight.BOLD, color=color),
|
||||
ft.Container(
|
||||
height=300, border=ft.Border.all(1, ft.Colors.GREY_800),
|
||||
border_radius=8, padding=8,
|
||||
content=ft.Column([
|
||||
ft.Text(i, size=11, font_family="Consolas") for i in items
|
||||
] or [ft.Text("—", color=ft.Colors.GREY_600)],
|
||||
scroll=ft.ScrollMode.AUTO, spacing=1),
|
||||
),
|
||||
]))
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Gitea: деплой compose-проектов."""
|
||||
import flet as ft
|
||||
|
||||
from app.core import gitea
|
||||
from app.core import servers as servers_core
|
||||
from app.core.ssh import SSHClient
|
||||
from app.core.tasks import tasks
|
||||
from app.ui.components import append_log, log_view, snack
|
||||
|
||||
|
||||
class GiteaPage(ft.Column):
|
||||
def __init__(self, page: ft.Page, go):
|
||||
super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12)
|
||||
self._page = page
|
||||
|
||||
cfg = gitea.get_config()
|
||||
self.url_tf = ft.TextField(label="URL Gitea", value=cfg["url"], width=350)
|
||||
self.token_tf = ft.TextField(label="API-токен", value=cfg["token"],
|
||||
password=True, width=350)
|
||||
self.status = ft.Text("", size=12)
|
||||
|
||||
self.repos_dd = ft.Dropdown(label="Репозиторий", width=350, options=[])
|
||||
self.srv_dd = ft.Dropdown(label="Сервер", width=250, options=[
|
||||
ft.dropdown.Option(str(s.id), s.name) for s in servers_core.list_servers()
|
||||
])
|
||||
self.branch_tf = ft.TextField(label="Ветка", value="main", width=150)
|
||||
self.compose_tf = ft.TextField(label="Путь к compose", value="docker-compose.yml",
|
||||
width=250)
|
||||
self.env_tf = ft.TextField(label=".env (опционально)", multiline=True,
|
||||
min_lines=3, max_lines=5, width=450,
|
||||
hint_text="KEY=value")
|
||||
self.log = log_view()
|
||||
self.log_container = ft.Container(
|
||||
height=200, border=ft.Border.all(1, ft.Colors.GREY_800),
|
||||
border_radius=8, padding=8, content=self.log, visible=False)
|
||||
self.bindings_col = ft.Column(spacing=4)
|
||||
|
||||
self.controls = [
|
||||
ft.Text("Gitea Deploy", size=28, weight=ft.FontWeight.BOLD),
|
||||
ft.Row([self.url_tf, self.token_tf,
|
||||
ft.FilledButton("Сохранить и проверить", on_click=self._save_cfg)]),
|
||||
self.status,
|
||||
ft.Divider(),
|
||||
ft.Row([self.repos_dd,
|
||||
ft.IconButton(ft.Icons.REFRESH, tooltip="Загрузить репозитории",
|
||||
on_click=self._load_repos)]),
|
||||
ft.Row([self.srv_dd, self.branch_tf, self.compose_tf]),
|
||||
self.env_tf,
|
||||
ft.FilledButton("Деплой", icon=ft.Icons.ROCKET_LAUNCH,
|
||||
on_click=self._deploy),
|
||||
self.log_container,
|
||||
ft.Divider(),
|
||||
ft.Text("Webhook-автодеплой (push → redeploy)", weight=ft.FontWeight.BOLD, size=16),
|
||||
ft.Text("Приложение слушает порт 9100. В Gitea: репозиторий → Настройки → Webhooks → "
|
||||
"создать: http://<этот-ПК>:9100/webhooks/gitea, событие push.",
|
||||
size=12, color=ft.Colors.GREY_500),
|
||||
ft.Row([
|
||||
ft.FilledButton("Добавить привязку", icon=ft.Icons.ADD,
|
||||
on_click=self._binding_dialog),
|
||||
ft.IconButton(ft.Icons.REFRESH, on_click=lambda e: self._load_bindings()),
|
||||
]),
|
||||
self.bindings_col,
|
||||
]
|
||||
self._load_bindings()
|
||||
|
||||
def _load_bindings(self):
|
||||
from app.core import webhook as wh
|
||||
from app.core import servers as servers_core
|
||||
servers = {s.id: s.name for s in servers_core.list_servers()}
|
||||
self.bindings_col.controls = []
|
||||
for b in wh.list_bindings():
|
||||
self.bindings_col.controls.append(ft.Card(content=ft.Container(
|
||||
padding=10,
|
||||
content=ft.Row([
|
||||
ft.Icon(ft.Icons.LINK, color=ft.Colors.BLUE_300),
|
||||
ft.Text(f"{b.repo}@{b.branch} → {servers.get(b.server_id, '?')}",
|
||||
size=13, expand=True),
|
||||
ft.Text(b.compose_path, size=11, color=ft.Colors.GREY_500),
|
||||
ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=16,
|
||||
on_click=lambda e, bid=b.id: self._del_binding(bid)),
|
||||
]),
|
||||
)))
|
||||
if not self.bindings_col.controls:
|
||||
self.bindings_col.controls.append(
|
||||
ft.Text("привязок нет — push в репозиторий не будет триггерить деплой",
|
||||
size=12, color=ft.Colors.GREY_500))
|
||||
self._page.update()
|
||||
|
||||
def _del_binding(self, bid):
|
||||
from app.core import webhook as wh
|
||||
wh.delete_binding(bid)
|
||||
self._load_bindings()
|
||||
|
||||
def _binding_dialog(self, e):
|
||||
from app.core import webhook as wh
|
||||
from app.core import servers as servers_core
|
||||
repo_tf = ft.TextField(label="Репозиторий (owner/name)", width=300,
|
||||
hint_text="dimon/myapp")
|
||||
srv_dd = ft.Dropdown(label="Сервер", width=250, options=[
|
||||
ft.dropdown.Option(str(s.id), s.name) for s in servers_core.list_servers()
|
||||
])
|
||||
branch_tf = ft.TextField(label="Ветка", value="main", width=150)
|
||||
compose_tf = ft.TextField(label="compose-файл", value="docker-compose.yml", width=250)
|
||||
env_tf = ft.TextField(label=".env (опционально)", multiline=True,
|
||||
min_lines=2, max_lines=4, width=350)
|
||||
|
||||
def save(e):
|
||||
if not repo_tf.value or not srv_dd.value:
|
||||
return
|
||||
wh.add_binding(repo=repo_tf.value.strip(),
|
||||
server_id=int(srv_dd.value),
|
||||
branch=branch_tf.value, compose_path=compose_tf.value,
|
||||
env=env_tf.value or "")
|
||||
self._page.pop_dialog()
|
||||
self._load_bindings()
|
||||
snack(self._page, "Привязка добавлена")
|
||||
|
||||
dlg = ft.AlertDialog(
|
||||
modal=True, title=ft.Text("Привязка webhook"),
|
||||
content=ft.Column([repo_tf, srv_dd, branch_tf, compose_tf, env_tf], tight=True),
|
||||
actions=[
|
||||
ft.TextButton("Отмена", on_click=lambda e: self._page.pop_dialog()),
|
||||
ft.FilledButton("Сохранить", on_click=save),
|
||||
],
|
||||
)
|
||||
self._page.show_dialog(dlg)
|
||||
|
||||
def _save_cfg(self, e):
|
||||
gitea.save_config(self.url_tf.value, self.token_tf.value)
|
||||
async def _t():
|
||||
ok, msg = await gitea.test_connection()
|
||||
self.status.value = ("✅ " if ok else "❌ ") + msg
|
||||
self.status.color = ft.Colors.GREEN_300 if ok else ft.Colors.RED_300
|
||||
self._page.update()
|
||||
self._page.run_task(_t)
|
||||
|
||||
def _load_repos(self, e):
|
||||
async def _l():
|
||||
try:
|
||||
repos = await gitea.list_repos()
|
||||
self.repos_dd.options = [
|
||||
ft.dropdown.Option(r["full_name"],
|
||||
f"{r['full_name']} ({r['default_branch']})")
|
||||
for r in repos
|
||||
]
|
||||
self._repos = {r["full_name"]: r for r in repos}
|
||||
self.status.value = f"загружено {len(repos)} репозиториев"
|
||||
except Exception as ex:
|
||||
self.status.value = f"ошибка: {ex}"
|
||||
self._page.update()
|
||||
self._page.run_task(_l)
|
||||
|
||||
def _deploy(self, e):
|
||||
if not self.repos_dd.value or not self.srv_dd.value:
|
||||
snack(self._page, "Выберите репозиторий и сервер", error=True)
|
||||
return
|
||||
repo = self._repos[self.repos_dd.value]
|
||||
srv = servers_core.get_server(int(self.srv_dd.value))
|
||||
self.log_container.visible = True
|
||||
|
||||
async def job(log):
|
||||
async with SSHClient(srv) as c:
|
||||
await gitea.deploy(c, repo["clone_url"], repo["name"],
|
||||
self.branch_tf.value, self.compose_tf.value,
|
||||
self.env_tf.value or None, log)
|
||||
tasks.start("gitea", f"deploy {repo['name']}", srv.id, job,
|
||||
on_log=lambda l: (append_log(self.log, l), self._page.update()))
|
||||
snack(self._page, f"Деплой {repo['name']} запущен")
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Групповые операции по серверам."""
|
||||
import flet as ft
|
||||
|
||||
from app.core import servers as servers_core
|
||||
from app.core.group_ops import (GroupResult, op_apt_update, op_install_package,
|
||||
op_reboot, op_shell, op_upgrade, run_group)
|
||||
from app.ui.components import snack
|
||||
|
||||
|
||||
class GroupOpsPage(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
|
||||
|
||||
servers = servers_core.list_servers()
|
||||
self.checks = {s.id: ft.Checkbox(label=f"{s.name} ({s.host})", value=False,
|
||||
data=s.id) for s in servers}
|
||||
|
||||
self.op_dd = ft.Dropdown(
|
||||
label="Операция", value="update", width=300,
|
||||
options=[
|
||||
ft.dropdown.Option("update", "apt update"),
|
||||
ft.dropdown.Option("upgrade", "upgrade"),
|
||||
ft.dropdown.Option("dist", "dist-upgrade"),
|
||||
ft.dropdown.Option("reboot", "reboot (с ожиданием)"),
|
||||
ft.dropdown.Option("shell", "произвольная команда"),
|
||||
ft.dropdown.Option("install", "установить пакет"),
|
||||
],
|
||||
on_select=lambda e: self._toggle_param(),
|
||||
)
|
||||
self.param_tf = ft.TextField(label="Команда / пакет", visible=False, width=400)
|
||||
self.rolling_cb = ft.Checkbox(label="Rolling (по одному, ждать возврата)", value=False)
|
||||
self.failfast_cb = ft.Checkbox(label="Остановиться при первой ошибке", value=False)
|
||||
self.results_col = ft.Column(spacing=6)
|
||||
|
||||
self.controls = [
|
||||
ft.Text("Групповые операции", size=28, weight=ft.FontWeight.BOLD),
|
||||
ft.Row([
|
||||
ft.Card(content=ft.Container(padding=14, width=380, content=ft.Column([
|
||||
ft.Text("Серверы", weight=ft.FontWeight.BOLD),
|
||||
*self.checks.values(),
|
||||
ft.TextButton("выбрать все", on_click=lambda e: self._select_all(True)),
|
||||
ft.TextButton("снять все", on_click=lambda e: self._select_all(False)),
|
||||
]))),
|
||||
ft.Card(content=ft.Container(padding=14, expand=True, content=ft.Column([
|
||||
ft.Text("Операция", weight=ft.FontWeight.BOLD),
|
||||
self.op_dd,
|
||||
self.param_tf,
|
||||
self.rolling_cb,
|
||||
self.failfast_cb,
|
||||
ft.FilledButton("Запустить", icon=ft.Icons.PLAY_ARROW,
|
||||
on_click=self._run),
|
||||
], spacing=10))),
|
||||
], vertical_alignment=ft.CrossAxisAlignment.START),
|
||||
ft.Text("Результаты", size=18, weight=ft.FontWeight.BOLD),
|
||||
self.results_col,
|
||||
]
|
||||
|
||||
def _select_all(self, v):
|
||||
for cb in self.checks.values():
|
||||
cb.value = v
|
||||
self._page.update()
|
||||
|
||||
def _toggle_param(self):
|
||||
self.param_tf.visible = self.op_dd.value in ("shell", "install")
|
||||
self._page.update()
|
||||
|
||||
def _run(self, e):
|
||||
selected = [s for s in servers_core.list_servers()
|
||||
if self.checks.get(s.id) and self.checks[s.id].value]
|
||||
if not selected:
|
||||
snack(self._page, "Выберите хотя бы один сервер", error=True)
|
||||
return
|
||||
|
||||
op_name = self.op_dd.value
|
||||
if op_name == "update":
|
||||
op = op_apt_update()
|
||||
elif op_name == "upgrade":
|
||||
op = op_upgrade(False)
|
||||
elif op_name == "dist":
|
||||
op = op_upgrade(True)
|
||||
elif op_name == "reboot":
|
||||
op = op_reboot(wait=True)
|
||||
elif op_name == "shell":
|
||||
if not self.param_tf.value:
|
||||
snack(self._page, "Введите команду", error=True)
|
||||
return
|
||||
op = op_shell(self.param_tf.value)
|
||||
else:
|
||||
if not self.param_tf.value:
|
||||
snack(self._page, "Введите имя пакета", error=True)
|
||||
return
|
||||
op = op_install_package(self.param_tf.value.split())
|
||||
|
||||
self.results_col.controls = [
|
||||
ft.Text(f"запущено на {len(selected)} серверах…", color=ft.Colors.GREY_500)]
|
||||
self._page.update()
|
||||
|
||||
async def job():
|
||||
def on_status(res: GroupResult):
|
||||
self._render_results(res)
|
||||
res = await run_group(
|
||||
selected, op,
|
||||
rolling=self.rolling_cb.value,
|
||||
fail_fast=self.failfast_cb.value,
|
||||
on_status=on_status,
|
||||
)
|
||||
self._render_results(res)
|
||||
snack(self._page, f"готово: ✅{res.ok_count} ❌{res.err_count}")
|
||||
self._page.run_task(job)
|
||||
|
||||
def _render_results(self, res: GroupResult):
|
||||
self.results_col.controls = []
|
||||
for r in res.results.values():
|
||||
icon = {"pending": ft.Icons.HOURGLASS_EMPTY, "running": ft.Icons.SYNC,
|
||||
"success": ft.Icons.CHECK_CIRCLE, "error": ft.Icons.ERROR}.get(r.status)
|
||||
color = {"success": ft.Colors.GREEN_400, "error": ft.Colors.RED_400,
|
||||
"running": ft.Colors.BLUE_300}.get(r.status, ft.Colors.GREY_500)
|
||||
log_preview = r.log.strip().splitlines()[-3:] if r.log.strip() else []
|
||||
self.results_col.controls.append(ft.Card(content=ft.Container(
|
||||
padding=10,
|
||||
content=ft.Column([
|
||||
ft.Row([ft.Icon(icon, color=color, size=18),
|
||||
ft.Text(r.name, weight=ft.FontWeight.BOLD),
|
||||
ft.Text(r.status, size=12, color=color)]),
|
||||
*[ft.Text(l, size=10, font_family="Consolas",
|
||||
color=ft.Colors.GREY_500) for l in log_preview],
|
||||
], spacing=2),
|
||||
)))
|
||||
self._page.update()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Плейбуки: список, редактор, запуск."""
|
||||
import flet as ft
|
||||
|
||||
from app.core import playbooks as pb
|
||||
from app.core import servers as servers_core
|
||||
from app.core.ssh import SSHClient
|
||||
from app.core.tasks import tasks
|
||||
from app.ui.components import append_log, log_view, snack
|
||||
|
||||
|
||||
class PlaybooksPage(ft.Column):
|
||||
def __init__(self, page: ft.Page, go):
|
||||
super().__init__(expand=True, spacing=12)
|
||||
self._page = page
|
||||
self.go = go
|
||||
|
||||
self.list_col = ft.Column(spacing=6, scroll=ft.ScrollMode.AUTO)
|
||||
self.editor = ft.TextField(multiline=True, min_lines=18, max_lines=18,
|
||||
text_size=12,
|
||||
text_style=ft.TextStyle(font_family="Consolas"),
|
||||
expand=True)
|
||||
self.editor_name = ft.Text("", weight=ft.FontWeight.BOLD)
|
||||
self.log = log_view()
|
||||
self.log_container = ft.Container(
|
||||
height=180, border=ft.Border.all(1, ft.Colors.GREY_800),
|
||||
border_radius=8, padding=8, content=self.log, visible=False)
|
||||
|
||||
self.controls = [
|
||||
ft.Text("Плейбуки", size=28, weight=ft.FontWeight.BOLD),
|
||||
ft.Row([
|
||||
ft.Container(width=300, content=ft.Column([
|
||||
ft.FilledButton("Новый плейбук", icon=ft.Icons.ADD,
|
||||
on_click=self._new),
|
||||
ft.Divider(),
|
||||
self.list_col,
|
||||
])),
|
||||
ft.VerticalDivider(width=1),
|
||||
ft.Container(expand=True, content=ft.Column([
|
||||
ft.Row([
|
||||
self.editor_name,
|
||||
ft.Container(expand=True),
|
||||
ft.OutlinedButton("Сохранить", icon=ft.Icons.SAVE,
|
||||
on_click=self._save),
|
||||
ft.FilledButton("Запустить", icon=ft.Icons.PLAY_ARROW,
|
||||
on_click=self._run_dialog),
|
||||
]),
|
||||
self.editor,
|
||||
self.log_container,
|
||||
], expand=True)),
|
||||
], expand=True),
|
||||
]
|
||||
self._current = None
|
||||
self._reload_list()
|
||||
|
||||
def _reload_list(self):
|
||||
self.list_col.controls = [
|
||||
ft.ListTile(
|
||||
title=ft.Text(name, size=13),
|
||||
subtitle=ft.Text("встроенный" if name in pb.BUILTIN else "пользовательский",
|
||||
size=10),
|
||||
dense=True,
|
||||
on_click=lambda e, n=name: self._open(n),
|
||||
) for name in pb.list_playbooks()
|
||||
]
|
||||
self._page.update()
|
||||
|
||||
def _open(self, name):
|
||||
import yaml
|
||||
self._current = name
|
||||
data = pb.load(name)
|
||||
self.editor.value = pb.BUILTIN.get(name) or (
|
||||
pb.PLAYBOOKS_DIR / f"{name}.yml").read_text(encoding="utf-8")
|
||||
self.editor_name.value = name
|
||||
self._page.update()
|
||||
|
||||
def _new(self, e):
|
||||
name_tf = ft.TextField(label="Имя", autofocus=True)
|
||||
def create(e):
|
||||
self._current = name_tf.value
|
||||
self.editor_name.value = name_tf.value
|
||||
self.editor.value = 'name: "Новый плейбук"\nsteps:\n - name: Проверка\n shell: "uptime"\n'
|
||||
self._page.pop_dialog()
|
||||
dlg = ft.AlertDialog(modal=True, title=ft.Text("Имя плейбука"), content=name_tf,
|
||||
actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)),
|
||||
ft.FilledButton("Создать", on_click=create)])
|
||||
self._page.show_dialog(dlg)
|
||||
|
||||
def _save(self, e):
|
||||
if not self._current or self._current in pb.BUILTIN:
|
||||
snack(self._page, "Встроенный плейбук нельзя перезаписать — создайте копию",
|
||||
error=True)
|
||||
return
|
||||
try:
|
||||
pb.save(self._current, self.editor.value)
|
||||
snack(self._page, "Сохранено")
|
||||
self._reload_list()
|
||||
except Exception as ex:
|
||||
snack(self._page, f"Ошибка YAML: {ex}", error=True)
|
||||
|
||||
def _run_dialog(self, e):
|
||||
if not self._current:
|
||||
return
|
||||
servers = servers_core.list_servers()
|
||||
checks = {s.id: ft.Checkbox(label=s.name, value=False) for s in servers}
|
||||
def run(e):
|
||||
selected = [s for s in servers if checks[s.id].value]
|
||||
if not selected:
|
||||
snack(self._page, "Выберите сервер", error=True)
|
||||
return
|
||||
self._page.pop_dialog()
|
||||
self._run_on(selected)
|
||||
dlg = ft.AlertDialog(
|
||||
modal=True, title=ft.Text(f"Запустить «{self._current}»"),
|
||||
content=ft.Column(list(checks.values()), tight=True),
|
||||
actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)),
|
||||
ft.FilledButton("Запустить", on_click=run)],
|
||||
)
|
||||
self._page.show_dialog(dlg)
|
||||
|
||||
def _run_on(self, servers):
|
||||
self.log_container.visible = True
|
||||
data = pb.load(self._current)
|
||||
for srv in servers:
|
||||
async def job(log, srv=srv):
|
||||
async with SSHClient(srv) as c:
|
||||
await pb.run_playbook(c, srv, data, log)
|
||||
tasks.start("playbook", f"плейбук {self._current}", srv.id, job,
|
||||
on_log=lambda l: (append_log(self.log, l), self._page.update()))
|
||||
|
||||
def _close(self, dlg):
|
||||
self._page.pop_dialog()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Portainer: статус, открытие в браузере, установка/перезапуск."""
|
||||
import flet as ft
|
||||
|
||||
from app.core.ssh import SSHClient
|
||||
from app.core import docker_svc as d
|
||||
from app.db.models import Server
|
||||
from app.ui.components import snack
|
||||
|
||||
PORTAINER_PORT = 9000
|
||||
|
||||
|
||||
class PortainerPage(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
|
||||
self.cards_col = ft.Column(spacing=10)
|
||||
self.controls = [
|
||||
ft.Text("Portainer", size=28, weight=ft.FontWeight.BOLD),
|
||||
ft.Row([
|
||||
ft.OutlinedButton("Обновить", icon=ft.Icons.REFRESH,
|
||||
on_click=lambda e: self._reload()),
|
||||
]),
|
||||
self.cards_col,
|
||||
]
|
||||
self._reload()
|
||||
|
||||
def _reload(self):
|
||||
from app.core import servers as servers_core
|
||||
self.cards_col.controls = []
|
||||
for srv in servers_core.list_servers():
|
||||
self.cards_col.controls.append(self._card_placeholder(srv))
|
||||
self._page.update()
|
||||
from app.core import servers as servers_core
|
||||
for srv, card in zip(servers_core.list_servers(), self.cards_col.controls):
|
||||
self._page.run_task(self._fill, srv, card)
|
||||
|
||||
def _card_placeholder(self, srv):
|
||||
return ft.Card(content=ft.Container(
|
||||
padding=14, width=500,
|
||||
content=ft.Column([
|
||||
ft.Text(srv.name, weight=ft.FontWeight.BOLD, size=16),
|
||||
ft.ProgressBar(height=3),
|
||||
]),
|
||||
))
|
||||
|
||||
async def _fill(self, srv, card):
|
||||
url = f"http://{srv.host}:{PORTAINER_PORT}"
|
||||
try:
|
||||
async with SSHClient(srv) as c:
|
||||
containers = await d.list_containers(c)
|
||||
portainer = next((x for x in containers if "portainer" in x["Names"].lower()), None)
|
||||
if not portainer:
|
||||
card.content.content.controls[1] = ft.Column([
|
||||
ft.Text("Portainer не установлен", color=ft.Colors.GREY_500),
|
||||
ft.FilledButton("Установить", icon=ft.Icons.DOWNLOAD,
|
||||
on_click=lambda e, s=srv: self._install(s)),
|
||||
])
|
||||
else:
|
||||
running = "Up" in portainer["Status"]
|
||||
cid = portainer["ID"]
|
||||
card.content.content.controls[1] = ft.Column([
|
||||
ft.Row([
|
||||
ft.Icon(ft.Icons.CIRCLE, size=12,
|
||||
color=ft.Colors.GREEN_400 if running else ft.Colors.RED_400),
|
||||
ft.Text(portainer["Status"], size=12),
|
||||
]),
|
||||
ft.Row([
|
||||
ft.FilledButton("Открыть в браузере", icon=ft.Icons.OPEN_IN_BROWSER,
|
||||
on_click=lambda e, u=url: self._page.launch_url(u)),
|
||||
ft.OutlinedButton(
|
||||
"Перезапустить" if running else "Запустить",
|
||||
on_click=lambda e, s=srv, c=cid, a="restart" if running else "start":
|
||||
self._action(s, c, a)),
|
||||
]),
|
||||
ft.Text(url, size=11, color=ft.Colors.BLUE_300, selectable=True),
|
||||
])
|
||||
except Exception as e:
|
||||
card.content.content.controls[1] = ft.Text(f"ошибка: {e}", size=12,
|
||||
color=ft.Colors.RED_300)
|
||||
self._page.update()
|
||||
|
||||
def _install(self, srv):
|
||||
from app.core.tasks import tasks
|
||||
from app.ui.components import append_log, log_view
|
||||
async def job(log):
|
||||
async with SSHClient(srv) as c:
|
||||
log("$ docker volume create portainer_data")
|
||||
await c.sudo("docker volume create portainer_data 2>/dev/null || true")
|
||||
log("$ docker run portainer")
|
||||
r = await c.sudo(
|
||||
"docker rm -f portainer 2>/dev/null; "
|
||||
"docker run -d -p 9000:9000 --name portainer --restart=always "
|
||||
"-v /var/run/docker.sock:/var/run/docker.sock "
|
||||
"-v portainer_data:/data portainer/portainer-ce:lts", timeout=600)
|
||||
log(r.stdout + r.stderr)
|
||||
tasks.start("portainer", "установка Portainer", srv.id, job,
|
||||
on_done=lambda r: self._reload())
|
||||
snack(self._page, f"Установка Portainer на {srv.name} запущена")
|
||||
|
||||
def _action(self, srv, cid, action):
|
||||
async def _do():
|
||||
async with SSHClient(srv) as c:
|
||||
await d.container_action(c, cid, action)
|
||||
self._reload()
|
||||
self._page.run_task(_do)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Экспорт отчётов: генерация CSV, история."""
|
||||
import subprocess
|
||||
|
||||
import flet as ft
|
||||
|
||||
from app.core import reports as rep
|
||||
from app.ui.components import snack
|
||||
|
||||
|
||||
class ReportsPage(ft.Column):
|
||||
def __init__(self, page: ft.Page, go):
|
||||
super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12)
|
||||
self._page = page
|
||||
|
||||
self.status = ft.Text("", size=13, color=ft.Colors.GREY_500)
|
||||
self.list_col = ft.Column(spacing=4)
|
||||
|
||||
self.controls = [
|
||||
ft.Text("Отчёты", size=28, weight=ft.FontWeight.BOLD),
|
||||
ft.Text("CSV со списком серверов, статусом онлайн, метриками и последними задачами.",
|
||||
size=13, color=ft.Colors.GREY_500),
|
||||
ft.Row([
|
||||
ft.FilledButton("Сгенерировать отчёт", icon=ft.Icons.SUMMARIZE,
|
||||
on_click=self._generate),
|
||||
ft.IconButton(ft.Icons.REFRESH, on_click=lambda e: self._reload()),
|
||||
]),
|
||||
self.status,
|
||||
ft.Divider(),
|
||||
ft.Text("История отчётов", weight=ft.FontWeight.BOLD, size=16),
|
||||
self.list_col,
|
||||
]
|
||||
self._reload()
|
||||
|
||||
def _generate(self, e):
|
||||
self.status.value = "генерация…"
|
||||
self._page.update()
|
||||
|
||||
async def _do():
|
||||
path = await rep.generate()
|
||||
self.status.value = f"сохранён: {path}"
|
||||
self._reload()
|
||||
self._page.run_task(_do)
|
||||
|
||||
def _reload(self):
|
||||
self.list_col.controls = []
|
||||
for p in rep.list_reports():
|
||||
size_kb = p.stat().st_size // 1024
|
||||
self.list_col.controls.append(
|
||||
ft.ListTile(
|
||||
leading=ft.Icon(ft.Icons.DESCRIPTION),
|
||||
title=ft.Text(p.name, size=13),
|
||||
subtitle=ft.Text(f"{size_kb} КБ", size=11),
|
||||
dense=True,
|
||||
trailing=ft.IconButton(
|
||||
ft.Icons.OPEN_IN_NEW, tooltip="Открыть",
|
||||
on_click=lambda e, p=p: self._open(p)),
|
||||
)
|
||||
)
|
||||
if not self.list_col.controls:
|
||||
self.list_col.controls.append(
|
||||
ft.Text("отчётов пока нет", color=ft.Colors.GREY_500))
|
||||
self._page.update()
|
||||
|
||||
def _open(self, path):
|
||||
try:
|
||||
subprocess.Popen(["explorer", "/select,", str(path)])
|
||||
except Exception as ex:
|
||||
snack(self._page, f"не удалось открыть: {ex}", error=True)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Планировщик: задачи по cron, пресеты, запуск."""
|
||||
import flet as ft
|
||||
from croniter import croniter
|
||||
|
||||
from app.core import scheduler as sch
|
||||
from app.ui.components import confirm_dialog, snack
|
||||
|
||||
|
||||
class SchedulerPage(ft.Column):
|
||||
def __init__(self, page: ft.Page, go):
|
||||
super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12)
|
||||
self._page = page
|
||||
self.table = ft.DataTable(columns=[
|
||||
ft.DataColumn(ft.Text("Вкл")),
|
||||
ft.DataColumn(ft.Text("Название")),
|
||||
ft.DataColumn(ft.Text("Расписание")),
|
||||
ft.DataColumn(ft.Text("Действие")),
|
||||
ft.DataColumn(ft.Text("Цель")),
|
||||
ft.DataColumn(ft.Text("Последний запуск")),
|
||||
ft.DataColumn(ft.Text("Статус")),
|
||||
ft.DataColumn(ft.Text("")),
|
||||
], rows=[])
|
||||
self.controls = [
|
||||
ft.Row([
|
||||
ft.Text("Планировщик", size=28, weight=ft.FontWeight.BOLD),
|
||||
ft.FilledButton("Добавить задачу", icon=ft.Icons.ADD,
|
||||
on_click=self._add_dialog),
|
||||
ft.OutlinedButton("Из пресета", icon=ft.Icons.LIST_ALT,
|
||||
on_click=self._preset_dialog),
|
||||
ft.IconButton(ft.Icons.REFRESH, on_click=lambda e: self._reload()),
|
||||
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
||||
ft.Card(content=ft.Container(padding=10, content=self.table)),
|
||||
]
|
||||
self._reload()
|
||||
|
||||
def _reload(self):
|
||||
self.table.rows = []
|
||||
for t in sch.list_tasks():
|
||||
try:
|
||||
nxt = croniter(t.cron).get_next(__import__("datetime").datetime)
|
||||
sched_txt = f"{t.cron} → {nxt:%d.%m %H:%M}"
|
||||
except Exception:
|
||||
sched_txt = f"{t.cron} (неверно)"
|
||||
self.table.rows.append(ft.DataRow(cells=[
|
||||
ft.DataCell(ft.Checkbox(value=t.enabled,
|
||||
on_change=lambda e, t=t: self._toggle(t))),
|
||||
ft.DataCell(ft.Text(t.name, weight=ft.FontWeight.BOLD)),
|
||||
ft.DataCell(ft.Text(sched_txt, size=11)),
|
||||
ft.DataCell(ft.Text(t.action, size=11)),
|
||||
ft.DataCell(ft.Text(t.target, size=11)),
|
||||
ft.DataCell(ft.Text(f"{t.last_run:%d.%m %H:%M}" if t.last_run else "—",
|
||||
size=11)),
|
||||
ft.DataCell(ft.Text(t.last_status or "—", size=11,
|
||||
color=ft.Colors.GREEN_300 if t.last_status == "success"
|
||||
else ft.Colors.RED_300 if t.last_status == "error"
|
||||
else ft.Colors.GREY_500)),
|
||||
ft.DataCell(ft.Row([
|
||||
ft.IconButton(ft.Icons.PLAY_ARROW, icon_size=16, tooltip="Запустить сейчас",
|
||||
on_click=lambda e, t=t: self._run_now(t)),
|
||||
ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=16,
|
||||
on_click=lambda e, t=t: self._delete(t)),
|
||||
], spacing=0)),
|
||||
]))
|
||||
self._page.update()
|
||||
|
||||
def _toggle(self, t):
|
||||
sch.update_task(t.id, enabled=not t.enabled)
|
||||
self._reload()
|
||||
|
||||
def _run_now(self, t):
|
||||
async def _r():
|
||||
status = await sch.execute_task(t.id)
|
||||
snack(self._page, f"{t.name}: {status}",
|
||||
error=status != "success")
|
||||
self._reload()
|
||||
self._page.run_task(_r)
|
||||
|
||||
def _delete(self, t):
|
||||
def do():
|
||||
sch.delete_task(t.id)
|
||||
self._reload()
|
||||
confirm_dialog(self._page, f"Удалить задачу «{t.name}»?", "", do)
|
||||
|
||||
def _add_dialog(self, e):
|
||||
self._form("Новая задача", None)
|
||||
|
||||
def _form(self, title, t):
|
||||
name_tf = ft.TextField(label="Название", value=t.name if t else "", width=350)
|
||||
cron_tf = ft.TextField(label="Cron (мин час день мес день_нед)",
|
||||
value=t.cron if t else "0 3 * * *", width=350)
|
||||
hint = ft.Text("", size=11, color=ft.Colors.GREY_500)
|
||||
|
||||
def upd(e):
|
||||
try:
|
||||
nxt = croniter(cron_tf.value).get_next(__import__("datetime").datetime)
|
||||
hint.value = f"следующий запуск: {nxt:%Y-%m-%d %H:%M}"
|
||||
hint.color = ft.Colors.GREY_500
|
||||
except Exception:
|
||||
hint.value = "неверное выражение"
|
||||
hint.color = ft.Colors.RED_300
|
||||
dlg.content.update()
|
||||
cron_tf.on_change = upd
|
||||
|
||||
action_dd = ft.Dropdown(label="Действие", value=t.action if t else "apt_update",
|
||||
width=350, options=[
|
||||
ft.dropdown.Option("apt_update", "apt update"),
|
||||
ft.dropdown.Option("upgrade", "upgrade"),
|
||||
ft.dropdown.Option("dist_upgrade", "dist-upgrade"),
|
||||
ft.dropdown.Option("shell", "команда"),
|
||||
ft.dropdown.Option("backup", "бэкап"),
|
||||
ft.dropdown.Option("playbook", "плейбук"),
|
||||
ft.dropdown.Option("report", "отчёт"),
|
||||
ft.dropdown.Option("cleanup_metrics", "очистка метрик"),
|
||||
])
|
||||
target_tf = ft.TextField(label="Цель (all или тег)",
|
||||
value=t.target if t else "all", width=350)
|
||||
params_tf = ft.TextField(label="Параметры (команда / пути / плейбук)",
|
||||
value=t.params if t else "", width=350)
|
||||
|
||||
def save(e):
|
||||
if "неверное" in (hint.value or ""):
|
||||
snack(self._page, "Исправьте cron-выражение", error=True)
|
||||
return
|
||||
fields = dict(name=name_tf.value, cron=cron_tf.value,
|
||||
action=action_dd.value, target=target_tf.value,
|
||||
params=params_tf.value)
|
||||
if t:
|
||||
sch.update_task(t.id, **fields)
|
||||
else:
|
||||
sch.add_task(**fields)
|
||||
self._page.pop_dialog()
|
||||
self._reload()
|
||||
snack(self._page, "Сохранено")
|
||||
|
||||
dlg = ft.AlertDialog(
|
||||
modal=True, title=ft.Text(title),
|
||||
content=ft.Column([name_tf, cron_tf, hint, action_dd, target_tf, params_tf],
|
||||
tight=True),
|
||||
actions=[
|
||||
ft.TextButton("Отмена", on_click=lambda e: self._page.pop_dialog()),
|
||||
ft.FilledButton("Сохранить", on_click=save),
|
||||
],
|
||||
)
|
||||
self._page.show_dialog(dlg)
|
||||
|
||||
def _preset_dialog(self, e):
|
||||
def add(idx):
|
||||
name, cron, action, target, params = sch.PRESETS[idx]
|
||||
sch.add_task(name=name, cron=cron, action=action, target=target, params=params)
|
||||
self._page.pop_dialog()
|
||||
self._reload()
|
||||
snack(self._page, f"Добавлено: {name}")
|
||||
dlg = ft.AlertDialog(
|
||||
modal=True, title=ft.Text("Пресеты"),
|
||||
content=ft.Column([
|
||||
ft.TextButton(f"{name} ({cron})", on_click=lambda e, i=i: add(i))
|
||||
for i, (name, cron, *_ ) in enumerate(sch.PRESETS)
|
||||
], tight=True),
|
||||
actions=[ft.TextButton("Отмена", on_click=lambda e: self._page.pop_dialog())],
|
||||
)
|
||||
self._page.show_dialog(dlg)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Страница сервера: вкладки Обзор / Пакеты / Docker / Файлы / Сервисы / Firewall / Cron."""
|
||||
import flet as ft
|
||||
|
||||
from app.core import servers as servers_core
|
||||
from app.ui.tabs.overview import OverviewTab
|
||||
from app.ui.tabs.packages import PackagesTab
|
||||
from app.ui.tabs.docker_tab import DockerTab
|
||||
from app.ui.tabs.files_tab import FilesTab
|
||||
from app.ui.tabs.systemd_tab import SystemdTab
|
||||
from app.ui.tabs.firewall_tab import FirewallTab
|
||||
from app.ui.tabs.cron_tab import CronTab
|
||||
from app.ui.tabs.terminal_tab import TerminalTab
|
||||
|
||||
|
||||
class ServerDetailPage(ft.Column):
|
||||
def __init__(self, page: ft.Page, server_id: int, go):
|
||||
super().__init__(expand=True)
|
||||
self._page = page
|
||||
self.go = go
|
||||
srv = servers_core.get_server(server_id)
|
||||
|
||||
tabs = ft.Tabs(
|
||||
selected_index=0,
|
||||
length=8,
|
||||
expand=True,
|
||||
content=ft.Column(
|
||||
expand=True,
|
||||
controls=[
|
||||
ft.TabBar(tabs=[
|
||||
ft.Tab(label="Обзор", icon=ft.Icons.INFO),
|
||||
ft.Tab(label="Пакеты", icon=ft.Icons.INVENTORY_2),
|
||||
ft.Tab(label="Docker", icon=ft.Icons.AC_UNIT),
|
||||
ft.Tab(label="Файлы", icon=ft.Icons.FOLDER),
|
||||
ft.Tab(label="Сервисы", icon=ft.Icons.SETTINGS_APPLICATIONS),
|
||||
ft.Tab(label="Firewall", icon=ft.Icons.SHIELD),
|
||||
ft.Tab(label="Cron", icon=ft.Icons.SCHEDULE),
|
||||
ft.Tab(label="Терминал", icon=ft.Icons.TERMINAL),
|
||||
]),
|
||||
ft.TabBarView(
|
||||
expand=True,
|
||||
controls=[
|
||||
ft.Container(padding=10, content=OverviewTab(page, srv)),
|
||||
ft.Container(padding=10, content=PackagesTab(page, srv)),
|
||||
ft.Container(padding=10, content=DockerTab(page, srv)),
|
||||
ft.Container(padding=10, content=FilesTab(page, srv)),
|
||||
ft.Container(padding=10, content=SystemdTab(page, srv)),
|
||||
ft.Container(padding=10, content=FirewallTab(page, srv)),
|
||||
ft.Container(padding=10, content=CronTab(page, srv)),
|
||||
ft.Container(padding=0, content=TerminalTab(page, srv)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
self.controls = [
|
||||
ft.Row([
|
||||
ft.IconButton(ft.Icons.ARROW_BACK, on_click=lambda e: go("dashboard")),
|
||||
ft.Text(srv.name, size=24, weight=ft.FontWeight.BOLD),
|
||||
ft.Text(f"{srv.username}@{srv.host}:{srv.port}",
|
||||
size=13, color=ft.Colors.GREY_500),
|
||||
]),
|
||||
tabs,
|
||||
]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Серверы: список, добавление, редактирование, удаление."""
|
||||
import flet as ft
|
||||
|
||||
from app.core import servers as servers_core
|
||||
from app.core.ssh import check_server
|
||||
from app.ui.components import confirm_dialog, snack
|
||||
|
||||
|
||||
class ServersPage(ft.Column):
|
||||
def __init__(self, page: ft.Page, go):
|
||||
super().__init__(expand=True, scroll=ft.ScrollMode.AUTO)
|
||||
self._page = page
|
||||
self.go = go
|
||||
self.table = ft.DataTable(
|
||||
columns=[
|
||||
ft.DataColumn(ft.Text("Имя")),
|
||||
ft.DataColumn(ft.Text("Хост")),
|
||||
ft.DataColumn(ft.Text("Пользователь")),
|
||||
ft.DataColumn(ft.Text("Теги")),
|
||||
ft.DataColumn(ft.Text("Статус")),
|
||||
ft.DataColumn(ft.Text("Действия")),
|
||||
],
|
||||
rows=[],
|
||||
)
|
||||
self.controls = [
|
||||
ft.Row([
|
||||
ft.Text("Серверы", size=28, weight=ft.FontWeight.BOLD),
|
||||
ft.FilledButton("Добавить сервер", icon=ft.Icons.ADD,
|
||||
on_click=self._add_dialog),
|
||||
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
||||
ft.Card(content=ft.Container(padding=10, content=self.table)),
|
||||
]
|
||||
self.reload()
|
||||
|
||||
def reload(self):
|
||||
self.table.rows = []
|
||||
for s in servers_core.list_servers():
|
||||
self.table.rows.append(ft.DataRow(cells=[
|
||||
ft.DataCell(ft.Text(s.name, weight=ft.FontWeight.BOLD)),
|
||||
ft.DataCell(ft.Text(f"{s.host}:{s.port}")),
|
||||
ft.DataCell(ft.Text(s.username)),
|
||||
ft.DataCell(ft.Text(s.tags or "—")),
|
||||
ft.DataCell(ft.Text("…", key=f"status_{s.id}")),
|
||||
ft.DataCell(ft.Row([
|
||||
ft.IconButton(ft.Icons.OPEN_IN_NEW, tooltip="Открыть",
|
||||
on_click=lambda e, sid=s.id: self.go("server_detail", sid)),
|
||||
ft.IconButton(ft.Icons.EDIT, tooltip="Изменить",
|
||||
on_click=lambda e, s=s: self._edit_dialog(s)),
|
||||
ft.IconButton(ft.Icons.DELETE, tooltip="Удалить",
|
||||
on_click=lambda e, s=s: self._delete(s)),
|
||||
])),
|
||||
]))
|
||||
self._page.update()
|
||||
for s in servers_core.list_servers():
|
||||
self._page.run_task(self._check, s)
|
||||
|
||||
async def _check(self, s):
|
||||
info = await check_server(s)
|
||||
txt = "🟢 онлайн" if info.get("online") else "🔴 оффлайн"
|
||||
self._statuses = getattr(self, "_statuses", {})
|
||||
self._statuses[s.id] = txt
|
||||
for i, srv in enumerate(servers_core.list_servers()):
|
||||
if i < len(self.table.rows):
|
||||
self.table.rows[i].cells[4].content = ft.Text(
|
||||
self._statuses.get(srv.id, "…"))
|
||||
self._page.update()
|
||||
|
||||
# ---------- диалоги ----------
|
||||
|
||||
def _form(self, s=None):
|
||||
return {
|
||||
"name": ft.TextField(label="Имя", value=s.name if s else "", width=300),
|
||||
"host": ft.TextField(label="Хост", value=s.host if s else "", width=300),
|
||||
"port": ft.TextField(label="Порт", value=str(s.port) if s else "22", width=100),
|
||||
"username": ft.TextField(label="Пользователь", value=s.username if s else "root", width=300),
|
||||
"auth_type": ft.Dropdown(
|
||||
label="Авторизация", width=300,
|
||||
value=s.auth_type if s else "password",
|
||||
options=[
|
||||
ft.dropdown.Option("password", "Пароль"),
|
||||
ft.dropdown.Option("key", "SSH-ключ"),
|
||||
],
|
||||
),
|
||||
"password": ft.TextField(label="Пароль / passphrase", password=True,
|
||||
value=s.password if s else "", width=300),
|
||||
"key_path": ft.TextField(label="Путь к ключу", value=s.key_path or "" if s else "",
|
||||
width=300),
|
||||
"sudo_password": ft.TextField(label="Sudo-пароль (если отличается)", password=True,
|
||||
value=s.sudo_password or "" if s else "", width=300),
|
||||
"tags": ft.TextField(label="Теги (через запятую)", value=s.tags if s else "", width=300),
|
||||
}
|
||||
|
||||
def _add_dialog(self, e):
|
||||
self._open_form("Новый сервер", self._form(), None)
|
||||
|
||||
def _edit_dialog(self, s):
|
||||
self._open_form(f"Изменить: {s.name}", self._form(s), s.id)
|
||||
|
||||
def _open_form(self, title, f, server_id):
|
||||
def save(e):
|
||||
fields = dict(
|
||||
name=f["name"].value, host=f["host"].value,
|
||||
port=int(f["port"].value or 22), username=f["username"].value,
|
||||
auth_type=f["auth_type"].value,
|
||||
password=f["password"].value or None,
|
||||
key_path=f["key_path"].value or None,
|
||||
sudo_password=f["sudo_password"].value or None,
|
||||
tags=f["tags"].value,
|
||||
)
|
||||
if server_id:
|
||||
servers_core.update_server(server_id, **fields)
|
||||
else:
|
||||
servers_core.add_server(**fields)
|
||||
self._page.pop_dialog()
|
||||
self.reload()
|
||||
snack(self._page, "Сохранено")
|
||||
|
||||
def test(e):
|
||||
async def _t():
|
||||
fields = dict(
|
||||
id=0, name=f["name"].value or "test", host=f["host"].value,
|
||||
port=int(f["port"].value or 22), username=f["username"].value,
|
||||
auth_type=f["auth_type"].value,
|
||||
password=f["password"].value or None,
|
||||
key_path=f["key_path"].value or None,
|
||||
sudo_password=f["sudo_password"].value or None,
|
||||
tags="", color="blue", read_only=False,
|
||||
)
|
||||
from app.db.models import Server
|
||||
info = await check_server(Server(**fields))
|
||||
if info.get("online"):
|
||||
snack(self._page, f"✅ {info['os']} | {info['uptime']}")
|
||||
else:
|
||||
snack(self._page, f"❌ {info.get('error')}", error=True)
|
||||
self._page.run_task(_t)
|
||||
|
||||
dlg = ft.AlertDialog(
|
||||
modal=True,
|
||||
title=ft.Text(title),
|
||||
content=ft.Column(list(f.values()), tight=True,
|
||||
scroll=ft.ScrollMode.AUTO, height=480),
|
||||
actions=[
|
||||
ft.TextButton("Проверить подключение", on_click=test),
|
||||
ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)),
|
||||
ft.FilledButton("Сохранить", on_click=save),
|
||||
],
|
||||
)
|
||||
self._page.show_dialog(dlg)
|
||||
|
||||
def _close(self, dlg):
|
||||
self._page.pop_dialog()
|
||||
|
||||
def _delete(self, s):
|
||||
confirm_dialog(
|
||||
self._page, "Удалить сервер?",
|
||||
f"Сервер «{s.name}» ({s.host}) будет удалён из приложения. На самом сервере ничего не изменится.",
|
||||
lambda: (servers_core.delete_server(s.id), self.reload()),
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Настройки: Telegram, Gitea."""
|
||||
import flet as ft
|
||||
|
||||
from app.core import notify
|
||||
from app.ui.components import snack
|
||||
|
||||
|
||||
class SettingsPage(ft.Column):
|
||||
def __init__(self, page: ft.Page, go):
|
||||
super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12)
|
||||
self._page = page
|
||||
|
||||
cfg = notify.get_config()
|
||||
self.tg_token = ft.TextField(label="Bot token", value=cfg["token"],
|
||||
password=True, width=400)
|
||||
self.tg_chat = ft.TextField(label="Chat ID", value=cfg["chat_id"], width=400)
|
||||
self.tg_enabled = ft.Checkbox(label="Уведомления включены", value=cfg["enabled"])
|
||||
self.tg_alert = ft.Checkbox(label="Алерты мониторинга", value=cfg["on_alert"])
|
||||
self.tg_task = ft.Checkbox(label="Завершение задач", value=cfg["on_task_done"])
|
||||
self.tg_status = ft.Text("", size=12)
|
||||
|
||||
self.controls = [
|
||||
ft.Text("Настройки", size=28, weight=ft.FontWeight.BOLD),
|
||||
ft.Card(content=ft.Container(padding=16, content=ft.Column([
|
||||
ft.Text("Интерфейс", weight=ft.FontWeight.BOLD, size=16),
|
||||
ft.Row([
|
||||
ft.Text("Тема:", size=13),
|
||||
ft.FilledButton(
|
||||
"Сменить тему (тёмная/светлая)",
|
||||
icon=ft.Icons.BRIGHTNESS_6,
|
||||
on_click=self._toggle_theme,
|
||||
),
|
||||
]),
|
||||
], spacing=10))),
|
||||
ft.Card(content=ft.Container(padding=16, content=ft.Column([
|
||||
ft.Text("Telegram-уведомления", weight=ft.FontWeight.BOLD, size=16),
|
||||
ft.Text("Создайте бота через @BotFather, узнайте chat_id через @userinfobot",
|
||||
size=12, color=ft.Colors.GREY_500),
|
||||
self.tg_token,
|
||||
self.tg_chat,
|
||||
self.tg_enabled,
|
||||
self.tg_alert,
|
||||
self.tg_task,
|
||||
ft.Row([
|
||||
ft.FilledButton("Сохранить", icon=ft.Icons.SAVE, on_click=self._save),
|
||||
ft.OutlinedButton("Тест", icon=ft.Icons.SEND, on_click=self._test),
|
||||
]),
|
||||
self.tg_status,
|
||||
], spacing=10))),
|
||||
]
|
||||
|
||||
def _toggle_theme(self, e):
|
||||
from app.db.database import get_session
|
||||
from app.db.models import Setting
|
||||
new = "light" if self._page.theme_mode == ft.ThemeMode.DARK else "dark"
|
||||
self._page.theme_mode = ft.ThemeMode.LIGHT if new == "light" else ft.ThemeMode.DARK
|
||||
with get_session() as s:
|
||||
row = s.get(Setting, "theme") or Setting(key="theme")
|
||||
row.value = new
|
||||
s.add(row)
|
||||
s.commit()
|
||||
self._page.update()
|
||||
|
||||
def _save(self, e):
|
||||
notify.save_config(self.tg_token.value, self.tg_chat.value,
|
||||
self.tg_enabled.value, self.tg_alert.value, self.tg_task.value)
|
||||
snack(self._page, "Настройки сохранены")
|
||||
|
||||
def _test(self, e):
|
||||
self._save(e)
|
||||
async def _t():
|
||||
ok, msg = await notify.test()
|
||||
self.tg_status.value = ("✅ " if ok else "❌ ") + msg
|
||||
self.tg_status.color = ft.Colors.GREEN_300 if ok else ft.Colors.RED_300
|
||||
self._page.update()
|
||||
self._page.run_task(_t)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Журнал задач."""
|
||||
import flet as ft
|
||||
|
||||
from app.core.tasks import tasks
|
||||
from app.core import servers as servers_core
|
||||
|
||||
|
||||
class TasksPage(ft.Column):
|
||||
def __init__(self, page: ft.Page, go):
|
||||
super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=8)
|
||||
self._page = page
|
||||
self.col = ft.Column(spacing=6)
|
||||
self.controls = [
|
||||
ft.Row([
|
||||
ft.Text("Журнал задач", size=28, weight=ft.FontWeight.BOLD),
|
||||
ft.IconButton(ft.Icons.REFRESH, on_click=lambda e: self._load()),
|
||||
]),
|
||||
self.col,
|
||||
]
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
self.col.controls = []
|
||||
servers = {s.id: s.name for s in servers_core.list_servers()}
|
||||
for t in tasks.list_recent(50):
|
||||
color = {"success": ft.Colors.GREEN_400, "error": ft.Colors.RED_400,
|
||||
"running": ft.Colors.BLUE_300, "cancelled": ft.Colors.ORANGE_300,
|
||||
}.get(t.status, ft.Colors.GREY_500)
|
||||
self.col.controls.append(
|
||||
ft.ExpansionTile(
|
||||
title=ft.Text(f"{t.title} — {servers.get(t.server_id, '?')}", size=14),
|
||||
subtitle=ft.Text(f"{t.created_at:%Y-%m-%d %H:%M} • {t.kind}",
|
||||
size=11),
|
||||
leading=ft.Icon(ft.Icons.CIRCLE, color=color, size=14),
|
||||
controls=[
|
||||
ft.Container(
|
||||
padding=10,
|
||||
content=ft.Text(t.log[-5000:] or "(нет лога)",
|
||||
size=11, font_family="Consolas",
|
||||
selectable=True),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
if not self.col.controls:
|
||||
self.col.controls.append(ft.Text("задач пока не было", color=ft.Colors.GREY_500))
|
||||
self._page.update()
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Мастер первичной настройки сервера: чекбоксы → плейбук."""
|
||||
import flet as ft
|
||||
|
||||
from app.core.ssh import SSHClient
|
||||
from app.core.tasks import tasks
|
||||
from app.core import playbooks as pb
|
||||
from app.db.models import Server
|
||||
from app.ui.components import append_log, log_view, snack
|
||||
|
||||
# (id, название, описание, шаги)
|
||||
WIZARD_STEPS = [
|
||||
("hostname", "Изменить hostname", "Задать имя сервера",
|
||||
lambda v: [{"name": "Hostname", "shell": f"hostnamectl set-hostname {v['hostname']}"}]),
|
||||
("timezone", "Часовой пояс", "timedatectl set-timezone (по умолчанию Asia/Ulaanbaatar)",
|
||||
lambda v: [{"name": "Timezone", "shell": f"timedatectl set-timezone {v['timezone']}"}]),
|
||||
("ntp", "Синхронизация времени", "Включить NTP",
|
||||
lambda v: [{"name": "NTP", "shell": "timedatectl set-ntp true"}]),
|
||||
("update", "Обновить систему", "apt update + dist-upgrade",
|
||||
lambda v: [{"name": "Update", "apt": {"update": True, "upgrade": "dist"}}]),
|
||||
("user", "Создать пользователя", "Новый пользователь с sudo",
|
||||
lambda v: [
|
||||
{"name": "Создать пользователя",
|
||||
"shell": f"useradd -m -s /bin/bash {v['new_user']} 2>/dev/null || true"},
|
||||
{"name": "Пароль",
|
||||
"shell": f"echo '{v['new_user']}:{v['new_pass']}' | chpasswd"},
|
||||
{"name": "Sudo",
|
||||
"shell": f"usermod -aG sudo {v['new_user']}"},
|
||||
]),
|
||||
("utils", "Базовые утилиты", "curl, wget, htop, git, mc, net-tools",
|
||||
lambda v: [{"name": "Утилиты",
|
||||
"apt": {"install": ["curl", "wget", "htop", "git", "mc", "net-tools"]}}]),
|
||||
("docker", "Docker", "docker.io + docker-compose + автозапуск",
|
||||
lambda v: [
|
||||
{"name": "Установить Docker",
|
||||
"apt": {"install": ["docker.io", "docker-compose"]}},
|
||||
{"name": "Запустить Docker", "systemd": {"unit": "docker", "action": "enable --now"}},
|
||||
]),
|
||||
("portainer", "Portainer (порт 9000)", "Веб-интерфейс для Docker",
|
||||
lambda v: [
|
||||
{"name": "Portainer volume", "shell": "docker volume create portainer_data || true"},
|
||||
{"name": "Portainer run",
|
||||
"shell": "docker rm -f portainer 2>/dev/null; "
|
||||
"docker run -d -p 9000:9000 --name portainer --restart=always "
|
||||
"-v /var/run/docker.sock:/var/run/docker.sock "
|
||||
"-v portainer_data:/data portainer/portainer-ce:lts"},
|
||||
]),
|
||||
("ufw", "Firewall (ufw)", "Установить, разрешить SSH, включить",
|
||||
lambda v: [
|
||||
{"name": "Установить ufw", "apt": {"install": ["ufw"]}},
|
||||
{"name": "Пресет", "ufw": {"preset": "Только SSH"}},
|
||||
{"name": "Включить ufw", "shell": "yes | ufw enable"},
|
||||
]),
|
||||
("fail2ban", "Fail2ban", "Защита SSH от перебора",
|
||||
lambda v: [
|
||||
{"name": "Установить fail2ban", "apt": {"install": ["fail2ban"]}},
|
||||
{"name": "Запустить", "systemd": {"unit": "fail2ban", "action": "enable --now"}},
|
||||
]),
|
||||
("unattended", "Автообновления безопасности", "unattended-upgrades",
|
||||
lambda v: [
|
||||
{"name": "Установить", "apt": {"install": ["unattended-upgrades"]}},
|
||||
{"name": "Включить",
|
||||
"shell": "echo 'unattended-upgrades unattended-upgrades/enable_auto_updates boolean true' "
|
||||
"| debconf-set-selections && dpkg-reconfigure -f noninteractive unattended-upgrades"},
|
||||
]),
|
||||
("agent", "Агент мониторинга", "dm-agent для real-time метрик (HTTP)",
|
||||
None), # обрабатывается отдельно
|
||||
]
|
||||
|
||||
RECOMMENDED = {"update", "utils", "ufw", "fail2ban", "ntp"}
|
||||
|
||||
|
||||
class WizardPage(ft.Column):
|
||||
def __init__(self, page: ft.Page, go):
|
||||
super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12)
|
||||
self._page = page
|
||||
|
||||
from app.core import servers as servers_core
|
||||
self.srv_dd = ft.Dropdown(label="Сервер", width=300, options=[
|
||||
ft.dropdown.Option(str(s.id), s.name) for s in servers_core.list_servers()
|
||||
])
|
||||
|
||||
self.fields = {
|
||||
"hostname": ft.TextField(label="hostname", width=250, visible=False, dense=True),
|
||||
"timezone": ft.TextField(label="часовой пояс", value="Asia/Ulaanbaatar",
|
||||
width=250, visible=False, dense=True),
|
||||
"new_user": ft.TextField(label="имя пользователя", width=200, visible=False, dense=True),
|
||||
"new_pass": ft.TextField(label="пароль", password=True, width=200,
|
||||
visible=False, dense=True),
|
||||
}
|
||||
|
||||
self.checks = {}
|
||||
step_rows = []
|
||||
for sid, name, desc, _ in WIZARD_STEPS:
|
||||
cb = ft.Checkbox(label=name, value=sid in RECOMMENDED,
|
||||
on_change=lambda e, sid=sid: self._toggle_fields())
|
||||
self.checks[sid] = cb
|
||||
extra = []
|
||||
if sid == "hostname":
|
||||
extra = [self.fields["hostname"]]
|
||||
elif sid == "timezone":
|
||||
extra = [self.fields["timezone"]]
|
||||
elif sid == "user":
|
||||
extra = [ft.Row([self.fields["new_user"], self.fields["new_pass"]])]
|
||||
step_rows.append(ft.Column([
|
||||
ft.Row([cb, ft.Text(desc, size=12, color=ft.Colors.GREY_500)]),
|
||||
ft.Container(content=ft.Row(extra), padding=ft.Padding.only(left=40))
|
||||
if extra else ft.Container(),
|
||||
], spacing=2))
|
||||
|
||||
self.log = log_view()
|
||||
self.log_container = ft.Container(
|
||||
height=250, border=ft.Border.all(1, ft.Colors.GREY_800),
|
||||
border_radius=8, padding=8, content=self.log, visible=False)
|
||||
|
||||
self.controls = [
|
||||
ft.Text("Мастер первичной настройки", size=28, weight=ft.FontWeight.BOLD),
|
||||
ft.Text("Отметьте что настроить, заполните поля, нажмите «Применить».",
|
||||
size=13, color=ft.Colors.GREY_500),
|
||||
self.srv_dd,
|
||||
ft.Card(content=ft.Container(padding=14, content=ft.Column(step_rows, spacing=6))),
|
||||
ft.Row([
|
||||
ft.FilledButton("Применить выбранное", icon=ft.Icons.PLAY_ARROW,
|
||||
on_click=self._apply),
|
||||
ft.TextButton("Выбрать рекомендуемое", on_click=lambda e: self._preset()),
|
||||
]),
|
||||
self.log_container,
|
||||
]
|
||||
|
||||
def _toggle_fields(self):
|
||||
self.fields["hostname"].visible = self.checks["hostname"].value
|
||||
self.fields["timezone"].visible = self.checks["timezone"].value
|
||||
user_on = self.checks["user"].value
|
||||
self.fields["new_user"].visible = user_on
|
||||
self.fields["new_pass"].visible = user_on
|
||||
self._page.update()
|
||||
|
||||
def _preset(self):
|
||||
for sid, cb in self.checks.items():
|
||||
cb.value = sid in RECOMMENDED
|
||||
self._toggle_fields()
|
||||
|
||||
def _apply(self, e):
|
||||
if not self.srv_dd.value:
|
||||
snack(self._page, "Выберите сервер", error=True)
|
||||
return
|
||||
from app.core import servers as servers_core
|
||||
srv = servers_core.get_server(int(self.srv_dd.value))
|
||||
|
||||
values = {
|
||||
"hostname": self.fields["hostname"].value or "server",
|
||||
"timezone": self.fields["timezone"].value or "Asia/Ulaanbaatar",
|
||||
"new_user": self.fields["new_user"].value or "deploy",
|
||||
"new_pass": self.fields["new_pass"].value or "changeme",
|
||||
"ssh_port": str(srv.port),
|
||||
}
|
||||
|
||||
steps = []
|
||||
agent_selected = False
|
||||
for sid, _, _, builder in WIZARD_STEPS:
|
||||
if not self.checks[sid].value:
|
||||
continue
|
||||
if sid == "agent":
|
||||
agent_selected = True
|
||||
continue
|
||||
steps.extend(builder(values))
|
||||
|
||||
if not steps and not agent_selected:
|
||||
snack(self._page, "Ничего не выбрано", error=True)
|
||||
return
|
||||
|
||||
self.log_container.visible = True
|
||||
self._page.update()
|
||||
|
||||
playbook = {"name": "Мастер настройки", "vars": values, "steps": steps}
|
||||
|
||||
async def job(log):
|
||||
async with SSHClient(srv) as c:
|
||||
if steps:
|
||||
await pb.run_playbook(c, srv, playbook, log)
|
||||
if agent_selected:
|
||||
from app.core import agent as agent_core
|
||||
import socket
|
||||
# определить IP приложения, доступный серверу
|
||||
app_host = socket.gethostbyname(socket.gethostname())
|
||||
log(f"установка агента (app_host={app_host})...")
|
||||
await agent_core.install_agent(srv, app_host, log)
|
||||
|
||||
tasks.start("wizard", "мастер настройки", srv.id, job,
|
||||
on_log=lambda l: (append_log(self.log, l), self._page.update()))
|
||||
snack(self._page, "Мастер запущен — следите за логом")
|
||||
Reference in New Issue
Block a user