#!/usr/bin/env python3 """TUI-пульт на Textual: списки стенда и дампов на одном экране, фильтр сужает их по буквам. Модуль ничего не знает про pg_dump и не собирает командных строк: он вызывает те же do_sync / do_dump / do_restore, что и CLI, а видит их через четыре хука ядра — вывод (set_sink), границы шагов (set_progress), пароль (set_password_prompt) и отмену. Отсутствие textual роняет только импорт этого файла, ядро остаётся работоспособным. """ from __future__ import annotations import importlib.util import threading import time from collections import deque from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from textual import work from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical, VerticalScroll from textual.content import Content from textual.message import Message from textual.screen import ModalScreen, Screen from textual.theme import Theme from textual.widgets import (Button, Collapsible, Footer, Input, Label, OptionList, ProgressBar, RichLog, Static, Switch) from textual.widgets.option_list import Option from textual.worker import WorkerState, get_current_worker import pg_stand_sync as core CSS_FILE = Path(__file__).with_name("pg_stand_sync.tcss") # Только BMP-геометрия: emoji в Windows Terminal занимают две ячейки и рвут колонки SPINNER = "⣾⣽⣻⢿⡿⣟⣯⣷" STATE_GLYPH = {"pending": "·", "active": "▸", "done": "✓", "warn": "▲", "fail": "✗"} STATE_STYLE = {"pending": "dim", "active": "bold $accent", "done": "$success", "warn": "$warning", "fail": "$error"} # глиф дублирует цвет: журнал должен читаться и в монохромном скриншоте LOG_STYLE = {"log": "", "out": "dim", "warn": "$warning", "err": "$error", "ok": "$success"} JOURNAL_LIMIT = 20000 # держится в памяти; в панель всё равно влезает max_lines=5000 LOG_LINES = 5000 PASSWORD_WAIT = 600.0 # верхний предел ожидания модалки рабочим потоком DARK = Theme( name="pgsync-dark", dark=True, background="#14181c", surface="#1b2126", panel="#222a30", foreground="#d7e0e6", primary="#3fb8a8", secondary="#5aa9e6", accent="#e3a83c", success="#6ec06e", warning="#e3a83c", error="#e05f5f", ) LIGHT = Theme( name="pgsync-light", dark=False, background="#f5f7f8", surface="#ffffff", panel="#eceff1", foreground="#1d2429", primary="#17796d", secondary="#2f6690", accent="#a86b12", success="#2f7d32", warning="#8a5b00", error="#b23a3a", ) # ЙЦУКЕН поверх QWERTY: при русской раскладке терминал присылает Ctrl+«в» вместо Ctrl+D, # и хоткей молча не срабатывает. Дубли вешаются автоматически, чтобы список клавиш не разъехался. RU_LAYOUT = {"a": "ф", "c": "с", "d": "в", "l": "д", "r": "к", "s": "ы", "u": "г", "x": "ч"} def with_ru_layout(bindings: list) -> list: """Добавить к Ctrl-хоткеям их кириллические двойники.""" extra = [] for binding in bindings: key = binding.key if key.startswith("ctrl+") and len(key) == 6 and key[5] in RU_LAYOUT: extra.append(Binding(f"ctrl+{RU_LAYOUT[key[5]]}", binding.action, binding.description, priority=binding.priority, show=False)) return list(bindings) + extra def available() -> bool: return importlib.util.find_spec("textual") is not None # --------------------------------------------------------------------------- данные @dataclass class DbRow: name: str size: str @dataclass class DumpRow: path: Path name: str size_mb: float mtime: datetime db: str @dataclass class OpSpec: """Ровно аргументы do_* — больше TUI об операции ничего не знает.""" kind: str # sync | dump | restore dbs: list = field(default_factory=list) dump_file: Path | None = None target_db: str | None = None no_recreate: bool = False # правки секций dump/restore на этот запуск; в ядро уходят копией cfg, файл не трогаем overrides: dict = field(default_factory=dict) @dataclass class PlanStep: label: str state: str = "pending" elapsed: float | None = None class MetaLoaded(Message): def __init__(self, dbs: list, dumps: list) -> None: super().__init__() self.dbs = dbs self.dumps = dumps class MetaFailed(Message): def __init__(self, error: str) -> None: super().__init__() self.error = error class OpDone(Message): def __init__(self, ok: bool, error: str, elapsed: float, cancelled: bool = False) -> None: super().__init__() self.ok = ok self.error = error self.elapsed = elapsed self.cancelled = cancelled # --------------------------------------------------------------------------- списки def clip(text: str, room: int) -> str: """Обрезать с многоточием: молча срезанный хвост делает длинные имена неразличимыми.""" if room <= 1 or len(text) <= room: return text[:room] if room > 0 else "" return text[:room - 1] + "…" def highlighted_content(text: str, query: str) -> Content: """Подсветить куски, из-за которых строка прошла фильтр: иначе непонятно, почему она тут.""" content = Content(text) if not query: return content low = text.lower() for word in query.lower().split(): start = low.find(word) if start >= 0: content = content.stylize("bold $primary", start, start + len(word)) return content class FilterList(OptionList): """Общее для обоих списков: строки-объекты рядом с опциями, счётчик и подсветка.""" def __init__(self, **kwargs) -> None: # markup=False: в именах файлов и в выводе утилит попадаются [ ], разметка бы их съела super().__init__(markup=False, compact=True, **kwargs) self.rows: list = [] self.total = 0 self.query = "" self._relaying = False def line(self, row) -> str: raise NotImplementedError def decorate(self, row) -> Content: return highlighted_content(self.line(row), self.query) def counter_host(self): """Счётчик рисуется на рамке панели: у самого OptionList в CSS border: none, а Textual выводит border_subtitle только в строке рамки.""" return self.parent if self.parent is not None else self def counter(self) -> str: return f"показано {len(self.rows)}/{self.total}" # запас под вертикальный скроллбар: content_size его не вычитает, и строка ровно по # ширине переносит хвост («1277 MB» разрывается на «1277» и «MB») SCROLLBAR_ROOM = 2 def room_for(self, tail: str, fallback: int) -> int: """Сколько ячеек остаётся имени: считаем от реальной ширины, иначе строка переносится.""" width = self.content_size.width or fallback return max(width - len(tail) - self.SCROLLBAR_ROOM, 12) def show(self, rows: list, query: str, total: int) -> None: keep = self.highlighted or 0 self.rows = list(rows) self.query = query self.total = total self.clear_options() self.add_options([Option(self.decorate(row)) for row in self.rows]) if self.rows: self.highlighted = min(keep, len(self.rows) - 1) elif query: # пустая панель неотличима от незагрузившейся — текстовый pick() здесь тоже говорит self.add_option(Option(Content("ничего не найдено по фильтру").stylize("dim"), disabled=True)) self.counter_host().border_subtitle = self.counter() def on_resize(self) -> None: # строки собраны под прежнюю ширину: после изменения размера их надо пересобрать if not self.rows or self._relaying: return self._relaying = True try: self.show(self.rows, self.query, self.total) finally: self._relaying = False @property def current(self): index = self.highlighted if index is None or not self.rows: return None return self.rows[index] class DbList(FilterList): """Список баз стенда. Отметки живут в множестве имён — фильтр их не теряет.""" BINDINGS = [Binding("space", "toggle_mark", "отметить", show=True)] class MarksChanged(Message): pass def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.marked: set = set() def counter(self) -> str: # счётчик отметок держится в той же строке: иначе он терялся при каждой перерисовке base = super().counter() return base + (f" · отмечено {len(self.marked)}" if self.marked else "") def line(self, row) -> str: mark = "●" if row.name in self.marked else "○" tail = f"{row.size:>10}" room = self.room_for(f"{mark} {tail}", 46) return f"{mark} {clip(row.name, room):<{room}}{tail}" def action_toggle_mark(self) -> None: row = self.current if row is None: return if row.name in self.marked: self.marked.discard(row.name) else: self.marked.add(row.name) self.replace_option_prompt_at_index(self.highlighted, self.decorate(row)) self.post_message(self.MarksChanged()) def selection(self) -> list: """Все отмеченные базы, а если отметок нет — та, что под курсором. Фильтр отметок не сужает: счётчик «отмечено N» обязан совпадать с составом операции. """ if self.marked: return sorted(self.marked) row = self.current return [row.name] if row else [] class DumpList(FilterList): def line(self, row) -> str: tail = f"{row.size_mb:>9.1f} МБ {row.mtime:%d.%m %H:%M}" room = self.room_for(f" {tail}", 66) return f"{clip(row.name, room):<{room}} {tail}" # --------------------------------------------------------------------------- модалки class PasswordModal(ModalScreen): """getpass под запущенным Textual невозможен: пароль спрашивается только отсюда.""" BINDINGS = [Binding("escape", "cancel", "отмена")] def __init__(self, label: str, node: dict) -> None: super().__init__() self.label = label self.node = node def compose(self) -> ComposeResult: with Vertical(classes="dialog"): yield Static("Нужен пароль", classes="title") yield Static(f"{self.node.get('user')}@{self.node.get('host')} ({self.label}), " f"Enter — без пароля", classes="subject") yield Input(password=True, id="pwd") def on_mount(self) -> None: self.query_one("#pwd", Input).focus() def on_input_submitted(self, event: Input.Submitted) -> None: self.dismiss(event.value) def action_cancel(self) -> None: self.dismiss(None) class ConfirmModal(ModalScreen): """Последний экран перед деструктивной операцией: имя базы, режим и прямая формулировка.""" BINDINGS = [Binding("escape", "cancel", "отмена"), Binding("ctrl+enter", "start", "начать", priority=True)] TITLES = {"sync": "Dump + Restore", "dump": "Dump", "restore": "Restore"} def __init__(self, spec: OpSpec, cfg: dict, subject: str) -> None: super().__init__() self.spec = spec self.cfg = cfg self.subject = subject self.recreate = bool(cfg["target"].get("recreate", True)) def compose(self) -> ComposeResult: with Vertical(classes="dialog"): yield Static(self.TITLES[self.spec.kind], classes="title") yield Static(self.subject, classes="subject") if self.spec.kind != "dump": if self.spec.target_db is None: yield Static("локальные базы — как на стенде (выбрано несколько)", classes="subject") else: yield Label("локальная база") yield Input(value=self.spec.target_db, id="target") with Horizontal(classes="switch-row"): yield Switch(value=self.recreate, id="recreate") yield Label("пересоздать (DROP + CREATE)") yield Static(self._danger_text(), id="danger", classes="danger" + ("" if self.recreate else " hidden")) with Horizontal(classes="row"): yield Button("Отмена", id="cancel") yield Button("Начать", variant="success", id="start") def on_mount(self) -> None: # повторяет умолчание confirm_target: при recreate=true согласие не подразумевается target = "cancel" if (self.recreate and self.spec.kind != "dump") else "start" self.query_one(f"#{target}", Button).focus() def _danger_text(self) -> str: name = self.spec.target_db or "<как на стенде>" return (f"локальная база {name} будет удалена (DROP DATABASE) и создана заново — " f"её текущее содержимое пропадёт") def on_switch_changed(self, event: Switch.Changed) -> None: self.query_one("#danger", Static).set_class(not event.value, "hidden") def on_input_submitted(self) -> None: # Enter в поле имени НЕ запускает операцию: случайное нажатие не должно дропать базу self.query_one("#start", Button).focus() def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "start": self.action_start() else: self.action_cancel() def action_start(self) -> None: if self.spec.kind != "dump": if self.spec.target_db is not None: value = self.query_one("#target", Input).value.strip() if not value: self.notify("имя локальной базы пустое", severity="error") return self.spec.target_db = value self.spec.no_recreate = not self.query_one("#recreate", Switch).value self.dismiss(self.spec) def action_cancel(self) -> None: self.dismiss(None) def _csv(values: list) -> str: return ", ".join(str(v) for v in values or []) def _split_csv(text: str) -> list: return [part.strip() for part in text.split(",") if part.strip()] class DbOpModal(ModalScreen): """Что делаем с выбранной базой: параметры дампа и галочка автоподнятия локально. Заменяет собой выбор действия: снятый флаг — просто dump, поднятый — dump + restore. Правки живут до выхода из пульта, config.json остаётся источником умолчаний. """ BINDINGS = [Binding("escape", "cancel", "отмена"), Binding("ctrl+enter", "start", "начать", priority=True)] def __init__(self, dbs: list, cfg: dict, defaults: dict) -> None: super().__init__() self.dbs = dbs self.cfg = cfg self.dump_opts = dict(defaults.get("dump") or {}) self.restore_opts = dict(defaults.get("restore") or {}) self.autorestore = bool(defaults.get("autorestore", True)) self.recreate = bool(defaults.get("recreate", cfg["target"].get("recreate", True))) configured = cfg["target"].get("database") # несколько баз льются каждая в одноимённую локальную — общего имени тут нет self.target_db = None if len(dbs) > 1 else (configured or dbs[0]) def compose(self) -> ComposeResult: with Vertical(classes="dialog"): yield Static("Операция с базой", classes="title") yield Static(", ".join(self.dbs), classes="subject") with VerticalScroll(classes="form"): with Horizontal(classes="switch-row"): yield Switch(value=self.autorestore, id="autorestore") yield Label("поднять на локальной PG сразу после дампа") yield Static("Дамп", classes="section") for wid, label, key in (("schema-only", "только схема, без данных", "schema_only"), ("no-owner", "без владельцев (--no-owner)", "no_owner"), ("no-priv", "без прав (--no-privileges)", "no_privileges")): with Horizontal(classes="switch-row"): yield Switch(value=bool(self.dump_opts.get(key, key != "schema_only")), id=wid) yield Label(label) yield Label("сжатие 0–9") yield Input(value=str(self.dump_opts.get("compress", 6)), id="compress") with Collapsible(title="состав дампа", collapsed=True): yield Label("только схемы (через запятую)") yield Input(value=_csv(self.dump_opts.get("schemas")), id="schemas") yield Label("исключить схемы") yield Input(value=_csv(self.dump_opts.get("exclude_schemas")), id="ex-schemas") yield Label("исключить таблицы") yield Input(value=_csv(self.dump_opts.get("exclude_tables")), id="ex-tables") yield Label("исключить только данные таблиц") yield Input(value=_csv(self.dump_opts.get("exclude_table_data")), id="ex-data") with Vertical(id="target-block", classes="" if self.autorestore else "hidden"): yield Static("Локальная база", classes="section") if self.target_db is None: yield Static("каждая база — в одноимённую локальную", classes="subject") else: yield Label("имя") yield Input(value=self.target_db, id="target") with Horizontal(classes="switch-row"): yield Switch(value=self.recreate, id="recreate") yield Label("пересоздать (DROP + CREATE)") yield Label("параллельных воркеров restore") yield Input(value=str(self.restore_opts.get("jobs", 4)), id="jobs") yield Static(self._danger_text(), id="danger", classes="danger" + ("" if self.recreate else " hidden")) with Horizontal(classes="row"): yield Button("Отмена", id="cancel") yield Button("Начать", variant="success", id="start") def on_mount(self) -> None: # согласие на DROP не подразумевается: фокус на «Отмена», пока база пересоздаётся risky = self.autorestore and self.recreate self.query_one("#cancel" if risky else "#start", Button).focus() def _danger_text(self) -> str: name = self.target_db or "<как на стенде>" return (f"локальная база {name} будет удалена (DROP DATABASE) и создана заново — " f"её текущее содержимое пропадёт") def on_switch_changed(self, event: Switch.Changed) -> None: if event.switch.id == "autorestore": self.autorestore = event.value self.query_one("#target-block").set_class(not event.value, "hidden") elif event.switch.id == "recreate": self.recreate = event.value self.query_one("#danger", Static).set_class(not event.value, "hidden") def on_input_submitted(self) -> None: # Enter в поле НЕ запускает операцию: случайное нажатие не должно дропать базу self.query_one("#start", Button).focus() def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "start": self.action_start() else: self.action_cancel() def _number(self, widget_id: str, low: int, high: int) -> int | None: raw = self.query_one(f"#{widget_id}", Input).value.strip() try: value = int(raw) except ValueError: value = None if value is None or not low <= value <= high: self.notify(f"нужно число {low}–{high}", severity="error") self.query_one(f"#{widget_id}", Input).focus() return None return value def action_start(self) -> None: compress = self._number("compress", 0, 9) if compress is None: return dump_opts = { "schema_only": self.query_one("#schema-only", Switch).value, "no_owner": self.query_one("#no-owner", Switch).value, "no_privileges": self.query_one("#no-priv", Switch).value, "compress": compress, "schemas": _split_csv(self.query_one("#schemas", Input).value), "exclude_schemas": _split_csv(self.query_one("#ex-schemas", Input).value), "exclude_tables": _split_csv(self.query_one("#ex-tables", Input).value), "exclude_table_data": _split_csv(self.query_one("#ex-data", Input).value), } overrides = {"dump": dump_opts} target_db, no_recreate = None, False if self.autorestore: jobs = self._number("jobs", 1, 64) if jobs is None: return overrides["restore"] = {"jobs": jobs} no_recreate = not self.query_one("#recreate", Switch).value if self.target_db is not None: target_db = self.query_one("#target", Input).value.strip() if not target_db: self.notify("имя локальной базы пустое", severity="error") return spec = OpSpec("sync" if self.autorestore else "dump", dbs=self.dbs, target_db=target_db, no_recreate=no_recreate, overrides=overrides) self.dismiss(spec) def action_cancel(self) -> None: self.dismiss(None) class QuitConfirm(ModalScreen): BINDINGS = [Binding("escape", "stay", "остаться")] def compose(self) -> ComposeResult: with Vertical(classes="dialog"): yield Static("Операция ещё идёт", classes="title") yield Static("выход прервёт её; если DROP DATABASE уже прошёл, " "локальная база останется пустой", classes="danger") with Horizontal(classes="row"): yield Button("Остаться", id="stay") yield Button("Прервать и выйти", variant="error", id="quit") def on_mount(self) -> None: self.query_one("#stay", Button).focus() def on_button_pressed(self, event: Button.Pressed) -> None: self.dismiss(event.button.id == "quit") def action_stay(self) -> None: self.dismiss(False) class HelpModal(ModalScreen): HELP = """\ [b]набор букв[/b] фильтр панели под фокусом, сужается на каждом символе [b]↑ ↓ PgUp PgDn[/b] курсор в списке, фокус остаётся в поле фильтра [b]Tab[/b] следующая панель [b]Enter[/b] базы — параметры дампа (галочка «поднять локально»), дампы — Restore [b]Space[/b] отметить базу (когда фокус в списке баз) [b]Ctrl+D[/b] то же окно с выключенным автоподнятием — просто Dump [b]Ctrl+A / Ctrl+U[/b] отметить показанные / снять все отметки [b]Esc[/b] очистить фильтр, затем отметки [b]F5[/b] перечитать списки [b]Ctrl+X[/b] прервать операцию (это kill, не откат) [b]Ctrl+L / Ctrl+S[/b] очистить журнал / сохранить его в dumps [b]F4[/b] журнал во весь экран (при операции — сам, Esc — назад) [b]F2 / F3[/b] тема / показывать ли вывод утилит [b]Ctrl+C[/b] выход """ BINDINGS = [Binding("escape", "close", "закрыть")] def compose(self) -> ComposeResult: with Vertical(classes="dialog"): yield Static("Клавиши", classes="title") yield Static(self.HELP, id="help-body") def on_key(self) -> None: self.dismiss(None) def action_close(self) -> None: self.dismiss(None) # --------------------------------------------------------------------------- пульт class RunPlan(VerticalScroll): """Шаги операции с состоянием: на двадцатиминутном restore спиннер не отвечает «где мы».""" def compose(self) -> ComposeResult: yield Static(id="plan-body") def render_steps(self, steps: list, frame: str) -> None: body = self.query_one("#plan-body", Static) if not steps: body.update(Content("операция не запущена").stylize("dim")) return # ширина берётся у виджета: фиксированные 24+8 не влезали в панель и рвали «1.2 c» пополам width = self.content_size.width or 32 lines = [] for step in steps: glyph = frame if step.state == "active" else STATE_GLYPH[step.state] tail = f"{step.elapsed:>6.1f} c" if step.elapsed is not None else "" room = max(width - len(tail) - 2, 8) lines.append(Content(f"{glyph} {clip(step.label, room):<{room}}{tail}") .stylize(STATE_STYLE[step.state])) body.update(Content("\n").join(lines)) # длинный план не влезает в панель: активный шаг должен оставаться видимым сам active = next((i for i, step in enumerate(steps) if step.state == "active"), None) if active is not None and self.content_size.height: if not (self.scroll_offset.y <= active < self.scroll_offset.y + self.content_size.height): self.scroll_to(y=max(active - self.content_size.height // 2, 0), animate=False) class DashboardScreen(Screen): """Единственный рабочий экран: оба списка на виду, поэтому шага «выбор действия» нет.""" BINDINGS = [ Binding("down", "cursor('down')", show=False), Binding("up", "cursor('up')", show=False), Binding("pagedown", "cursor('page_down')", show=False), Binding("pageup", "cursor('page_up')", show=False), Binding("ctrl+home", "cursor('first')", show=False), Binding("ctrl+end", "cursor('last')", show=False), Binding("escape", "back", "сброс", show=False), ] def __init__(self, cfg: dict, tools: dict, dry_run: bool) -> None: super().__init__() self.cfg = cfg self.tools = tools self.dry_run = dry_run self.all_dbs: list = [] self.all_dumps: list = [] self.op_active = False self.show_out = True # (kind, ts, text) — переживает очистку панели; предел свой, потому что --verbose # на большой базе даёт сотни тысяч строк, а перерисовка журнала идёт в цикле событий self.journal: deque = deque(maxlen=JOURNAL_LIMIT) self.steps: list = [] self.op_title = "" self.op_started = 0.0 self.frame = 0 self._focus_before_op = None self.zoomed = False # журнал во весь экран self._focus_before_zoom = None self._zoom_auto = False # развернули сами на время операции — сами и свернём # умолчания диалога операции: из конфига, дальше — то, что выбрали в прошлый раз self.op_defaults = {"dump": dict(cfg.get("dump") or {}), "restore": dict(cfg.get("restore") or {}), "recreate": bool(cfg["target"].get("recreate", True)), "autorestore": True} self._queue: deque = deque() self._queue_lock = threading.Lock() # ---------------------------------------------------------------- разметка def compose(self) -> ComposeResult: yield Static(id="topbar") with Vertical(id="board"): with Vertical(id="pane-db", classes="panel"): yield Input(placeholder="фильтр…", id="flt-db", compact=True) yield Static(id="db-error") yield DbList(id="tbl-db") with Vertical(id="col-right"): yield Static(id="target-card") with Vertical(id="pane-dump", classes="panel"): yield Input(placeholder="фильтр…", id="flt-dump", compact=True) yield Static(id="dump-note") yield DumpList(id="tbl-dump") with Horizontal(id="bottom"): yield RunPlan(id="plan") yield RichLog(id="log", markup=True, highlight=False, wrap=False, max_lines=LOG_LINES, auto_scroll=True) with Horizontal(id="status"): yield Static("", id="op-spin") yield Static("готов", id="op-title") yield ProgressBar(id="op-progress", total=1, show_eta=False) yield Static("", id="op-timer") yield Footer(compact=True, show_command_palette=False) def on_mount(self) -> None: src, tgt = self.cfg["source"], self.cfg["target"] badge = " ◆ DRY-RUN" if self.dry_run else "" self.query_one("#topbar", Static).update( Content(f"pg-stand-sync{badge}\n" f"стенд {src['user']}@{src['host']}:{src.get('port', 5432)}" f" → локально {tgt['user']}@{tgt['host']}:{tgt.get('port', 5432)}")) self.query_one("#pane-db").border_title = f"Базы стенда · {src['host']}" self.query_one("#pane-dump").border_title = f"Дампы · {core.dump_dir_of(self.cfg).name}" self.query_one("#plan").border_title = "План" self.query_one("#log").border_title = "Журнал" self._render_target_card() self.query_one("#op-progress", ProgressBar).display = False self.set_interval(0.08, self._drain) # батч строк: --verbose иначе топит цикл self.set_interval(0.1, self._tick) self.query_one("#flt-db", Input).focus() self.reload() def _render_target_card(self) -> None: tgt = self.cfg["target"] mode = "DROP + CREATE" if tgt.get("recreate", True) else "накатить поверх" database = tgt.get("database") or "как на стенде" jobs = self.cfg.get("restore", {}).get("jobs", 4) card = self.query_one("#target-card", Static) card.border_title = "Приёмник" card.update(Content(f"режим {mode}\n" f"цель {database}\n" f"jobs {jobs} · keep_dumps {self.cfg.get('keep_dumps', 5)}")) def on_resize(self) -> None: # media-queries в Textual нет, поэтому классы вешаются руками self.set_class(self.size.width < 100, "narrow") # в низком окне фиксированные 20 строк журнала не оставляют места спискам self.set_class(self.size.height < 32, "short") # ---------------------------------------------------------------- данные def reload(self) -> None: db_list = self.query_one("#tbl-db", DbList) db_list.loading = True self.query_one("#db-error", Static).remove_class("shown") self.query_one("#pane-db").remove_class("pane-error") self.load_meta() @work(thread=True, exclusive=True, group="meta", exit_on_error=False) def load_meta(self) -> None: # exclusive отменяет только задачу, поток продолжает крутиться: результат вытесненного # чтения (и его forget_password) не должен перетирать состояние следующего worker = get_current_worker() try: rows = [DbRow(name, size) for name, size in core.list_databases(self.tools, self.cfg["source"], "стенд")] except core.Cancelled: if not worker.is_cancelled: self.post_message(MetaFailed("чтение списка баз отменено")) return except (core.StepError, core.ConfigError) as exc: if worker.is_cancelled: return # пароль мог не подойти — иначе F5 будет молча повторять ту же ошибку core.forget_password(self.cfg["source"], "стенд") self.post_message(MetaFailed(str(exc))) return if worker.is_cancelled: return dumps = [] for path in core.local_dumps(self.cfg): st = path.stat() # stat снимается один раз, а не на каждой перерисовке dumps.append(DumpRow(path, path.name, st.st_size / 1024 / 1024, datetime.fromtimestamp(st.st_mtime), core.db_from_dump_name(path))) self.post_message(MetaLoaded(rows, dumps)) def on_meta_loaded(self, message: MetaLoaded) -> None: self.query_one("#tbl-db", DbList).loading = False self.all_dbs = message.dbs self.all_dumps = message.dumps self._apply_filters() note = self.query_one("#dump-note", Static) if not self.all_dumps: note.update(f"в {core.dump_dir_of(self.cfg)} нет файлов *.dump") note.add_class("shown") else: note.remove_class("shown") def on_meta_failed(self, message: MetaFailed) -> None: self.query_one("#tbl-db", DbList).loading = False self.query_one("#pane-db").add_class("pane-error") error = self.query_one("#db-error", Static) error.update(f"{message.error}\nF5 — повторить") error.add_class("shown") self._write(("err", datetime.now(), message.error)) def _apply_filters(self) -> None: db_query = self.query_one("#flt-db", Input).value rows = [row for row in self.all_dbs if core.matches(f"{row.name} {row.size}", db_query)] self.query_one("#tbl-db", DbList).show(rows, db_query, len(self.all_dbs)) dump_query = self.query_one("#flt-dump", Input).value dumps = [row for row in self.all_dumps if core.matches(row.name, dump_query)] self.query_one("#tbl-dump", DumpList).show(dumps, dump_query, len(self.all_dumps)) self._render_marks() def _render_marks(self) -> None: db_list = self.query_one("#tbl-db", DbList) db_list.counter_host().border_subtitle = db_list.counter() def on_input_changed(self, event: Input.Changed) -> None: # приходит на каждый символ — Enter для сужения списка не нужен if event.input.id in ("flt-db", "flt-dump"): self._apply_filters() def on_db_list_marks_changed(self) -> None: self._render_marks() # ---------------------------------------------------------------- действия def _pane(self) -> str: focused = self.focused.id if self.focused else "" return "dump" if focused in ("flt-dump", "tbl-dump") else "db" def _active_list(self) -> FilterList: if self._pane() == "dump": return self.query_one("#tbl-dump", DumpList) return self.query_one("#tbl-db", DbList) CURSOR_ACTIONS = {"down": "action_cursor_down", "up": "action_cursor_up", "page_down": "action_page_down", "page_up": "action_page_up", "first": "action_first", "last": "action_last"} LOG_SCROLL = {"down": "scroll_down", "up": "scroll_up", "page_down": "scroll_page_down", "page_up": "scroll_page_up", "first": "scroll_home", "last": "scroll_end"} def action_cursor(self, what: str) -> None: """Стрелки из поля фильтра двигают список, не забирая фокус у ввода.""" if self.zoomed: getattr(self.query_one("#log", RichLog), self.LOG_SCROLL[what])() return getattr(self._active_list(), self.CURSOR_ACTIONS[what])() def toggle_zoom(self) -> None: # ручное переключение отменяет авто-возврат: раз человек решил сам, дальше решает он self._zoom_auto = False self.set_zoom(not self.zoomed) def set_zoom(self, on: bool) -> None: """Журнал во весь экран: на узком окне списки и лог не помещаются одновременно.""" if on == self.zoomed: return self.zoomed = on self.set_class(on, "zoom-log") if on: self._focus_before_zoom = self.focused self.query_one("#log", RichLog).focus() return widget = self._focus_before_zoom self._focus_before_zoom = None if widget is None or not widget.is_attached or widget.disabled: widget = self.query_one("#flt-db", Input) self.set_focus(widget) def action_back(self) -> None: if self.zoomed: self.set_zoom(False) return pane = self._pane() field_id = "#flt-dump" if pane == "dump" else "#flt-db" field = self.query_one(field_id, Input) if field.value: field.value = "" return db_list = self.query_one("#tbl-db", DbList) if db_list.marked: self.clear_marks() def on_input_submitted(self, event: Input.Submitted) -> None: if event.input.id in ("flt-db", "flt-dump"): self.primary_action() def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: self.primary_action() def primary_action(self) -> None: if self._pane() == "dump": self.restore_selected() else: self.sync_selected() def sync_selected(self) -> None: self.open_db_op(autorestore=True) def dump_selected(self) -> None: self.open_db_op(autorestore=False) def open_db_op(self, autorestore: bool) -> None: """Клик по базе открывает не «подтверждение», а параметры дампа с галочкой restore.""" db_list = self.query_one("#tbl-db", DbList) dbs = db_list.selection() if not self._ready(dbs, "не выбрана база"): return configured = self.cfg["target"].get("database") if autorestore and len(dbs) > 1 and configured: # do_sync считает local = target_db or cfg.target.database or dbname, поэтому None # здесь не значит «как на стенде»: все отмеченные базы легли бы в configured по очереди self.notify(f"в конфиге target.database = {configured}: несколько баз слились бы " f"в одну — синхронизируйте по одной", severity="warning") return defaults = dict(self.op_defaults, autorestore=autorestore) self.ask_and_run(DbOpModal(dbs, self.cfg, defaults)) def remember_defaults(self, spec: OpSpec) -> None: """Параметры живут до выхода из пульта: следующий диалог открывается на них же.""" self.op_defaults["dump"] = dict(spec.overrides.get("dump") or {}) if spec.kind == "sync": self.op_defaults["restore"] = dict(spec.overrides.get("restore") or {}) self.op_defaults["recreate"] = not spec.no_recreate self.op_defaults["autorestore"] = spec.kind == "sync" def op_cfg(self, spec: OpSpec) -> dict: """Копия конфига с правками из диалога: ядро принимает cfg, менять его не нужно.""" if not spec.overrides: return self.cfg cfg = dict(self.cfg) for section, values in spec.overrides.items(): cfg[section] = {**(self.cfg.get(section) or {}), **values} return cfg def restore_selected(self) -> None: row = self.query_one("#tbl-dump", DumpList).current if not self._ready([row] if row else [], "не выбран файл дампа"): return target = self.cfg["target"].get("database") or row.db spec = OpSpec("restore", dump_file=row.path, target_db=target) self.ask_and_run(ConfirmModal(spec, self.cfg, row.name)) def _ready(self, items: list, complaint: str) -> bool: if self.op_active: self.notify("операция уже идёт", severity="warning") return False if not items: self.notify(complaint, severity="warning") return False return True def _password_nodes(self, spec: OpSpec) -> list: """Чьи пароли понадобятся операции: их же забываем, если она провалилась.""" nodes = [] if spec.kind in ("sync", "dump"): nodes.append((self.cfg["source"], "стенд")) if spec.kind in ("sync", "restore"): nodes.append((self.cfg["target"], "локальная")) return nodes @work(exclusive=True, group="prep") async def ask_and_run(self, dialog: ModalScreen) -> None: """Диалог и пароли — до старта воркера: getpass из потока подвесил бы UI.""" confirmed = await self.app.push_screen_wait(dialog) if confirmed is None: return if confirmed.dbs: self.remember_defaults(confirmed) for node, label in self._password_nodes(confirmed): if not core.password_needed(node, label): continue value = await self.app.push_screen_wait(PasswordModal(label, node)) if value is None: return core.prime_password(node, label, value) self.begin(confirmed) # ---------------------------------------------------------------- операция def begin(self, spec: OpSpec) -> None: core.clear_cancel() self.op_active = True self.op_started = time.monotonic() self.op_title = {"sync": "sync", "dump": "dump", "restore": "restore"}[spec.kind] if spec.dbs: self.op_title += " " + ", ".join(spec.dbs) elif spec.dump_file: self.op_title += " " + spec.dump_file.name self.steps = [PlanStep(label) for label in self._expected_steps(spec)] bar = self.query_one("#op-progress", ProgressBar) bar.display = True bar.update(total=max(len(self.steps), 1), progress=0) # disabled у предка снимает фокус: без запоминания фильтр после операции немой до Tab self._focus_before_op = self.focused for widget_id in ("#pane-db", "#pane-dump", "#flt-db", "#flt-dump", "#tbl-db", "#tbl-dump"): widget = self.query_one(widget_id) widget.add_class("locked") widget.disabled = True self._write(("log", datetime.now(), "━" * 8 + f" {self.op_title} " + "━" * 8)) # смотреть во время операции всё равно нечего, кроме журнала и плана self._zoom_auto = not self.zoomed self.set_zoom(True) self.run_op(spec) def _expected_steps(self, spec: OpSpec) -> list: """План строится по тем же формулам, что и what в run(); дрейф дописывается на ходу.""" src, tgt = self.cfg["source"], self.cfg["target"] recreate = bool(tgt.get("recreate", True)) and not spec.no_recreate post = len(self.cfg.get("post_restore_sql") or []) labels = [] def restore_block(dbname: str) -> None: if recreate: labels.extend(["обрыв активных сессий", "DROP DATABASE", "CREATE DATABASE"]) labels.append(f"restore в {dbname}@{tgt['host']}") labels.extend(f"post-SQL {i}/{post}" for i in range(1, post + 1)) if spec.kind in ("sync", "dump"): for dbname in spec.dbs: labels.append(f"дамп {dbname}@{src['host']}") if spec.kind == "sync": restore_block(spec.target_db or tgt.get("database") or dbname) else: restore_block(spec.target_db or "") return labels @work(thread=True, exclusive=True, group="op", exit_on_error=False) def run_op(self, spec: OpSpec) -> None: started = time.monotonic() cfg = self.op_cfg(spec) try: if spec.kind == "sync": core.do_sync(cfg, self.tools, spec.dbs, spec.target_db, spec.no_recreate, self.dry_run) elif spec.kind == "dump": core.do_dump(cfg, self.tools, spec.dbs, self.dry_run) else: core.do_restore(cfg, self.tools, spec.dump_file, spec.target_db, spec.no_recreate, self.dry_run) except core.Cancelled: self.post_message(OpDone(False, "операция прервана", time.monotonic() - started, True)) return except (core.StepError, core.ConfigError) as exc: # пароль мог не подойти: без сброса кэша модалка больше не покажется, # и все следующие операции будут падать тем же текстом до перезапуска for node, label in self._password_nodes(spec): core.forget_password(node, label) self.post_message(OpDone(False, str(exc), time.monotonic() - started)) return self.post_message(OpDone(True, "", time.monotonic() - started)) def on_op_done(self, message: OpDone) -> None: self._drain() # хвост очереди обгонять итоговой строкой нельзя self.op_active = False # единственное место, где снимать флаг отмены безопасно: воркер уже отдал результат # и в ядро больше не заходит, а следующий reload() иначе упрётся в ту же отмену core.clear_cancel() for widget_id in ("#pane-db", "#pane-dump", "#flt-db", "#flt-dump", "#tbl-db", "#tbl-dump"): widget = self.query_one(widget_id) widget.remove_class("locked") widget.disabled = False self._restore_focus() for step in self.steps: if step.state in ("pending", "active"): step.state = "fail" if not message.ok else "done" if message.ok: self._write(("ok", datetime.now(), f"готово за {message.elapsed:.1f} c")) self.query_one("#op-title", Static).update("готов") elif message.cancelled: self._write(("err", datetime.now(), "операция прервана; если DROP DATABASE уже прошёл, " "локальная база осталась пустой — повторите restore")) self.query_one("#op-title", Static).update("прервано") else: self._write(("err", datetime.now(), message.error)) self.query_one("#op-title", Static).update("ошибка") self.query_one("#op-spin", Static).update("") self.query_one("#op-timer", Static).update("") self.query_one("#op-progress", ProgressBar).display = False self.reload() def _restore_focus(self) -> None: """Вернуть фокус туда, где он был до блокировки панелей: иначе фильтр не набирается.""" widget = self._focus_before_op self._focus_before_op = None if self.zoomed and self._zoom_auto: # свернуть обратно: иначе фокус остаётся в журнале и фильтр не набирается self._zoom_auto = False self._focus_before_zoom = widget self.set_zoom(False) return if self.zoomed: self._focus_before_zoom = widget return if widget is None or not widget.is_attached or widget.disabled: widget = self.query_one("#flt-db", Input) self.set_focus(widget) def on_worker_state_changed(self, event) -> None: # второй рубеж: непойманное исключение воркера должно стать строкой в журнале, не падением if event.state is not WorkerState.ERROR: return error = event.worker.error self._write(("err", datetime.now(), f"сбой ({event.worker.group}): {error!r}")) if event.worker.group == "op": self.post_message(OpDone(False, str(error), time.monotonic() - self.op_started)) elif event.worker.group == "meta": self.post_message(MetaFailed(str(error))) def cancel_op(self) -> None: if not self.op_active: return self._write(("warn", datetime.now(), "прерываю: убиваю дочерние процессы")) self.kill_children() @work(thread=True, group="cancel", exit_on_error=False) def kill_children(self) -> None: """request_cancel ждёт смерти процессов секундами: в цикле событий это замерший UI.""" if not core.request_cancel(): self.sink("err", "остались процессы pg_restore — " "проверьте соединения к локальной базе") # ---------------------------------------------------------------- журнал и план def sink(self, kind: str, text: str) -> None: """Вызывается из рабочего потока: только очередь, никаких обращений к виджетам.""" with self._queue_lock: self._queue.append(("line", kind, datetime.now(), text)) def progress(self, event: str, what: str, elapsed=None) -> None: with self._queue_lock: self._queue.append(("step", event, what, elapsed)) def _drain(self) -> None: with self._queue_lock: if not self._queue: return batch = list(self._queue) self._queue.clear() for item in batch: if item[0] == "line": self._write(item[1:]) else: _, event, what, elapsed = item self._step_event(event, what, elapsed) def _step_event(self, event: str, what: str, elapsed) -> None: step = next((s for s in self.steps if s.label == what and s.state == "pending"), None) if step is None: step = PlanStep(what) self.steps.append(step) # конфиг мог разойтись с планом — не ломаемся об это self.query_one("#op-progress", ProgressBar).update(total=len(self.steps)) if event == "start": step.state = "active" self.query_one("#op-title", Static).update( f"{self.op_title} · {what} · шаг {self.steps.index(step) + 1}/{len(self.steps)}") else: step.state = "done" if event == "done" else "fail" step.elapsed = elapsed done = sum(1 for s in self.steps if s.state in ("done", "fail", "warn")) self.query_one("#op-progress", ProgressBar).update(progress=done) def _write(self, record, journal: bool = True) -> None: kind, stamp, text = record if journal: self.journal.append(record) if kind == "out" and not self.show_out: return if kind == "out": # строки утилит пишутся Content'ом, а не markup: [ ] в их выводе — обычные скобки line = Content(f" {text}") else: glyph = {"warn": "▲ ", "err": "✗ ", "ok": "✓ "}.get(kind, "") line = Content(f"[{stamp:%H:%M:%S}] {glyph}{text}") style = LOG_STYLE[kind] self.query_one("#log", RichLog).write(line.stylize(style) if style else line) def _tick(self) -> None: self.frame = (self.frame + 1) % len(SPINNER) frame = SPINNER[self.frame] self.query_one("#plan", RunPlan).render_steps(self.steps, frame) if not self.op_active: return self.query_one("#op-spin", Static).update(frame) span = int(time.monotonic() - self.op_started) self.query_one("#op-timer", Static).update(f"{span // 60:02d}:{span % 60:02d}") # ---------------------------------------------------------------- мелкие действия def mark_all(self) -> None: db_list = self.query_one("#tbl-db", DbList) db_list.marked.update(row.name for row in db_list.rows) db_list.show(db_list.rows, db_list.query, len(self.all_dbs)) self._render_marks() def clear_marks(self) -> None: db_list = self.query_one("#tbl-db", DbList) db_list.marked.clear() db_list.show(db_list.rows, db_list.query, len(self.all_dbs)) self._render_marks() def clear_log(self) -> None: self.query_one("#log", RichLog).clear() def toggle_out(self) -> None: """RichLog обрезается по max_lines: иногда проще спрятать шум утилит целиком.""" self.show_out = not self.show_out log_view = self.query_one("#log", RichLog) log_view.clear() # перерисовывается только хвост: длиннее max_lines панель всё равно не покажет for record in list(self.journal)[-LOG_LINES:]: self._write(record, journal=False) self.notify("вывод утилит: " + ("виден" if self.show_out else "скрыт")) def save_log(self) -> Path: stamp = datetime.now().strftime("%Y%m%d-%H%M%S") path = core.dump_dir_of(self.cfg) / f"session-{stamp}.log" path.parent.mkdir(parents=True, exist_ok=True) lines = [] for kind, when, text in self.journal: lines.append(f" {text}" if kind == "out" else f"[{when:%H:%M:%S}] {text}") path.write_text("\n".join(lines) + "\n", encoding="utf-8") return path # --------------------------------------------------------------------------- приложение class PgSyncApp(App): """Ctrl-хоткеи объявлены priority: иначе Input съедает ctrl+a/d/x/c и они молча не сработают.""" CSS_PATH = CSS_FILE TITLE = "pg-stand-sync" ENABLE_COMMAND_PALETTE = False BINDINGS = with_ru_layout([ Binding("ctrl+c", "leave", "выход", priority=True), Binding("ctrl+d", "dump", "dump", priority=True), Binding("ctrl+a", "mark_all", "все", priority=True), Binding("ctrl+u", "clear_marks", "снять", priority=True), Binding("ctrl+x", "stop", "стоп", priority=True), Binding("ctrl+l", "clear_log", "очистить", priority=True, show=False), Binding("ctrl+s", "save_log", "журнал", priority=True, show=False), Binding("f5", "reload", "обновить"), Binding("ctrl+r", "reload", "обновить", show=False), Binding("f1", "help", "помощь"), Binding("f2", "switch_theme", "тема", show=False), Binding("f3", "toggle_out", "вывод", show=False), Binding("f4", "zoom", "журнал"), ]) def __init__(self, cfg: dict, tools: dict, dry_run: bool) -> None: super().__init__() self.cfg = cfg self.tools = tools self.dry_run = dry_run self.dashboard: DashboardScreen | None = None # (событие, ответ) каждой открытой PasswordModal: при выходе их надо отпустить руками self._prompts: list = [] def on_mount(self) -> None: self.register_theme(DARK) self.register_theme(LIGHT) self.theme = DARK.name self.dashboard = DashboardScreen(self.cfg, self.tools, self.dry_run) core.set_sink(self.dashboard.sink) core.set_progress(self.dashboard.progress) core.set_password_prompt(self._password_from_thread) self.push_screen(self.dashboard) def on_unmount(self) -> None: core.set_sink(None) core.set_progress(None) core.set_password_prompt(None) # флаг отмены здесь НЕ снимается: рабочий поток может быть ещё жив, и со снятым флагом # он спокойно доработает следующий DROP DATABASE уже без UI. Сброс делает begin(). core.request_cancel() # dismiss при разборе экранов не вызывается — колбэк модалки не придёт никогда for answered, holder in list(self._prompts): holder.setdefault("value", None) answered.set() self._prompts.clear() def _password_from_thread(self, label: str, node: dict): """Пароль, запрошенный ленивым resolve_password из середины do_sync. Блокируется рабочий поток, а не цикл событий: модалку открывает call_from_thread, ответ приходит колбэком. Ждать через worker-контекст нельзя — его здесь нет. """ holder: dict = {} answered = threading.Event() def opener() -> None: def got(value) -> None: holder["value"] = value answered.set() self.push_screen(PasswordModal(label, node), got) pending = (answered, holder) self._prompts.append(pending) try: self.call_from_thread(opener) except Exception: self._prompts.remove(pending) return None try: # шагами, а не одним wait: закрытое приложение обязано отпустить поток сразу, # иначе процесс не завершается, пока не истечёт весь таймаут deadline = time.monotonic() + PASSWORD_WAIT while not answered.wait(0.2): if not self.is_running or time.monotonic() > deadline: return None finally: if pending in self._prompts: self._prompts.remove(pending) return holder.get("value") # ---- действия зовут пульт, а не текущий экран: сверху может лежать модалка def _dash(self) -> DashboardScreen | None: if self.dashboard is not None and isinstance(self.screen, DashboardScreen): return self.dashboard return None def action_dump(self) -> None: dash = self._dash() if dash: dash.dump_selected() def action_mark_all(self) -> None: dash = self._dash() if dash: dash.mark_all() def action_clear_marks(self) -> None: dash = self._dash() if dash: dash.clear_marks() def action_reload(self) -> None: dash = self._dash() if dash and not dash.op_active: dash.reload() def action_stop(self) -> None: dash = self._dash() if dash: dash.cancel_op() def action_clear_log(self) -> None: dash = self._dash() if dash: dash.clear_log() def action_toggle_out(self) -> None: dash = self._dash() if dash: dash.toggle_out() def action_save_log(self) -> None: dash = self._dash() if not dash: return try: self.notify(f"журнал: {dash.save_log()}") except OSError as exc: self.notify(f"не сохранился: {exc}", severity="error") def action_zoom(self) -> None: if self.dashboard is not None: self.dashboard.toggle_zoom() def action_help(self) -> None: if not isinstance(self.screen, HelpModal): self.push_screen(HelpModal()) def action_switch_theme(self) -> None: self.theme = LIGHT.name if self.theme == DARK.name else DARK.name @work(exclusive=True, group="quit") async def action_leave(self) -> None: """Ctrl+C в Textual 8 по умолчанию копирует текст: выход даёт только priority-биндинг.""" dash = self.dashboard if dash is not None and dash.op_active: if not await self.push_screen_wait(QuitConfirm()): return core.request_cancel() self.exit(130) return self.exit(0) def run_tui(cfg: dict, tools: dict, dry_run: bool) -> int: """Точка входа для main(): наружу отдаётся только код возврата.""" app = PgSyncApp(cfg, tools, dry_run) result = app.run() return 0 if result is None else int(result)