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