From bba380c8ef35392f9f39201f5ca7fd69ac11bdb3 Mon Sep 17 00:00:00 2001 From: engelgardt Date: Mon, 18 May 2026 11:49:12 +0300 Subject: [PATCH] v1.2.0: russian/english UI, config.ini, README.ru.md --- CHANGELOG.md | 9 +++- README.md | 2 + README.ru.md | 99 ++++++++++++++++++++++++++++++++++ src/dhcpsrv/__init__.py | 2 +- src/dhcpsrv/app.py | 33 +++++++----- src/dhcpsrv/config.py | 94 ++++++++++++++++++++++++++++++++ src/dhcpsrv/dhcp.py | 7 +-- src/dhcpsrv/i18n.py | 115 ++++++++++++++++++++++++++++++++++++++++ src/dhcpsrv/ui.py | 43 +++++++-------- 9 files changed, 366 insertions(+), 38 deletions(-) create mode 100644 README.ru.md create mode 100644 src/dhcpsrv/config.py create mode 100644 src/dhcpsrv/i18n.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7de108d..4c2d2d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +## [1.2.0] - 2026-05-18 +### Added +- Russian UI translation. On first launch the application asks which language to use (`1) English`, `2) Русский`) and writes the answer to a fresh `config.ini` next to `dhcpsrv.exe`. To change the language later, edit `language = en` / `language = ru` in that file — the comment at the top of the file explains how, in both languages. +- `config.ini` becomes the future home for other settable defaults (pool, lease, server IP) — currently only `[General] language` is consumed. +- Bilingual `README.ru.md` linked from the main `README.md`. + ## [1.1.3] - 2026-05-17 ### Changed - Update check no longer interrupts startup with an interactive prompt. If a newer release is available, the header line now shows a quiet `update available (vX.Y.Z)` hint right-aligned in dim grey — no key press required. @@ -40,7 +46,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Scrollback cleared on startup so mouse-wheel doesn't expose pre-launch text. - MIT licensed. -[Unreleased]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.1.3...HEAD +[Unreleased]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.1.3...v1.2.0 [1.1.3]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.1.2...v1.1.3 [1.1.2]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.1.1...v1.1.2 [1.1.1]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.1.0...v1.1.1 diff --git a/README.md b/README.md index 244bd8a..d358c2a 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ [![Latest release](https://img.shields.io/github/v/release/Engelgardt23/dhcpsrv)](https://github.com/Engelgardt23/dhcpsrv/releases/latest) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +🇬🇧 English | [🇷🇺 На русском](README.ru.md) + A tiny portable **DHCP server** for the laptop of a storage/server engineer. One double-click — pick a NIC — done. Live table of clients, ping status, packet counters. No install, no Python required on the target machine. diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..4e30e8b --- /dev/null +++ b/README.ru.md @@ -0,0 +1,99 @@ +# dhcpsrv + +[![Последний релиз](https://img.shields.io/github/v/release/Engelgardt23/dhcpsrv)](https://github.com/Engelgardt23/dhcpsrv/releases/latest) +[![Лицензия: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +[🇬🇧 English](README.md) | 🇷🇺 На русском + +Маленький портативный **DHCP-сервер** для ноутбука инженера хранения / серверов. +Двойной клик — выбрал сетевую — готово. Живая таблица клиентов, статус ping, счётчики пакетов. Ничего не устанавливается, Python на целевой машине не нужен. + +Сделан под сценарий «воткнул кабель, увидел как BMC получил IP» во время прошивки, восстановления и бенчмарков. + +> **Автор: engelgardt.** + +--- + +## Скачать + +Последний релиз: [**страница релизов**](https://github.com/Engelgardt23/dhcpsrv/releases/latest). +Архив `dhcpsrv-portable-vX.Y.Z.zip` (~12 МБ). + +## Запуск + +1. Распакуй куда угодно. +2. Двойной клик по `dhcpsrv.exe`. +3. **При первом запуске** программа спросит язык интерфейса (1 — English, 2 — Русский). Ответ запишется в `config.ini` рядом с exe — потом можно поменять руками. +4. Подтверди UAC (admin нужен, чтобы занять UDP/67 и переключить адаптер на статический IP). +5. Выбери сетевой адаптер, воткнутый в твой сервер или коммутатор — это единственный вопрос. +6. `Ctrl+C` — стоп. Спросит, вернуть ли адаптер обратно в DHCP. + +## Настройки по умолчанию (никаких других вопросов) + +| Параметр | Значение | +|---|---| +| IP сервера | `10.10.10.1/24` | +| Пул | `10.10.10.2 .. 10.10.10.51` (50 адресов) | +| Lease | `7200 с` (2 часа — переживёт долгий стресс-тест) | +| Опция TFTP | IP сервера (BMC сразу увидит твой Tftpd32) | + +## Что на экране + +``` +┌─ dhcpsrv v1.2.0 ────────────────────────────────────────────────────────┐ +│ Сервер: 10.10.10.1/255.255.255.0 Пул: 10.10.10.2–10.10.10.51 … │ +│ Аренды: 3/50 Пакетов: 47 DISCOVER: 12 REQUEST: 11 RELEASE: 0 │ +└─────────────────────────────────────────────────────────────────────────┘ +┌─ Клиенты ───────────────────────────────────────────────────────────────┐ +│ # │ IP │ Имя хоста │ MAC │ Последний │ Пинг │ +│ 1 │ 10.10.10.2 │ vegman-r120 │ a0:c5:f2:13:57:46 │ 17:42:18 │ OK │ +│ 2 │ 10.10.10.3 │ vegman-s220 │ 70:b3:d5:11:22:33 │ 17:42:21 │ -- │ +└─────────────────────────────────────────────────────────────────────────┘ +┌─ События ───────────────────────────────────────────────────────────────┐ +│ [17:42:18] DISCOVER a0:c5:f2:13:57:46 → OFFER 10.10.10.2 │ +│ [17:42:18] REQUEST a0:c5:f2:13:57:46 → ACK 10.10.10.2 │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## Типичные сценарии + +- **VEGMAN с общим LOM** — один кабель в порт BMC/host, и BMC и хост-ОС получают IP с этого DHCP. +- **8-портовый свитч** — ноут на одном порту, до 7 серверов на остальных; пул из 50 адресов покрывает всех. +- **Прямой кабель в выделенный Mgmt-порт** — один клиент (BMC). + +## Совместимость + +- Windows 10 / 11. +- В списке адаптеров отфильтровано всё лишнее (Wi-Fi, Cisco AnyConnect, Hyper-V, VMware, VirtualBox, TAP/TUN, WireGuard, OpenVPN, Tailscale, ZeroTier). +- Имена адаптеров с пробелами или не-ASCII экранируются корректно для `netsh`. + +## Конфиг + +При первом запуске рядом с `dhcpsrv.exe` появится `config.ini`: + +```ini +# Чтобы сменить язык интерфейса, измените 'language' ниже. +# Допустимые значения: en, ru +[General] +language = ru +``` + +В будущих релизах сюда же переедут пул, lease и IP сервера — пока что переменная одна. + +## Заметки + +- На машине ничего не устанавливается. Удалить — стереть папку. +- UAC спросит каждый раз. (Если хочешь убрать на *своей* машине — пропусти `dhcpsrv.exe` через Scheduled Task с галкой «Run with highest privileges» и запускай через `schtasks /run /tn dhcpsrv`.) +- Если в Tftpd32 включён модуль DHCP — отключи, UDP/67 окажется занят. +- Lease по умолчанию 7200 с; клиент продлевает в середине срока. Если нужно «навсегда», исходник поддерживает `2147483647` — пересобери из исходников. + +## Сборка из исходников + +``` +python -m pip install rich pyinstaller +python -m PyInstaller --onefile --uac-admin --console --name dhcpsrv dhcpsrv-launcher.py +``` + +## Лицензия + +MIT — см. [LICENSE](LICENSE). diff --git a/src/dhcpsrv/__init__.py b/src/dhcpsrv/__init__.py index 30816bd..ae69b4c 100644 --- a/src/dhcpsrv/__init__.py +++ b/src/dhcpsrv/__init__.py @@ -6,5 +6,5 @@ The single source of truth for the project version. Bump this before tagging a release; CI reads the tag, the code reads this constant. """ -__version__ = "1.1.3" +__version__ = "1.2.0" GITHUB_REPO = "Engelgardt23/dhcpsrv" diff --git a/src/dhcpsrv/app.py b/src/dhcpsrv/app.py index 673fd99..43e7ff5 100644 --- a/src/dhcpsrv/app.py +++ b/src/dhcpsrv/app.py @@ -17,37 +17,46 @@ from .update_check import check_for_update from .network import list_adapters, set_static_ip, revert_to_dhcp from .dhcp import DhcpConfig, DhcpServer, now_s from .ui import Ui +from .config import load_config +from .i18n import set_language, t def _select_nic(console: Console) -> dict | None: - console.rule("[bold cyan]Available adapters") + console.rule(f"[bold cyan]{t('available_adapters')}") adapters = list_adapters() if not adapters: - console.print("[red]No suitable wired adapters found.[/]") + console.print(f"[red]{t('no_adapters')}[/]") return None for i, a in enumerate(adapters, 1): ip = a.get("IPv4") or "—" console.print(f" {i}) [{a['Status']}] {a['Name']} ({a['Description']}) {ip}") while True: - s = Prompt.ask("Select adapter number").strip() + s = Prompt.ask(t("select_adapter")).strip() if s.isdigit() and 1 <= int(s) <= len(adapters): return adapters[int(s) - 1] - console.print("[red]Invalid selection.[/]") + console.print(f"[red]{t('invalid_selection')}[/]") def main() -> None: enable_vt() + + # Language prompt (writes config.ini on first run) happens BEFORE admin + # elevation so the user does not have to answer it twice after the UAC + # bounce. + cfg_data = load_config() + set_language(cfg_data["language"]) + require_admin() console = Console(log_path=False) - title = f"[bold cyan]dhcpsrv v{__version__}[/] - portable laptop-side DHCP server" + title = f"[bold cyan]dhcpsrv v{__version__}[/] {t('tagline')}" latest = check_for_update() if latest: header = Table.grid(expand=True) header.add_column(justify="left", ratio=1) header.add_column(justify="right") - header.add_row(title, f"[dim]update available ({latest})[/]") + header.add_row(title, f"[dim]{t('update_available', tag=latest)}[/]") console.print(header) else: console.print(title) @@ -55,10 +64,10 @@ def main() -> None: nic = _select_nic(console) if not nic: - input("Press Enter to exit"); return + input(t("press_enter")); return cfg = DhcpConfig.with_defaults() - console.print(f"[yellow]Setting {nic['Name']} → {cfg.server_ip} / {cfg.netmask} ...[/]") + console.print(f"[yellow]{t('setting_nic', name=nic['Name'], ip=cfg.server_ip, mask=cfg.netmask)}[/]") set_static_ip(nic["Name"], cfg.server_ip, cfg.netmask) # Wire server <-> ui through callbacks so neither imports the other. @@ -74,15 +83,15 @@ def main() -> None: stop.set() try: print() - print(f"[{now_s()}] Shutting down...") + print(t("shutting_down", ts=now_s())) try: - if Confirm.ask(f"Revert {nic['Name']} back to DHCP?", default=False): + if Confirm.ask(t("revert_nic", name=nic["Name"]), default=False): revert_to_dhcp(nic["Name"]) - print("NIC reverted to DHCP") + print(t("nic_reverted")) except (EOFError, KeyboardInterrupt): pass finally: - input("Press Enter to exit") + input(t("press_enter")) sys.exit(0) signal.signal(signal.SIGINT, shutdown) diff --git a/src/dhcpsrv/config.py b/src/dhcpsrv/config.py new file mode 100644 index 0000000..1b1c9e4 --- /dev/null +++ b/src/dhcpsrv/config.py @@ -0,0 +1,94 @@ +""" +config.ini handling next to the executable. + +On first run the file does not exist — we ask the user which language to use +and write the answer alongside the exe. On every subsequent run we just read +it. The .ini has a leading bilingual comment explaining how to change values +by editing the file directly. +""" + +from __future__ import annotations +import configparser +import sys +from pathlib import Path + + +SUPPORTED_LANGS = ("en", "ru") +DEFAULT_LANG = "en" + +CONFIG_HEADER = """\ +# --------------------------------------------------------------------------- +# dhcpsrv configuration +# +# To change the interface language, edit the 'language' value below. +# Valid values: en, ru +# +# Чтобы сменить язык интерфейса, измените значение 'language' ниже. +# Допустимые значения: en, ru +# --------------------------------------------------------------------------- + +""" + + +def app_dir() -> Path: + """Directory holding the running executable (or source folder when run via python).""" + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parent + + +def config_path() -> Path: + return app_dir() / "config.ini" + + +def _ask_language() -> str: + """First-run prompt. Stdin is always available in console apps, no Rich here + yet (we run before the main console is set up).""" + print() + print("Select language / Выберите язык:") + print(" 1) English") + print(" 2) Русский") + while True: + try: + choice = input("> ").strip() + except (EOFError, KeyboardInterrupt): + return DEFAULT_LANG + if choice == "1": + return "en" + if choice == "2": + return "ru" + print("Please enter 1 or 2 / Введите 1 или 2") + + +def _write_config(lang: str) -> None: + path = config_path() + cp = configparser.ConfigParser() + cp["General"] = {"language": lang} + with path.open("w", encoding="utf-8") as f: + f.write(CONFIG_HEADER) + cp.write(f) + + +def load_config() -> dict: + """Return the active configuration dict. Side-effect: creates config.ini on + first run after prompting the user.""" + path = config_path() + if not path.exists(): + lang = _ask_language() + try: + _write_config(lang) + except OSError: + # read-only location — fall back to in-memory default + pass + return {"language": lang} + + cp = configparser.ConfigParser() + try: + cp.read(path, encoding="utf-8") + except (configparser.Error, OSError): + return {"language": DEFAULT_LANG} + + lang = (cp.get("General", "language", fallback=DEFAULT_LANG) or DEFAULT_LANG).strip().lower() + if lang not in SUPPORTED_LANGS: + lang = DEFAULT_LANG + return {"language": lang} diff --git a/src/dhcpsrv/dhcp.py b/src/dhcpsrv/dhcp.py index ceeed3b..cd09ba5 100644 --- a/src/dhcpsrv/dhcp.py +++ b/src/dhcpsrv/dhcp.py @@ -15,6 +15,7 @@ from datetime import datetime from typing import Callable, Optional from .network import ping_one +from .i18n import t as _t # ---------- helpers ---------- @@ -174,8 +175,8 @@ class DhcpServer: try: s.bind(("0.0.0.0", 67)) except OSError as e: - self.log(f"[bold red]bind UDP/67 failed:[/] {e}") - self.log("[yellow]Another DHCP service (Tftpd32 DHCP, ICS, Windows DHCP) may be running.[/]") + self.log(f"[bold red]{_t('bind_failed')}[/] {e}") + self.log(f"[yellow]{_t('bind_hint')}[/]") stop.set() return @@ -200,7 +201,7 @@ class DhcpServer: self.stats["discovers"] += 1 ipn = self.alloc_ip(mac) if ipn is None: - self.log(f"[dim][{now_s()}][/] [red]DISCOVER[/] {mac} → [red]POOL EXHAUSTED[/]") + self.log(f"[dim][{now_s()}][/] [red]DISCOVER[/] {mac} → [red]{_t('pool_exhausted')}[/]") continue self.touch_client(mac, ipn, host) s.sendto(self.build_reply(data, 2, ipn), ("255.255.255.255", 68)) diff --git a/src/dhcpsrv/i18n.py b/src/dhcpsrv/i18n.py new file mode 100644 index 0000000..bf05f29 --- /dev/null +++ b/src/dhcpsrv/i18n.py @@ -0,0 +1,115 @@ +""" +Tiny in-memory translation table. We do not need .po/.mo machinery for a +two-language CLI tool — a flat dict per language is enough. + +Usage: + from .i18n import t, set_language + set_language("ru") + print(t("no_adapters")) + +`t(key, **params)` performs `.format(**params)` on the returned string, so +placeholders work the same way as f-strings. +""" + +from __future__ import annotations + + +_lang = "en" + +STRINGS: dict[str, dict[str, str]] = { + "en": { + # app.py / startup + "tagline": "- portable laptop-side DHCP server", + "update_available": "update available ({tag})", + "available_adapters": "Available adapters", + "no_adapters": "No suitable wired adapters found.", + "select_adapter": "Select adapter number", + "invalid_selection": "Invalid selection.", + "press_enter": "Press Enter to exit", + "setting_nic": "Setting {name} → {ip} / {mask} ...", + "shutting_down": "[{ts}] Shutting down...", + "revert_nic": "Revert {name} back to DHCP?", + "nic_reverted": "NIC reverted to DHCP", + + # dhcp.py / server messages + "bind_failed": "bind UDP/67 failed:", + "bind_hint": "Another DHCP service (Tftpd32 DHCP, ICS, Windows DHCP) may be running.", + "pool_exhausted": "POOL EXHAUSTED", + + # ui.py / header & panels + "panel_server": "Server", + "panel_pool": "Pool", + "panel_lease": "Lease", + "panel_tftp": "TFTP", + "panel_leases": "Leases", + "panel_pkts": "Pkts", + "panel_ctrlc": "Ctrl+C to stop", + "col_ip": "IP", + "col_host": "Hostname", + "col_mac": "MAC", + "col_last": "Last seen", + "col_ping": "Ping", + "no_clients": "(no clients yet)", + "more_clients": "(+{n} more — enlarge the window)", + "events_title": "Events", + "clients_title": "Clients", + "no_events": "(no events yet)", + }, + "ru": { + "tagline": "— портативный DHCP-сервер для инженера", + "update_available": "доступно обновление ({tag})", + "available_adapters": "Доступные адаптеры", + "no_adapters": "Подходящие проводные адаптеры не найдены.", + "select_adapter": "Введите номер адаптера", + "invalid_selection": "Неверный выбор.", + "press_enter": "Нажмите Enter для выхода", + "setting_nic": "Назначаю {name} → {ip} / {mask} ...", + "shutting_down": "[{ts}] Завершение работы...", + "revert_nic": "Вернуть {name} обратно в режим DHCP?", + "nic_reverted": "Адаптер возвращён в режим DHCP", + + "bind_failed": "не удалось занять UDP/67:", + "bind_hint": "Возможно, уже запущен другой DHCP (модуль DHCP в Tftpd32, ICS, Windows DHCP).", + "pool_exhausted": "ПУЛ ИСЧЕРПАН", + + "panel_server": "Сервер", + "panel_pool": "Пул", + "panel_lease": "Аренда", + "panel_tftp": "TFTP", + "panel_leases": "Аренды", + "panel_pkts": "Пакетов", + "panel_ctrlc": "Ctrl+C — выход", + "col_ip": "IP", + "col_host": "Имя хоста", + "col_mac": "MAC", + "col_last": "Последний", + "col_ping": "Пинг", + "no_clients": "(клиентов пока нет)", + "more_clients": "(ещё +{n} — увеличьте окно)", + "events_title": "События", + "clients_title": "Клиенты", + "no_events": "(пока пусто)", + }, +} + + +def set_language(lang: str) -> None: + global _lang + if lang in STRINGS: + _lang = lang + + +def language() -> str: + return _lang + + +def t(key: str, **params) -> str: + """Translate `key` for the active language; fall back to English if a key is + missing in the chosen language. Apply `.format(**params)` for placeholders.""" + s = STRINGS.get(_lang, {}).get(key) or STRINGS["en"].get(key, key) + if params: + try: + return s.format(**params) + except (KeyError, IndexError): + return s + return s diff --git a/src/dhcpsrv/ui.py b/src/dhcpsrv/ui.py index cc70b54..4d31ff1 100644 --- a/src/dhcpsrv/ui.py +++ b/src/dhcpsrv/ui.py @@ -19,6 +19,7 @@ from rich.text import Text from . import __version__ from .dhcp import DhcpServer, int2ip +from .i18n import t as _t # Fixed-size layout slots — used to compute the clients-table fit. @@ -54,27 +55,27 @@ class Ui: cfg = self.server.cfg body = ( f"[bold cyan]dhcpsrv v{__version__}[/]\n" - f"Server: [bold]{cfg.server_ip}[/]/{cfg.netmask} " - f"Pool: [bold]{int2ip(cfg.pool[0])}–{int2ip(cfg.pool[-1])}[/] " - f"Lease: [bold]{cfg.lease}s[/] " - f"TFTP: [bold]{cfg.tftp}[/]\n" - f"Leases: [bold]{leased}/{len(cfg.pool)}[/] " - f"Pkts: [dim]{st['packets']}[/] " + f"{_t('panel_server')}: [bold]{cfg.server_ip}[/]/{cfg.netmask} " + f"{_t('panel_pool')}: [bold]{int2ip(cfg.pool[0])}–{int2ip(cfg.pool[-1])}[/] " + f"{_t('panel_lease')}: [bold]{cfg.lease}s[/] " + f"{_t('panel_tftp')}: [bold]{cfg.tftp}[/]\n" + f"{_t('panel_leases')}: [bold]{leased}/{len(cfg.pool)}[/] " + f"{_t('panel_pkts')}: [dim]{st['packets']}[/] " f"DISCOVER: [cyan]{st['discovers']}[/] " f"REQUEST: [green]{st['requests']}[/] " f"RELEASE: [yellow]{st['releases']}[/] " - f"[dim]Ctrl+C to stop[/]" + f"[dim]{_t('panel_ctrlc')}[/]" ) return Panel(body, border_style="cyan") def _render_table(self) -> Table: - t = Table(expand=True, header_style="bold") - t.add_column("#", style="dim", width=3, justify="right") - t.add_column("IP", width=16) - t.add_column("Hostname", min_width=10) - t.add_column("MAC", width=19) - t.add_column("Last seen", style="dim", width=10) - t.add_column("Ping", width=6, justify="center") + tbl = Table(expand=True, header_style="bold") + tbl.add_column("#", style="dim", width=3, justify="right") + tbl.add_column(_t("col_ip"), width=16) + tbl.add_column(_t("col_host"), min_width=10) + tbl.add_column(_t("col_mac"), width=19) + tbl.add_column(_t("col_last"), style="dim", width=10) + tbl.add_column(_t("col_ping"), width=6, justify="center") with self.server.lock: rows = sorted(self.server.clients.items(), key=lambda kv: kv[1]["ip_int"]) @@ -85,12 +86,12 @@ class Ui: rows = rows[: avail - 1] # leave one slot for the "(+N more)" marker if not rows: - t.add_row("—", "—", "(no clients yet)", "—", "—", "—") + tbl.add_row("—", "—", _t("no_clients"), "—", "—", "—") else: for i, (mac, c) in enumerate(rows, 1): ping = (Text("OK", style="bold green") if c.get("ping_ok") else Text("--", style="bold red")) - t.add_row( + tbl.add_row( str(i), int2ip(c["ip_int"]), c.get("host") or "—", @@ -99,20 +100,20 @@ class Ui: ping, ) if overflow: - t.add_row("…", "", f"[dim](+{overflow} more — enlarge the window)[/]", "", "", "") - return t + tbl.add_row("…", "", f"[dim]{_t('more_clients', n=overflow)}[/]", "", "", "") + return tbl def _render_events(self) -> Panel: with self.events_lock: last = list(self.events)[-20:] - body = "\n".join(last) if last else "[dim](no events yet)[/]" - return Panel(body, title="Events", border_style="dim") + body = "\n".join(last) if last else f"[dim]{_t('no_events')}[/]" + return Panel(body, title=_t("events_title"), border_style="dim") def _render_screen(self) -> Layout: layout = Layout() layout.split_column( Layout(self._render_header(), name="hdr", size=HEADER_LINES), - Layout(Panel(self._render_table(), title="Clients", border_style="cyan"), name="tbl"), + Layout(Panel(self._render_table(), title=_t("clients_title"), border_style="cyan"), name="tbl"), Layout(self._render_events(), name="evt", size=EVENTS_LINES), ) return layout