173 lines
7.3 KiB
Python
173 lines
7.3 KiB
Python
|
|
"""Вкладка 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()
|