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
View File
+65
View File
@@ -0,0 +1,65 @@
"""Общие UI-хелперы (Flet ≥ 0.80: show_dialog/pop_dialog, SnackBar через show_dialog)."""
import asyncio
import flet as ft
def confirm_dialog(page: ft.Page, title: str, text: str, on_confirm, danger: bool = True):
"""Модальный диалог подтверждения."""
def close(e=None):
page.pop_dialog()
def yes(e):
page.pop_dialog()
on_confirm()
dlg = ft.AlertDialog(
modal=True,
title=ft.Text(title),
content=ft.Text(text),
actions=[
ft.TextButton("Отмена", on_click=close),
ft.FilledButton(
"Подтвердить", on_click=yes,
style=ft.ButtonStyle(bgcolor=ft.Colors.RED_700 if danger else None),
),
],
)
page.show_dialog(dlg)
def snack(page: ft.Page, text: str, error: bool = False):
page.show_dialog(ft.SnackBar(
ft.Text(text),
bgcolor=ft.Colors.RED_700 if error else ft.Colors.GREEN_700,
open=True,
))
def log_view(initial: str = "") -> ft.ListView:
"""Скроллящаяся панель лога."""
return ft.ListView(expand=True, spacing=2, auto_scroll=True)
def append_log(lv: ft.ListView, line: str):
lv.controls.append(
ft.Text(line, size=12, font_family="Consolas", selectable=True)
)
try:
lv.update()
except Exception:
pass
def open_dialog(page: ft.Page, dlg: ft.AlertDialog):
page.show_dialog(dlg)
def close_dialog(page: ft.Page):
page.pop_dialog()
def fmt_mb(v: float) -> str:
if v >= 1024:
return f"{v/1024:.1f} ГБ"
return f"{v:.0f} МБ"
View File
+108
View File
@@ -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)
+87
View File
@@ -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, "Бэкап запущен")
+128
View File
@@ -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()
+84
View File
@@ -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),
),
]))
+168
View File
@@ -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']} запущен")
+131
View File
@@ -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()
+131
View File
@@ -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()
+106
View File
@@ -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)
+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)
+68
View File
@@ -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)
+161
View File
@@ -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)
+64
View File
@@ -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,
]
+158
View File
@@ -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()),
)
+76
View File
@@ -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)
+47
View File
@@ -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()
+190
View File
@@ -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, "Мастер запущен — следите за логом")
View File
+172
View File
@@ -0,0 +1,172 @@
"""Вкладка Cron: редактор crontab."""
import flet as ft
from app.core import cron_svc as cron
from app.core.ssh import SSHClient
from app.db.models import Server
from app.ui.components import confirm_dialog, snack
class CronTab(ft.Column):
def __init__(self, page: ft.Page, srv: Server):
super().__init__(expand=True, spacing=8, scroll=ft.ScrollMode.AUTO)
self._page = page
self.srv = srv
self._lines: list[cron.CronLine] = []
self._user = "" # пусто = текущий sudo-пользовательский контекст
self.entries_col = ft.Column(spacing=4)
self.controls = [
ft.Container(height=8),
ft.Row([
ft.FilledButton("Добавить задачу", icon=ft.Icons.ADD,
on_click=self._add_dialog),
ft.OutlinedButton("Обновить", icon=ft.Icons.REFRESH,
on_click=lambda e: self._page.run_task(self._load)),
ft.OutlinedButton("Откатить к бэкапу", icon=ft.Icons.HISTORY,
on_click=self._restore_dialog),
]),
self.entries_col,
]
self._page.run_task(self._load)
async def _load(self):
try:
async with SSHClient(self.srv) as c:
content = await cron.get_crontab(c)
self._lines = cron.parse_crontab(content)
except Exception as e:
snack(self._page, f"cron: {e}", error=True)
self._render()
def _render(self):
self.entries_col.controls = []
for i, l in enumerate(self._lines):
if not l.is_entry:
if l.raw.strip() and not l.raw.strip().startswith(("#", "SHELL", "PATH", "MAILTO")):
self.entries_col.controls.append(
ft.Text(l.raw, size=11, color=ft.Colors.GREY_600,
font_family="Consolas"))
continue
self.entries_col.controls.append(
ft.Card(content=ft.Container(padding=8, content=ft.Row([
ft.Checkbox(value=l.enabled,
on_change=lambda e, idx=i: self._toggle(idx)),
ft.Column([
ft.Text(l.schedule, size=13, weight=ft.FontWeight.BOLD,
font_family="Consolas"),
ft.Text(l.command, size=12, font_family="Consolas"),
ft.Text(cron.humanize(l.schedule), size=10,
color=ft.Colors.GREY_500),
], spacing=1, expand=True),
ft.IconButton(ft.Icons.EDIT, icon_size=16,
on_click=lambda e, idx=i: self._edit_dialog(idx)),
ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=16,
on_click=lambda e, idx=i: self._delete(idx)),
])))
)
if not any(l.is_entry for l in self._lines):
self.entries_col.controls.append(
ft.Text("crontab пуст", color=ft.Colors.GREY_500))
self._page.update()
async def _save(self):
content = cron.render(self._lines)
async with SSHClient(self.srv) as c:
await cron.set_crontab(c, content, self.srv.id)
snack(self._page, "crontab сохранён (бэкап записан)")
def _toggle(self, idx):
self._lines[idx].enabled = not self._lines[idx].enabled
self._page.run_task(self._save)
def _delete(self, idx):
def do():
del self._lines[idx]
self._page.run_task(self._save_and_reload)
confirm_dialog(self._page, "Удалить задачу?", self._lines[idx].command[:80], do)
async def _save_and_reload(self):
await self._save()
await self._load()
def _edit_dialog(self, idx):
l = self._lines[idx]
sched_tf = ft.TextField(label="Расписание (мин час день мес день_нед)",
value=l.schedule, width=400)
cmd_tf = ft.TextField(label="Команда", value=l.command, width=400)
hint = ft.Text(cron.humanize(l.schedule), size=11, color=ft.Colors.GREY_500)
def upd_hint(e):
hint.value = cron.humanize(sched_tf.value)
dlg.content.update()
sched_tf.on_change = upd_hint
def save(e):
if "неверное" in hint.value:
snack(self._page, "Неверное cron-выражение", error=True)
return
self._lines[idx] = cron.CronLine("", l.enabled, sched_tf.value.strip(),
cmd_tf.value.strip())
self._page.pop_dialog()
self._page.run_task(self._save_and_reload)
dlg = ft.AlertDialog(
modal=True, title=ft.Text("Редактировать задачу"),
content=ft.Column([sched_tf, cmd_tf, hint], tight=True),
actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)),
ft.FilledButton("Сохранить", on_click=save)],
)
self._page.show_dialog(dlg)
def _add_dialog(self, e):
sched_tf = ft.TextField(label="Расписание", hint_text="0 3 * * *", width=400)
cmd_tf = ft.TextField(label="Команда", width=400)
hint = ft.Text("", size=11, color=ft.Colors.GREY_500)
def upd_hint(ev):
hint.value = cron.humanize(sched_tf.value)
dlg.content.update()
sched_tf.on_change = upd_hint
def save(e):
if not sched_tf.value or not cmd_tf.value:
return
self._lines.append(cron.CronLine("", True, sched_tf.value.strip(),
cmd_tf.value.strip()))
self._page.pop_dialog()
self._page.run_task(self._save_and_reload)
dlg = ft.AlertDialog(
modal=True, title=ft.Text("Новая cron-задача"),
content=ft.Column([sched_tf, cmd_tf, hint], tight=True),
actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)),
ft.FilledButton("Добавить", on_click=save)],
)
self._page.show_dialog(dlg)
def _restore_dialog(self, e):
backups = cron.backups(self.srv.id, "current")
if not backups:
snack(self._page, "Бэкапов пока нет", error=True)
return
def restore(b):
self._page.pop_dialog()
async def _r():
async with SSHClient(self.srv) as c:
await cron.set_crontab(c, b.content, self.srv.id)
await self._load()
self._page.run_task(_r)
dlg = ft.AlertDialog(
modal=True, title=ft.Text("Откатить к бэкапу"),
content=ft.Column([
ft.TextButton(f"{b.created_at:%Y-%m-%d %H:%M} ({len(b.content)} символов)",
on_click=lambda e, b=b: restore(b))
for b in backups
], tight=True),
actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg))],
)
self._page.show_dialog(dlg)
def _close(self, dlg):
self._page.pop_dialog()
+296
View File
@@ -0,0 +1,296 @@
"""Вкладка Docker: контейнеры, образы, тома, сети, compose."""
import flet as ft
from app.core import docker_svc as d
from app.core.ssh import SSHClient
from app.core.tasks import tasks
from app.db.models import Server
from app.ui.components import append_log, confirm_dialog, log_view, snack
class DockerTab(ft.Column):
def __init__(self, page: ft.Page, srv: Server):
super().__init__(expand=True, spacing=8)
self._page = page
self.srv = srv
self.inner_tabs = ft.Tabs(
selected_index=0, length=5, expand=True,
content=ft.Column(
expand=True,
controls=[
ft.TabBar(tabs=[
ft.Tab(label="Контейнеры"),
ft.Tab(label="Образы"),
ft.Tab(label="Тома"),
ft.Tab(label="Сети"),
ft.Tab(label="Система"),
]),
ft.TabBarView(
expand=True,
controls=[
self._containers_view(),
self._images_view(),
self._volumes_view(),
self._networks_view(),
self._system_view(),
],
),
],
),
)
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.controls = [ft.Container(height=8), self.inner_tabs, self.log_container]
self._page.run_task(self._check)
async def _check(self):
try:
async with SSHClient(self.srv) as c:
if not await d.available(c):
snack(self._page, "Docker не установлен на сервере", error=True)
return
await self._load_containers()
await self._load_images()
await self._load_volumes()
await self._load_networks()
await self._load_system()
except Exception as e:
snack(self._page, f"Docker: {e}", error=True)
# ---------- контейнеры ----------
def _containers_view(self):
self.ct_table = ft.DataTable(columns=[
ft.DataColumn(ft.Text("Имя")), ft.DataColumn(ft.Text("Образ")),
ft.DataColumn(ft.Text("Статус")), ft.DataColumn(ft.Text("Порты")),
ft.DataColumn(ft.Text("Действия")),
], rows=[])
self.stats_row = ft.Row(spacing=16, wrap=True)
return ft.Column([
ft.Row([
ft.OutlinedButton("Обновить", icon=ft.Icons.REFRESH,
on_click=lambda e: self._page.run_task(self._load_containers)),
]),
ft.Container(expand=True, content=ft.Column(
[self.ct_table, ft.Divider(), ft.Text("Нагрузка:", weight=ft.FontWeight.BOLD), self.stats_row],
scroll=ft.ScrollMode.AUTO)),
], expand=True)
async def _load_containers(self):
async with SSHClient(self.srv) as c:
containers = await d.list_containers(c)
try:
stats = {s["Name"]: s for s in await d.container_stats(c)}
except Exception:
stats = {}
self.ct_table.rows = []
for ct in containers:
running = "Up" in ct.get("Status", "")
cid = ct["ID"]
name = ct["Names"]
self.ct_table.rows.append(ft.DataRow(cells=[
ft.DataCell(ft.Text(name, weight=ft.FontWeight.BOLD)),
ft.DataCell(ft.Text(ct["Image"], size=12)),
ft.DataCell(ft.Text(ct["Status"], size=12,
color=ft.Colors.GREEN_300 if running else ft.Colors.GREY_500)),
ft.DataCell(ft.Text(ct.get("Ports", "") or "—", size=11)),
ft.DataCell(ft.Row([
ft.IconButton(ft.Icons.PLAY_ARROW if not running else ft.Icons.STOP,
icon_size=18, tooltip="start" if not running else "stop",
on_click=lambda e, c=cid, a="start" if not running else "stop": self._ct_action(c, a)),
ft.IconButton(ft.Icons.RESTART_ALT, icon_size=18, tooltip="restart",
on_click=lambda e, c=cid: self._ct_action(c, "restart")),
ft.IconButton(ft.Icons.ARTICLE, icon_size=18, tooltip="логи",
on_click=lambda e, c=cid, n=name: self._show_logs(c, n)),
ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=18, tooltip="удалить",
on_click=lambda e, c=cid, n=name: self._remove_ct(c, n)),
], spacing=0)),
]))
self.stats_row.controls = [
ft.Chip(label=ft.Text(f"{n}: CPU {s.get('CPUPerc','?')} MEM {s.get('MemPerc','?')}"))
for n, s in stats.items()
] or [ft.Text("нет запущенных", size=12, color=ft.Colors.GREY_500)]
self._page.update()
def _ct_action(self, cid, action):
async def _do():
async with SSHClient(self.srv) as c:
await d.container_action(c, cid, action)
await self._load_containers()
self._page.run_task(_do)
def _show_logs(self, cid, name):
self.log_container.visible = True
append_log(self.log, f"=== логи {name} ===")
self._page.update()
async def _do():
async with SSHClient(self.srv) as c:
out = await d.container_logs(c, cid)
for line in out.splitlines()[-100:]:
append_log(self.log, line)
self._page.update()
self._page.run_task(_do)
def _remove_ct(self, cid, name):
def do():
async def _d():
async with SSHClient(self.srv) as c:
await d.container_action(c, cid, "rm")
await self._load_containers()
self._page.run_task(_d)
confirm_dialog(self._page, f"Удалить контейнер {name}?", "Действие необратимо.", do)
# ---------- образы ----------
def _images_view(self):
self.img_table = ft.DataTable(columns=[
ft.DataColumn(ft.Text("Репозиторий")), ft.DataColumn(ft.Text("Тег")),
ft.DataColumn(ft.Text("Размер")), ft.DataColumn(ft.Text("Создан")),
ft.DataColumn(ft.Text("")),
], rows=[])
pull_tf = ft.TextField(hint_text="образ для pull (напр. nginx:alpine)", width=300, dense=True)
return ft.Column([
ft.Row([
pull_tf,
ft.FilledButton("Pull", icon=ft.Icons.DOWNLOAD,
on_click=lambda e: self._pull(pull_tf.value)),
ft.OutlinedButton("Обновить", icon=ft.Icons.REFRESH,
on_click=lambda e: self._page.run_task(self._load_images)),
]),
ft.Container(expand=True, content=ft.Column([self.img_table], scroll=ft.ScrollMode.AUTO)),
], expand=True)
async def _load_images(self):
async with SSHClient(self.srv) as c:
images = await d.list_images(c)
self.img_table.rows = [
ft.DataRow(cells=[
ft.DataCell(ft.Text(i["Repository"])),
ft.DataCell(ft.Text(i["Tag"])),
ft.DataCell(ft.Text(i["Size"])),
ft.DataCell(ft.Text(i.get("CreatedSince", ""), size=11)),
ft.DataCell(ft.IconButton(
ft.Icons.DELETE_OUTLINE, icon_size=18,
on_click=lambda e, ref=f"{i['Repository']}:{i['Tag']}": self._rmi(ref))),
]) for i in images
]
self._page.update()
def _pull(self, image):
if not image:
return
self.log_container.visible = True
async def job(log):
async with SSHClient(self.srv) as c:
stream = await d.pull(c, image)
async for line in stream:
log(line)
tasks.start("docker", f"docker pull {image}", self.srv.id, job,
on_log=lambda l: (append_log(self.log, l), self._page.update()),
on_done=lambda r: self._page.run_task(self._load_images))
def _rmi(self, ref):
def do():
async def _d():
async with SSHClient(self.srv) as c:
await d.remove_image(c, ref)
await self._load_images()
self._page.run_task(_d)
confirm_dialog(self._page, f"Удалить образ {ref}?", "", do)
# ---------- тома ----------
def _volumes_view(self):
self.vol_table = ft.DataTable(columns=[
ft.DataColumn(ft.Text("Имя")), ft.DataColumn(ft.Text("Драйвер")),
ft.DataColumn(ft.Text("")),
], rows=[])
return ft.Column([
ft.OutlinedButton("Обновить", icon=ft.Icons.REFRESH,
on_click=lambda e: self._page.run_task(self._load_volumes)),
ft.Container(expand=True, content=ft.Column([self.vol_table], scroll=ft.ScrollMode.AUTO)),
], expand=True)
async def _load_volumes(self):
async with SSHClient(self.srv) as c:
vols = await d.list_volumes(c)
self.vol_table.rows = [
ft.DataRow(cells=[
ft.DataCell(ft.Text(v["Name"])),
ft.DataCell(ft.Text(v.get("Driver", ""))),
ft.DataCell(ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=18,
on_click=lambda e, n=v["Name"]: self._rm_vol(n))),
]) for v in vols
]
self._page.update()
def _rm_vol(self, name):
def do():
async def _d():
async with SSHClient(self.srv) as c:
await d.remove_volume(c, name)
await self._load_volumes()
self._page.run_task(_d)
confirm_dialog(self._page, f"Удалить том {name}?", "Данные будут потеряны.", do)
# ---------- сети ----------
def _networks_view(self):
self.net_table = ft.DataTable(columns=[
ft.DataColumn(ft.Text("Имя")), ft.DataColumn(ft.Text("Драйвер")),
ft.DataColumn(ft.Text("Scope")),
], rows=[])
return ft.Column([
ft.OutlinedButton("Обновить", icon=ft.Icons.REFRESH,
on_click=lambda e: self._page.run_task(self._load_networks)),
ft.Container(expand=True, content=ft.Column([self.net_table], scroll=ft.ScrollMode.AUTO)),
], expand=True)
async def _load_networks(self):
async with SSHClient(self.srv) as c:
nets = await d.list_networks(c)
self.net_table.rows = [
ft.DataRow(cells=[
ft.DataCell(ft.Text(n["Name"])),
ft.DataCell(ft.Text(n.get("Driver", ""))),
ft.DataCell(ft.Text(n.get("Scope", ""))),
]) for n in nets
]
self._page.update()
# ---------- система ----------
def _system_view(self):
self.sys_text = ft.Text("…", selectable=True, size=12, font_family="Consolas")
return ft.Column([
ft.Row([
ft.OutlinedButton("Обновить df", icon=ft.Icons.REFRESH,
on_click=lambda e: self._page.run_task(self._load_system)),
ft.FilledButton("System prune", icon=ft.Icons.CLEANING_SERVICES,
style=ft.ButtonStyle(bgcolor=ft.Colors.ORANGE_800),
on_click=self._prune),
]),
ft.Container(padding=8, content=self.sys_text),
])
async def _load_system(self):
async with SSHClient(self.srv) as c:
ver = await d.server_version(c)
df = await d.system_df(c)
self.sys_text.value = f"Docker {ver}\n\n{df}"
self._page.update()
def _prune(self, e):
def do():
self.log_container.visible = True
async def job(log):
async with SSHClient(self.srv) as c:
log(await d.system_prune(c))
tasks.start("docker", "docker system prune", self.srv.id, job,
on_log=lambda l: (append_log(self.log, l), self._page.update()),
on_done=lambda r: self._page.run_task(self._load_system))
confirm_dialog(self._page, "Docker system prune?",
"Будут удалены остановленные контейнеры, неиспользуемые сети, "
"висячие образы и кэш сборки.", do)
+229
View File
@@ -0,0 +1,229 @@
"""Вкладка Файлы: SFTP-браузер с upload/download/редактором."""
import os
from pathlib import PurePosixPath
import flet as ft
from app.core import files as fcore
from app.core.ssh import SSHClient
from app.db.models import Server
from app.ui.components import confirm_dialog, snack
class FilesTab(ft.Column):
def __init__(self, page: ft.Page, srv: Server):
super().__init__(expand=True, spacing=8)
self._page = page
self.srv = srv
self._path = f"/home/{srv.username}" if srv.username != "root" else "/root"
self._client = None
self.path_text = ft.Text(self._path, size=13, weight=ft.FontWeight.BOLD)
self.list_col = ft.Column(scroll=ft.ScrollMode.AUTO, expand=True, spacing=0)
self.progress = ft.ProgressBar(visible=False, height=4)
self.status = ft.Text("", size=12, color=ft.Colors.GREY_500)
self.controls = [
ft.Container(height=8),
ft.Row([
ft.IconButton(ft.Icons.ARROW_UPWARD, tooltip="Вверх",
on_click=self._up),
ft.IconButton(ft.Icons.HOME, tooltip="Домой", on_click=self._home),
ft.IconButton(ft.Icons.REFRESH, on_click=lambda e: self._reload()),
self.path_text,
ft.Container(expand=True),
ft.IconButton(ft.Icons.CREATE_NEW_FOLDER, tooltip="Новая папка",
on_click=self._mkdir_dialog),
ft.IconButton(ft.Icons.UPLOAD_FILE, tooltip="Загрузить файл",
on_click=self._upload_dialog),
]),
self.progress,
ft.Container(expand=True, border=ft.Border.all(1, ft.Colors.GREY_800),
border_radius=8, padding=4, content=self.list_col),
self.status,
]
# file picker для upload/download (async API flet ≥0.80)
self.picker = ft.FilePicker()
page.services.append(self.picker)
self._page.run_task(self._connect)
async def _connect(self):
try:
self._client = SSHClient(self.srv)
await self._client.connect()
await self._load()
except Exception as e:
snack(self._page, f"SFTP: {e}", error=True)
async def _load(self):
if not self._client:
return
try:
entries = await fcore.list_dir(self._client, self._path)
except PermissionError as e:
self.status.value = f"нет доступа: {e}"
self._page.update()
return
self.path_text.value = self._path
self.list_col.controls = []
for en in entries:
icon = ft.Icons.FOLDER if en.is_dir else ft.Icons.INSERT_DRIVE_FILE
size = "" if en.is_dir else self._fmt_size(en.size)
self.list_col.controls.append(
ft.ListTile(
leading=ft.Icon(icon, size=20,
color=ft.Colors.AMBER_300 if en.is_dir else None),
title=ft.Text(en.name, size=13),
subtitle=ft.Text(en.permissions, size=10,
color=ft.Colors.GREY_600),
trailing=ft.Row([
ft.Text(size, size=11, color=ft.Colors.GREY_500),
ft.IconButton(ft.Icons.DOWNLOAD, icon_size=16, tooltip="Скачать",
visible=not en.is_dir,
on_click=lambda e, p=en.path: self._download(p)),
ft.IconButton(ft.Icons.EDIT, icon_size=16, tooltip="Редактировать",
visible=not en.is_dir,
on_click=lambda e, p=en.path: self._edit(p)),
ft.IconButton(ft.Icons.DRIVE_FILE_RENAME_OUTLINE, icon_size=16,
tooltip="Переименовать",
on_click=lambda e, en=en: self._rename_dialog(en)),
ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=16, tooltip="Удалить",
on_click=lambda e, en=en: self._delete(en)),
], spacing=0, tight=True),
dense=True,
on_click=lambda e, en=en: self._enter(en),
)
)
self.status.value = f"{len(entries)} объектов"
self._page.update()
def _fmt_size(self, s):
for unit in ("Б", "КБ", "МБ", "ГБ"):
if s < 1024 or unit == "ГБ":
return f"{s:.0f} {unit}" if unit == "Б" else f"{s:.1f} {unit}"
s /= 1024
def _reload(self):
self._page.run_task(self._load)
def _enter(self, en):
if en.is_dir:
self._path = en.path
self._reload()
else:
self._edit(en.path)
def _up(self, e):
parent = str(PurePosixPath(self._path).parent)
self._path = parent or "/"
self._reload()
def _home(self, e):
self._path = f"/home/{self.srv.username}" if self.srv.username != "root" else "/root"
self._reload()
# ---------- действия ----------
def _delete(self, en):
def do():
async def _d():
await fcore.delete(self._client, en.path, en.is_dir)
await self._load()
self._page.run_task(_d)
confirm_dialog(self._page, f"Удалить {en.name}?",
f"{'Папка и всё содержимое' if en.is_dir else 'Файл'} будет удалён с сервера.", do)
def _rename_dialog(self, en):
tf = ft.TextField(label="Новое имя", value=en.name, autofocus=True)
def do(e):
async def _r():
new = str(PurePosixPath(self._path) / tf.value)
await fcore.rename(self._client, en.path, new)
self._page.pop_dialog()
await self._load()
self._page.run_task(_r)
dlg = ft.AlertDialog(modal=True, title=ft.Text("Переименовать"), content=tf,
actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)),
ft.FilledButton("Ок", on_click=do)])
self._page.show_dialog(dlg)
def _mkdir_dialog(self, e):
tf = ft.TextField(label="Имя папки", autofocus=True)
def do(e):
async def _m():
await fcore.mkdir(self._client, str(PurePosixPath(self._path) / tf.value))
self._page.pop_dialog()
await self._load()
self._page.run_task(_m)
dlg = ft.AlertDialog(modal=True, title=ft.Text("Новая папка"), content=tf,
actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)),
ft.FilledButton("Создать", on_click=do)])
self._page.show_dialog(dlg)
def _close(self, dlg):
self._page.pop_dialog()
def _download(self, remote_path):
async def _do():
local = await self.picker.save_file(
dialog_title="Сохранить как",
file_name=PurePosixPath(remote_path).name,
)
if not local:
return
self.progress.visible = True
self._page.update()
def prog(done, total):
self.progress.value = done / total if total else None
self.status.value = f"скачивание… {done//1024}/{total//1024} КБ"
self._page.update()
await fcore.download(self._client, remote_path, local, prog)
self.progress.visible = False
self.status.value = f"скачано: {local}"
self._page.update()
self._page.run_task(_do)
def _upload_dialog(self, e):
async def _do():
files = await self.picker.pick_files(dialog_title="Выберите файл для загрузки")
if not files:
return
f = files[0]
remote = str(PurePosixPath(self._path) / f.name)
self.progress.visible = True
self._page.update()
def prog(done, total):
self.progress.value = done / total if total else None
self.status.value = f"загрузка… {done//1024}/{total//1024} КБ"
self._page.update()
await fcore.upload(self._client, f.path, remote, prog)
self.progress.visible = False
await self._load()
self._page.run_task(_do)
def _edit(self, path):
tf = ft.TextField(multiline=True, min_lines=20, max_lines=20,
text_size=12, text_style=ft.TextStyle(font_family="Consolas"),
expand=True)
sudo_cb = ft.Checkbox(label="sudo (root-файл)", value=False)
async def _load_text():
try:
tf.value = await fcore.read_text(self._client, path)
dlg.content.update()
except Exception as ex:
tf.value = f"// ошибка чтения: {ex}"
dlg.content.update()
def save(e):
async def _s():
await fcore.write_text(self._client, path, tf.value, sudo_cb.value)
self._page.pop_dialog()
snack(self._page, "Сохранено")
self._page.run_task(_s)
dlg = ft.AlertDialog(
modal=True, title=ft.Text(path, size=13),
content=ft.Container(width=700, content=ft.Column([sudo_cb, tf], tight=True)),
actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)),
ft.FilledButton("Сохранить", on_click=save)],
)
self._page.show_dialog(dlg)
self._page.run_task(_load_text)
+155
View File
@@ -0,0 +1,155 @@
"""Вкладка Firewall: ufw."""
import flet as ft
from app.core import firewall as fw
from app.core.ssh import SSHClient
from app.db.models import Server
from app.ui.components import confirm_dialog, snack
class FirewallTab(ft.Column):
def __init__(self, page: ft.Page, srv: Server):
super().__init__(expand=True, spacing=8, scroll=ft.ScrollMode.AUTO)
self._page = page
self.srv = srv
self.status_text = ft.Text("…", size=13)
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("")),
], rows=[])
self.listen_text = ft.Column(spacing=1)
self.controls = [
ft.Container(height=8),
ft.Row([
ft.FilledButton("Включить ufw", icon=ft.Icons.SHIELD,
on_click=self._enable),
ft.OutlinedButton("Выключить", on_click=self._disable),
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._page.run_task(self._load)),
]),
self.status_text,
ft.Card(content=ft.Container(padding=10, content=self.table)),
ft.Text("Прослушиваемые порты (ss -tlnp):", weight=ft.FontWeight.BOLD, size=13),
self.listen_text,
]
self._page.run_task(self._load)
async def _load(self):
try:
async with SSHClient(self.srv) as c:
if not await fw.is_installed(c):
self.status_text.value = "ufw не установлен (apt install ufw)"
self._page.update()
return
active, raw = await fw.status(c)
rules = await fw.list_rules(c)
listening = await fw.listening_ports(c)
self.status_text.value = "🟢 ufw активен" if active else "⚪ ufw выключен"
self.table.rows = [
ft.DataRow(cells=[
ft.DataCell(ft.Text(str(r.number))),
ft.DataCell(ft.Text(r.to)),
ft.DataCell(ft.Text(r.action,
color=ft.Colors.GREEN_300 if "ALLOW" in r.action.upper()
else ft.Colors.RED_300)),
ft.DataCell(ft.Text(r.from_)),
ft.DataCell(ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=16,
on_click=lambda e, n=r.number: self._del_rule(n))),
]) for r in rules
]
self.listen_text.controls = [
ft.Text(l, size=11, font_family="Consolas", color=ft.Colors.GREY_500)
for l in listening
]
except Exception as e:
self.status_text.value = f"ошибка: {e}"
self._page.update()
def _enable(self, e):
def do():
async def _d():
async with SSHClient(self.srv) as c:
out = await fw.enable(c, self.srv.port)
snack(self._page, out.strip().splitlines()[-1] if out.strip() else "ufw включён")
await self._load()
self._page.run_task(_d)
confirm_dialog(self._page, "Включить ufw?",
f"Порт {self.srv.port} (текущий SSH) будет автоматически разрешён.", do,
danger=False)
def _disable(self, e):
def do():
async def _d():
async with SSHClient(self.srv) as c:
await fw.disable(c)
await self._load()
self._page.run_task(_d)
confirm_dialog(self._page, "Выключить ufw?", "Файрвол будет полностью отключён.", do)
def _del_rule(self, number):
def do():
async def _d():
async with SSHClient(self.srv) as c:
await fw.delete_rule(c, number)
await self._load()
self._page.run_task(_d)
confirm_dialog(self._page, f"Удалить правило #{number}?", "", do)
def _add_dialog(self, e):
port_tf = ft.TextField(label="Порт (22, 80, 8000:8010)", width=250)
action_dd = ft.Dropdown(label="Действие", value="allow", width=150, options=[
ft.dropdown.Option("allow"), ft.dropdown.Option("deny"),
ft.dropdown.Option("limit"), ft.dropdown.Option("reject"),
])
proto_dd = ft.Dropdown(label="Протокол", value="tcp", width=120, options=[
ft.dropdown.Option("tcp"), ft.dropdown.Option("udp"), ft.dropdown.Option("any"),
])
from_tf = ft.TextField(label="От адреса (пусто = любой)", width=250)
def do(e):
async def _d():
async with SSHClient(self.srv) as c:
out = await fw.add_rule(c, port_tf.value, action_dd.value,
proto_dd.value, from_tf.value.strip())
self._page.pop_dialog()
snack(self._page, out.strip() or "правило добавлено")
await self._load()
self._page.run_task(_d)
dlg = ft.AlertDialog(
modal=True, title=ft.Text("Новое правило"),
content=ft.Column([port_tf, action_dd, proto_dd, from_tf], tight=True),
actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)),
ft.FilledButton("Добавить", on_click=do)],
)
self._page.show_dialog(dlg)
def _preset_dialog(self, e):
def apply(name):
self._page.pop_dialog()
async def _d():
async with SSHClient(self.srv) as c:
out = await fw.apply_preset(c, name, self.srv.port)
snack(self._page, "пресет применён")
await self._load()
self._page.run_task(_d)
dlg = ft.AlertDialog(
modal=True, title=ft.Text("Применить пресет"),
content=ft.Column([
ft.FilledButton(n, on_click=lambda e, n=n: apply(n), width=250)
for n in fw.PRESETS
], tight=True),
actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg))],
)
self._page.show_dialog(dlg)
def _close(self, dlg):
self._page.pop_dialog()
+158
View File
@@ -0,0 +1,158 @@
"""Вкладка Обзор: метрики, update/upgrade/reboot, история."""
import flet as ft
from app.core import monitor
from app.core.packages import apt_update, list_upgradable
from app.core.ssh import SSHClient, check_server
from app.core.tasks import tasks
from app.db.models import Server
from app.ui.components import append_log, confirm_dialog, fmt_mb, log_view, snack
class OverviewTab(ft.Column):
def __init__(self, page: ft.Page, srv: Server):
super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12)
self._page = page
self.srv = srv
self.info_text = ft.Text("загрузка…", size=13)
self.upg_text = ft.Text("", size=13, color=ft.Colors.ORANGE_300)
self.metrics_col = ft.Column(spacing=4)
self.log = log_view()
self.log_container = ft.Container(
height=220, border=ft.Border.all(1, ft.Colors.GREY_800),
border_radius=8, padding=8, content=self.log, visible=False,
)
self.controls = [
ft.Container(height=8),
ft.Row([
ft.FilledButton("apt update", icon=ft.Icons.REFRESH,
on_click=self._update),
ft.FilledButton("Upgrade", icon=ft.Icons.UPGRADE,
on_click=lambda e: self._upgrade(False)),
ft.OutlinedButton("Dist-upgrade",
on_click=lambda e: self._upgrade(True)),
ft.FilledButton("Reboot", icon=ft.Icons.RESTART_ALT,
style=ft.ButtonStyle(bgcolor=ft.Colors.RED_800),
on_click=self._reboot),
self.upg_text,
], spacing=10),
ft.Card(content=ft.Container(padding=14, content=ft.Column([
ft.Text("Система", weight=ft.FontWeight.BOLD),
self.info_text,
ft.Container(height=6),
self.metrics_col,
]))),
self.log_container,
ft.Text("История метрик", weight=ft.FontWeight.BOLD),
self._chart(),
]
self._page.run_task(self._load)
# ---------- данные ----------
async def _load(self):
info = await check_server(self.srv)
if info.get("online"):
self.info_text.value = (
f"🟢 {info['hostname']} • {info['os']}\n"
f"{info['uptime']} • CPU: {info['cpu_cores']} ядер • RAM: {info['mem_mb']} МБ"
)
try:
async with SSHClient(self.srv) as c:
upg = await list_upgradable(c)
self.upg_text.value = (
f"доступно обновлений: {len(upg)}" if upg else "система обновлена"
)
snap = await monitor.collect(c, self.srv.id)
self._show_metrics(snap)
except Exception as e:
self.info_text.value += f"\n⚠ {e}"
else:
self.info_text.value = f"🔴 {info.get('error')}"
self._page.update()
def _show_metrics(self, snap):
def bar(label, pct, color):
return ft.Row([
ft.Text(label, size=12, width=45),
ft.ProgressBar(value=pct / 100, height=8, expand=True, color=color,
bgcolor=ft.Colors.GREY_800),
ft.Text(f"{pct:.0f}%", size=12, width=42,
text_align=ft.TextAlign.RIGHT),
])
self.metrics_col.controls = [
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}"
f" • net ↓{snap.net_rx_kbs:.0f} ↑{snap.net_tx_kbs:.0f} КБ/с",
size=11, color=ft.Colors.GREY_500),
]
def _chart(self):
hist = monitor.history(self.srv.id, 30)
if len(hist) < 2:
return ft.Text("данных пока мало — график появится после нескольких замеров",
size=12, color=ft.Colors.GREY_500)
# простое текстовое представление истории (LineChart недоступен в flet 0.86)
cpu_vals = [h.cpu_percent for h in hist]
ram_vals = [h.mem_percent for h in hist]
rows = []
for i, h in enumerate(hist[-15:]):
bar_cpu = "█" * int(h.cpu_percent / 5) + "░" * (20 - int(h.cpu_percent / 5))
bar_ram = "█" * int(h.mem_percent / 5) + "░" * (20 - int(h.mem_percent / 5))
rows.append(ft.Text(
f"{h.ts:%H:%M} CPU {bar_cpu} {h.cpu_percent:5.1f}% RAM {bar_ram} {h.mem_percent:5.1f}%",
size=10, font_family="Consolas", color=ft.Colors.GREY_400,
))
return ft.Column(rows, spacing=1, scroll=ft.ScrollMode.AUTO, height=180)
# ---------- действия ----------
def _show_log(self, show=True):
self.log_container.visible = show
self._page.update()
def _update(self, e):
self._show_log()
async def job(log):
async with SSHClient(self.srv) as c:
log("$ apt-get update")
out = await apt_update(c)
for line in out.splitlines()[-30:]:
log(line)
tasks.start("update", "apt update", self.srv.id, job,
on_log=lambda l: (append_log(self.log, l), self._page.update()))
snack(self._page, "apt update запущен")
def _upgrade(self, dist: bool):
label = "dist-upgrade" if dist else "upgrade"
def do():
self._show_log()
async def job(log):
async with SSHClient(self.srv) as c:
log(f"$ apt-get {label} -y")
stream = await __import__("app.core.packages", fromlist=["apt_upgrade"]).apt_upgrade(c, dist)
async for line in stream:
log(line)
tasks.start("upgrade", label, self.srv.id, job,
on_log=lambda l: (append_log(self.log, l), self._page.update()))
confirm_dialog(self._page, f"Запустить {label}?",
f"На сервере «{self.srv.name}» будут обновлены пакеты.", do)
def _reboot(self, e):
def do():
self._show_log()
from app.core.group_ops import op_reboot
async def job(log):
async with SSHClient(self.srv) as c:
await op_reboot(wait=True)(c, self.srv, log)
tasks.start("reboot", "reboot", self.srv.id, job,
on_log=lambda l: (append_log(self.log, l), self._page.update()))
confirm_dialog(self._page, "Перезагрузить сервер?",
f"Сервер «{self.srv.name}» будет перезагружен. Приложение дождётся его возврата.",
do)
+216
View File
@@ -0,0 +1,216 @@
"""Вкладка Пакеты: список с разбивкой, установка/удаление."""
import flet as ft
from app.core.packages import (Package, apt_search, install, list_packages,
remove, split_packages)
from app.core.ssh import SSHClient
from app.core.tasks import tasks
from app.db.models import Server
from app.ui.components import append_log, confirm_dialog, log_view, snack
class PackagesTab(ft.Column):
def __init__(self, page: ft.Page, srv: Server):
super().__init__(expand=True, spacing=8)
self._page = page
self.srv = srv
self._all: list[Package] = []
self._filter = "user" # all/system/user/deps
self.search = ft.TextField(
hint_text="Поиск по имени…", prefix_icon=ft.Icons.SEARCH,
on_change=lambda e: self._render_table(), expand=True, dense=True,
)
self.chips = ft.Row([
ft.Chip(label=ft.Text("Пользовательские"), on_select=lambda e: self._set_filter("user"), selected=True),
ft.Chip(label=ft.Text("Системные"), on_select=lambda e: self._set_filter("system")),
ft.Chip(label=ft.Text("Зависимости"), on_select=lambda e: self._set_filter("deps")),
ft.Chip(label=ft.Text("Все"), on_select=lambda e: self._set_filter("all")),
])
self.count_text = ft.Text("", size=12, color=ft.Colors.GREY_500)
self.table = ft.DataTable(
columns=[
ft.DataColumn(ft.Text("Пакет")),
ft.DataColumn(ft.Text("Версия")),
ft.DataColumn(ft.Text("Тип")),
ft.DataColumn(ft.Text("")),
],
rows=[], expand=True,
)
self.table_col = ft.Column([self.table], scroll=ft.ScrollMode.AUTO, expand=True)
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.controls = [
ft.Container(height=8),
ft.Row([
ft.FilledButton("Установить пакет", icon=ft.Icons.ADD,
on_click=self._install_dialog),
ft.OutlinedButton("Обновить список", icon=ft.Icons.REFRESH,
on_click=lambda e: self._reload()),
ft.OutlinedButton("Autoremove", icon=ft.Icons.CLEANING_SERVICES,
on_click=self._autoremove),
self.count_text,
]),
ft.Row([self.search], spacing=10),
self.chips,
ft.Container(expand=True, content=self.table_col),
self.log_container,
]
self._page.run_task(self._load)
def _set_filter(self, f):
self._filter = f
for c in self.chips.controls:
c.selected = False
idx = {"user": 0, "system": 1, "deps": 2, "all": 3}[f]
self.chips.controls[idx].selected = True
self._render_table()
async def _load(self):
try:
async with SSHClient(self.srv) as c:
self._all = await list_packages(c)
except Exception as e:
snack(self._page, f"Ошибка загрузки: {e}", error=True)
self._render_table()
def _reload(self):
self.count_text.value = "загрузка…"
self._page.update()
self._page.run_task(self._load)
def _render_table(self):
groups = split_packages(self._all)
items = self._all if self._filter == "all" else groups.get(self._filter, [])
q = (self.search.value or "").lower()
if q:
items = [p for p in items if q in p.name.lower()]
self.count_text.value = f"показано {len(items)} из {len(self._all)}"
kind_label = lambda p: ("системный" if p.is_system else
"пользовательский" if p.manual else "зависимость")
kind_color = lambda p: (ft.Colors.BLUE_300 if p.is_system else
ft.Colors.GREEN_300 if p.manual else ft.Colors.GREY_500)
self.table.rows = [
ft.DataRow(cells=[
ft.DataCell(ft.Text(p.name, selectable=True)),
ft.DataCell(ft.Text(p.version, size=12)),
ft.DataCell(ft.Text(kind_label(p), size=12, color=kind_color(p))),
ft.DataCell(ft.IconButton(
ft.Icons.DELETE_OUTLINE, icon_size=18, tooltip="Удалить",
on_click=lambda e, p=p: self._remove(p),
)),
]) for p in items[:300]
]
if len(items) > 300:
self.count_text.value += " (первые 300)"
self._page.update()
# ---------- действия ----------
def _show_log(self):
self.log_container.visible = True
self._page.update()
def _install_dialog(self, e):
name_tf = ft.TextField(label="Имя пакета (или несколько через пробел)",
autofocus=True, width=400)
results = ft.Column(tight=True, spacing=2)
def search(e):
async def _s():
results.controls = [ft.Text("поиск…", size=12)]
dlg.content.update()
try:
async with SSHClient(self.srv) as c:
lines = await apt_search(c, name_tf.value)
results.controls = [
ft.TextButton(
l[:90],
on_click=lambda e, n=l.split(" ")[0]: setattr(
name_tf, "value", n) or dlg.content.update(),
style=ft.ButtonStyle(padding=2),
) for l in lines[:15]
] or [ft.Text("ничего не найдено", size=12)]
except Exception as ex:
results.controls = [ft.Text(f"ошибка: {ex}", size=12)]
dlg.content.update()
self._page.run_task(_s)
def do_install(e):
names = name_tf.value.split()
if not names:
return
self._page.pop_dialog()
self._show_log()
async def job(log):
async with SSHClient(self.srv) as c:
log(f"$ apt-get install -y {' '.join(names)}")
stream = await install(c, names)
async for line in stream:
log(line)
tasks.start("install", f"install {' '.join(names)}", self.srv.id, job,
on_log=lambda l: (append_log(self.log, l), self._page.update()),
on_done=lambda r: self._reload())
dlg = ft.AlertDialog(
modal=True, title=ft.Text("Установить пакет"),
content=ft.Column([name_tf, results], tight=True, height=350, width=450),
actions=[
ft.TextButton("Найти", on_click=search),
ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)),
ft.FilledButton("Установить", on_click=do_install),
],
)
self._page.show_dialog(dlg)
def _close(self, dlg):
self._page.pop_dialog()
def _remove(self, p: Package):
if p.is_system:
snack(self._page, f"«{p.name}» — системный пакет, удаление заблокировано",
error=True)
return
purge_cb = ft.Checkbox(label="Очистить конфигурацию (purge)", value=False)
def do():
self._page.pop_dialog()
self._show_log()
async def job(log):
async with SSHClient(self.srv) as c:
stream = await remove(c, [p.name], purge_cb.value)
async for line in stream:
log(line)
tasks.start("remove", f"remove {p.name}", self.srv.id, job,
on_log=lambda l: (append_log(self.log, l), self._page.update()),
on_done=lambda r: self._reload())
dlg = ft.AlertDialog(
modal=True, title=ft.Text(f"Удалить {p.name}?"),
content=purge_cb,
actions=[
ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)),
ft.FilledButton("Удалить", on_click=lambda e: do(),
style=ft.ButtonStyle(bgcolor=ft.Colors.RED_700)),
],
)
self._page.show_dialog(dlg)
def _autoremove(self, e):
def do():
self._show_log()
from app.core.packages import autoremove
async def job(log):
async with SSHClient(self.srv) as c:
stream = await autoremove(c)
async for line in stream:
log(line)
tasks.start("autoremove", "autoremove", self.srv.id, job,
on_log=lambda l: (append_log(self.log, l), self._page.update()))
confirm_dialog(self._page, "Autoremove?",
"Будут удалены неиспользуемые зависимости.", do)
+116
View File
@@ -0,0 +1,116 @@
"""Вкладка Сервисы: systemd-юниты, журнал."""
import flet as ft
from app.core import systemd_svc as sd
from app.core.ssh import SSHClient
from app.db.models import Server
from app.ui.components import confirm_dialog, snack
class SystemdTab(ft.Column):
def __init__(self, page: ft.Page, srv: Server):
super().__init__(expand=True, spacing=8)
self._page = page
self.srv = srv
self._units = []
self.search = ft.TextField(hint_text="Поиск юнита…", prefix_icon=ft.Icons.SEARCH,
on_change=lambda e: self._render(), expand=True, dense=True)
self.only_failed = ft.Checkbox(label="только failed", value=False,
on_change=lambda e: self._render())
self.table = ft.DataTable(columns=[
ft.DataColumn(ft.Text("Юнит")),
ft.DataColumn(ft.Text("Состояние")),
ft.DataColumn(ft.Text("Enabled")),
ft.DataColumn(ft.Text("Описание")),
ft.DataColumn(ft.Text("Действия")),
], rows=[])
self.log_text = ft.Column(scroll=ft.ScrollMode.AUTO, spacing=1)
self.controls = [
ft.Container(height=8),
ft.Row([
ft.OutlinedButton("Обновить", icon=ft.Icons.REFRESH,
on_click=lambda e: self._page.run_task(self._load)),
self.search, self.only_failed,
]),
ft.Row([
ft.Container(expand=3, content=ft.Column([self.table],
scroll=ft.ScrollMode.AUTO)),
ft.VerticalDivider(width=1),
ft.Container(expand=2, content=self.log_text),
], expand=True),
]
self._page.run_task(self._load)
async def _load(self):
try:
async with SSHClient(self.srv) as c:
self._units = await sd.list_units(c)
except Exception as e:
snack(self._page, f"systemd: {e}", error=True)
self._render()
def _render(self):
q = (self.search.value or "").lower()
units = self._units
if self.only_failed.value:
units = [u for u in units if u.active == "failed"]
if q:
units = [u for u in units if q in u.name.lower() or q in u.description.lower()]
def state_color(u):
return {"active": ft.Colors.GREEN_300, "failed": ft.Colors.RED_400,
"inactive": ft.Colors.GREY_500}.get(u.active, ft.Colors.GREY_500)
self.table.rows = [
ft.DataRow(cells=[
ft.DataCell(ft.Text(u.name, size=12, selectable=True)),
ft.DataCell(ft.Text(f"{u.active}/{u.sub}", size=12, color=state_color(u))),
ft.DataCell(ft.Text(u.enabled or "—", size=11)),
ft.DataCell(ft.Text(u.description[:50], size=11)),
ft.DataCell(ft.Row([
ft.IconButton(ft.Icons.PLAY_ARROW, icon_size=16, tooltip="start",
on_click=lambda e, n=u.name: self._act(n, "start")),
ft.IconButton(ft.Icons.STOP, icon_size=16, tooltip="stop",
on_click=lambda e, n=u.name: self._act(n, "stop")),
ft.IconButton(ft.Icons.RESTART_ALT, icon_size=16, tooltip="restart",
on_click=lambda e, n=u.name: self._act(n, "restart")),
ft.IconButton(ft.Icons.ARTICLE, icon_size=16, tooltip="логи",
on_click=lambda e, n=u.name: self._logs(n)),
], spacing=0)),
]) for u in units[:200]
]
self._page.update()
def _act(self, name, action):
def do():
async def _d():
try:
async with SSHClient(self.srv) as c:
await sd.action(c, name, action)
snack(self._page, f"{name}: {action} ✓")
await self._load()
except Exception as ex:
snack(self._page, f"{ex}", error=True)
self._page.run_task(_d)
if name in sd.CRITICAL_UNITS and action in ("stop", "restart"):
confirm_dialog(self._page, f"⚠ {action} {name}?",
"Это критичный системный сервис! Можно потерять связь с сервером.", do)
else:
do()
def _logs(self, name):
async def _l():
self.log_text.controls = [ft.Text(f"journalctl -u {name}", weight=ft.FontWeight.BOLD, size=12)]
self._page.update()
try:
async with SSHClient(self.srv) as c:
out = await sd.logs(c, name)
for line in out.splitlines()[-80:]:
self.log_text.controls.append(
ft.Text(line, size=10, font_family="Consolas", selectable=True))
except Exception as ex:
self.log_text.controls.append(ft.Text(f"ошибка: {ex}", size=11))
self._page.update()
self._page.run_task(_l)
+272
View File
@@ -0,0 +1,272 @@
"""Вкладка Терминал: PTY-сессия + библиотека команд обслуживания."""
import flet as ft
from app.core.terminal import TerminalSession
from app.db.models import Server
# Категории → [(название, команда), ...]
# {server} — подстановка не нужна, команды выполняются на самом сервере
COMMANDS = {
"Пользователи": [
("Список пользователей", "cat /etc/passwd | awk -F: '$3>=1000{print $1}'"),
("Кто залогинен", "who -a"),
("Добавить пользователя", "sudo adduser"),
("Удалить пользователя", "sudo deluser --remove-home"),
("Сменить пароль пользователя", "sudo passwd"),
("Дать sudo", "sudo usermod -aG sudo"),
("Убрать sudo", "sudo deluser $(whoami) sudo"),
("Список групп", "getent group | awk -F: '$3>=1000{print $1}'"),
("Добавить в группу", "sudo usermod -aG"),
("Сессии пользователя", "last -n 20"),
],
"Система": [
("Аптайм и нагрузка", "uptime"),
("Инфо о системе", "hostnamectl"),
("Версия ядра", "uname -a"),
("Время и часовой пояс", "timedatectl"),
("Установить часовой пояс", "sudo timedatectl set-timezone Asia/Ulaanbaatar"),
("Синхронизация времени", "sudo timedatectl set-ntp true"),
("Обновить locale", "sudo dpkg-reconfigure locales"),
("Память", "free -h"),
("Диски", "lsblk"),
("SMART дисков", "sudo smartctl -a /dev/sda 2>/dev/null || echo 'smartmontools не установлен'"),
],
"Процессы": [
("Топ процессов", "ps aux --sort=-%cpu | head -20"),
("Дерево процессов", "pstree -p"),
("Найти процесс", "ps aux | grep"),
("Убить по имени", "pkill -f"),
("Открытые файлы процесса", "sudo lsof -p"),
],
"Сеть": [
("IP-адреса", "ip a"),
("Маршруты", "ip r"),
("Открытые порты", "sudo ss -tlnp"),
("Соединения", "sudo ss -tnp"),
("DNS", "cat /etc/resolv.conf"),
("Проверить связь", "ping -c 4 8.8.8.8"),
("Скачать файл", "wget"),
("Открыть порт в ufw", "sudo ufw allow"),
],
"Логи": [
("Системный журнал", "sudo journalctl -n 100 --no-pager"),
("Ошибки за час", "sudo journalctl -p err -S '1 hour ago' --no-pager"),
("Логи сервиса", "sudo journalctl -u"),
("dmesg", "sudo dmesg | tail -30"),
("Авторизации", "sudo last -n 20"),
("Неудачные логины", "sudo lastb -n 20 2>/dev/null || sudo journalctl _COMM=sshd | grep -i fail | tail -20"),
],
"Пакеты": [
("Обновить индекс", "sudo apt update"),
("Обновить систему", "sudo apt upgrade -y"),
("Найти пакет", "apt-cache search"),
("Инфо о пакете", "apt-cache show"),
("Установленные вручную", "apt-mark showmanual"),
("Очистка", "sudo apt autoremove -y && sudo apt autoclean"),
("История apt", "grep -h ' install \\| upgrade ' /var/log/apt/history.log* | tail -20"),
],
"Безопасность": [
("Статус fail2ban", "sudo fail2ban-client status 2>/dev/null || echo 'не установлен'"),
("Правила ufw", "sudo ufw status numbered"),
("Открытые сокеты", "sudo lsof -i -P -n | grep LISTEN"),
("SUID-файлы", "sudo find / -perm -4000 -type f 2>/dev/null | head -20"),
("Пользователи с shell", "grep -E '/bin/(bash|sh|zsh)' /etc/passwd"),
],
}
class TerminalTab(ft.Column):
def __init__(self, page: ft.Page, srv: Server):
super().__init__(expand=True, spacing=8)
self._page = page
self.srv = srv
self._term: TerminalSession | None = None
self._connected = False
# вывод терминала
self.output = ft.Column(
scroll=ft.ScrollMode.AUTO, spacing=0, expand=True,
auto_scroll=True,
)
self.output_container = ft.Container(
expand=True, bgcolor=ft.Colors.BLACK, border_radius=6, padding=8,
content=self.output,
)
# строка ввода
self.input = ft.TextField(
hint_text="команда… (Enter — отправить)",
dense=True, expand=True, text_size=13,
text_style=ft.TextStyle(font_family="Consolas"),
on_submit=self._send,
disabled=True,
)
# статус подключения
self.status_icon = ft.Icon(ft.Icons.CIRCLE, color=ft.Colors.GREY, size=12)
self.status_text = ft.Text("не подключено", size=12, color=ft.Colors.GREY_500)
# селектор команд
cat_names = list(COMMANDS.keys())
self.cat_dd = ft.Dropdown(
label="Категория", value=cat_names[0], width=180,
options=[ft.dropdown.Option(c) for c in cat_names],
on_select=lambda e: self._fill_cmd_dd(),
)
self.cmd_dd = ft.Dropdown(
label="Команда", width=350,
options=[ft.dropdown.Option(n) for n, _ in COMMANDS[cat_names[0]]],
on_select=lambda e: self._on_cmd_selected(),
)
self.param_tf = ft.TextField(
hint_text="параметр (если нужен)", width=200, dense=True, visible=False,
)
self.controls = [
ft.Container(height=8),
# панель подключения
ft.Row([
ft.FilledButton("Подключиться", icon=ft.Icons.TERMINAL,
on_click=self._connect),
ft.OutlinedButton("Отключиться", icon=ft.Icons.CLOSE,
on_click=self._disconnect),
self.status_icon, self.status_text,
ft.Container(expand=True),
ft.TextButton("Очистить вывод", on_click=lambda e: self._clear()),
]),
# селектор команд
ft.Row([
self.cat_dd, self.cmd_dd, self.param_tf,
ft.FilledButton("Выполнить", icon=ft.Icons.PLAY_ARROW,
on_click=self._run_selected),
]),
# терминал
ft.Container(expand=True, content=self.output_container),
# ввод
ft.Row([self.input]),
]
# ---------- подключение ----------
def _connect(self, e=None):
if self._connected:
return
self._set_status("подключение…", ft.Colors.ORANGE_300)
self._page.run_task(self._do_connect)
async def _do_connect(self):
try:
self._term = TerminalSession(self.srv, self._on_output)
await self._term.open()
self._connected = True
self.input.disabled = False
self._set_status(f"подключено: {self.srv.username}@{self.srv.host}",
ft.Colors.GREEN_400)
# приглашение
self._term.write(f"echo '--- Debian Manager Terminal | {self.srv.name} ---'\n")
except Exception as ex:
self._set_status(f"ошибка: {ex}", ft.Colors.RED_400)
def _disconnect(self, e=None):
if self._term:
self._term.close()
self._term = None
self._connected = False
self.input.disabled = True
self._set_status("не подключено", ft.Colors.GREY_500)
def _set_status(self, text, color):
self.status_text.value = text
self.status_icon.color = color
self._page.update()
def _on_output(self, data: str):
# разбиваем ANSI-строки на Text-строки (упрощённо — без цветов)
for line in data.splitlines():
# убираем ANSI escape-коды
clean = self._strip_ansi(line)
if clean is not None:
self.output.controls.append(
ft.Text(clean, size=12, font_family="Consolas",
color=ft.Colors.GREEN_100, selectable=True)
)
# ограничиваем буфер
if len(self.output.controls) > 500:
self.output.controls = self.output.controls[-400:]
self._page.update()
@staticmethod
def _strip_ansi(s: str) -> str:
import re
return re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", s)
def _clear(self):
self.output.controls = []
self._page.update()
# ---------- ввод ----------
def _send(self, e=None):
if self._term and self._connected and self.input.value:
self._term.write(self.input.value + "\n")
self.input.value = ""
self._page.update()
# ---------- библиотека команд ----------
def _fill_cmd_dd(self):
cat = self.cat_dd.value
self.cmd_dd.options = [
ft.dropdown.Option(n) for n, _ in COMMANDS[cat]
]
self.cmd_dd.value = None
self.param_tf.visible = False
self._page.update()
def _on_cmd_selected(self):
"""Показать поле параметра, если команда его требует."""
cat = self.cat_dd.value
name = self.cmd_dd.value
if not name:
return
cmd = next((c for n, c in COMMANDS[cat] if n == name), "")
# команды, заканчивающиеся на пробел/утилиту без аргумента — нужен параметр
needs_param = cmd.endswith((" ", "adduser", "passwd", "deluser", "usermod -aG",
"journalctl -u", "grep", "pkill -f", "lsof -p",
"allow", "search", "show", "wget"))
self.param_tf.visible = needs_param
self._page.update()
def _run_selected(self, e=None):
if not self._connected:
self._connect()
# повторим после подключения
self._page.run_task(self._run_after_connect)
return
self._execute_selected()
async def _run_after_connect(self):
import asyncio
for _ in range(30):
await asyncio.sleep(0.2)
if self._connected:
break
if self._connected:
self._execute_selected()
def _execute_selected(self):
cat = self.cat_dd.value
name = self.cmd_dd.value
if not name:
return
cmd = next((c for n, c in COMMANDS[cat] if n == name), "")
param = (self.param_tf.value or "").strip()
full = f"{cmd} {param}".rstrip() if param else cmd
# подсветим выполняемую команду в выводе
self.output.controls.append(
ft.Text(f"$ {full}", size=12, font_family="Consolas",
color=ft.Colors.YELLOW_300, weight=ft.FontWeight.BOLD)
)
self._term.write(full + "\n")
self.param_tf.value = ""
self._page.update()