9fdb02c72a
Списки баз стенда и локальных дампов на одном экране, фильтр сужает их на каждом набранном символе. Enter по базе открывает окно параметров дампа с галочкой автоподнятия: снята — только dump, поднята — dump плюс restore. Правки полей уезжают в копию конфига, config.json не трогается. Ядро получило четыре хука для UI (лог, границы шагов, пароль, отмена), run() читает вывод утилит построчно через Popen, поэтому журнал наполняется во время работы pg_dump. Текстовый режим, CLI и --dry-run ведут себя как раньше; без textual или вне tty пульт не запускается. Журнал разворачивается на весь экран по F4 и сам на старте операции. Раскладка адаптивная: narrow ниже 100 колонок, short ниже 32 строк. Ctrl-хоткеи продублированы кириллицей — русская раскладка их не глушит. Запуск через run.ps1 (venv, UTF-8-консоль, -Setup и -Tests). Проверка — tests_tui.py: 46 тестов, TUI гоняется headless через Pilot.
1389 lines
64 KiB
Python
1389 lines
64 KiB
Python
#!/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)
|