"""Вкладка 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()