v1.2.0: russian/english UI, config.ini, README.ru.md

This commit is contained in:
engelgardt 2026-05-18 11:49:12 +03:00
parent 6c6602278d
commit bba380c8ef
9 changed files with 366 additions and 38 deletions

View file

@ -6,6 +6,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
## [Unreleased] ## [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 ## [1.1.3] - 2026-05-17
### Changed ### 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. - 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. - Scrollback cleared on startup so mouse-wheel doesn't expose pre-launch text.
- MIT licensed. - 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.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.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 [1.1.1]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.1.0...v1.1.1

View file

@ -3,6 +3,8 @@
[![Latest release](https://img.shields.io/github/v/release/Engelgardt23/dhcpsrv)](https://github.com/Engelgardt23/dhcpsrv/releases/latest) [![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) [![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. 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. One double-click — pick a NIC — done. Live table of clients, ping status, packet counters. No install, no Python required on the target machine.

99
README.ru.md Normal file
View file

@ -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.210.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).

View file

@ -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. a release; CI reads the tag, the code reads this constant.
""" """
__version__ = "1.1.3" __version__ = "1.2.0"
GITHUB_REPO = "Engelgardt23/dhcpsrv" GITHUB_REPO = "Engelgardt23/dhcpsrv"

View file

@ -17,37 +17,46 @@ from .update_check import check_for_update
from .network import list_adapters, set_static_ip, revert_to_dhcp from .network import list_adapters, set_static_ip, revert_to_dhcp
from .dhcp import DhcpConfig, DhcpServer, now_s from .dhcp import DhcpConfig, DhcpServer, now_s
from .ui import Ui from .ui import Ui
from .config import load_config
from .i18n import set_language, t
def _select_nic(console: Console) -> dict | None: def _select_nic(console: Console) -> dict | None:
console.rule("[bold cyan]Available adapters") console.rule(f"[bold cyan]{t('available_adapters')}")
adapters = list_adapters() adapters = list_adapters()
if not adapters: if not adapters:
console.print("[red]No suitable wired adapters found.[/]") console.print(f"[red]{t('no_adapters')}[/]")
return None return None
for i, a in enumerate(adapters, 1): for i, a in enumerate(adapters, 1):
ip = a.get("IPv4") or "" ip = a.get("IPv4") or ""
console.print(f" {i}) [{a['Status']}] {a['Name']} ({a['Description']}) {ip}") console.print(f" {i}) [{a['Status']}] {a['Name']} ({a['Description']}) {ip}")
while True: 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): if s.isdigit() and 1 <= int(s) <= len(adapters):
return adapters[int(s) - 1] return adapters[int(s) - 1]
console.print("[red]Invalid selection.[/]") console.print(f"[red]{t('invalid_selection')}[/]")
def main() -> None: def main() -> None:
enable_vt() 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() require_admin()
console = Console(log_path=False) 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() latest = check_for_update()
if latest: if latest:
header = Table.grid(expand=True) header = Table.grid(expand=True)
header.add_column(justify="left", ratio=1) header.add_column(justify="left", ratio=1)
header.add_column(justify="right") 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) console.print(header)
else: else:
console.print(title) console.print(title)
@ -55,10 +64,10 @@ def main() -> None:
nic = _select_nic(console) nic = _select_nic(console)
if not nic: if not nic:
input("Press Enter to exit"); return input(t("press_enter")); return
cfg = DhcpConfig.with_defaults() 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) set_static_ip(nic["Name"], cfg.server_ip, cfg.netmask)
# Wire server <-> ui through callbacks so neither imports the other. # Wire server <-> ui through callbacks so neither imports the other.
@ -74,15 +83,15 @@ def main() -> None:
stop.set() stop.set()
try: try:
print() print()
print(f"[{now_s()}] Shutting down...") print(t("shutting_down", ts=now_s()))
try: 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"]) revert_to_dhcp(nic["Name"])
print("NIC reverted to DHCP") print(t("nic_reverted"))
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
pass pass
finally: finally:
input("Press Enter to exit") input(t("press_enter"))
sys.exit(0) sys.exit(0)
signal.signal(signal.SIGINT, shutdown) signal.signal(signal.SIGINT, shutdown)

94
src/dhcpsrv/config.py Normal file
View file

@ -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}

View file

@ -15,6 +15,7 @@ from datetime import datetime
from typing import Callable, Optional from typing import Callable, Optional
from .network import ping_one from .network import ping_one
from .i18n import t as _t
# ---------- helpers ---------- # ---------- helpers ----------
@ -174,8 +175,8 @@ class DhcpServer:
try: try:
s.bind(("0.0.0.0", 67)) s.bind(("0.0.0.0", 67))
except OSError as e: except OSError as e:
self.log(f"[bold red]bind UDP/67 failed:[/] {e}") self.log(f"[bold red]{_t('bind_failed')}[/] {e}")
self.log("[yellow]Another DHCP service (Tftpd32 DHCP, ICS, Windows DHCP) may be running.[/]") self.log(f"[yellow]{_t('bind_hint')}[/]")
stop.set() stop.set()
return return
@ -200,7 +201,7 @@ class DhcpServer:
self.stats["discovers"] += 1 self.stats["discovers"] += 1
ipn = self.alloc_ip(mac) ipn = self.alloc_ip(mac)
if ipn is None: 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 continue
self.touch_client(mac, ipn, host) self.touch_client(mac, ipn, host)
s.sendto(self.build_reply(data, 2, ipn), ("255.255.255.255", 68)) s.sendto(self.build_reply(data, 2, ipn), ("255.255.255.255", 68))

115
src/dhcpsrv/i18n.py Normal file
View file

@ -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

View file

@ -19,6 +19,7 @@ from rich.text import Text
from . import __version__ from . import __version__
from .dhcp import DhcpServer, int2ip from .dhcp import DhcpServer, int2ip
from .i18n import t as _t
# Fixed-size layout slots — used to compute the clients-table fit. # Fixed-size layout slots — used to compute the clients-table fit.
@ -54,27 +55,27 @@ class Ui:
cfg = self.server.cfg cfg = self.server.cfg
body = ( body = (
f"[bold cyan]dhcpsrv v{__version__}[/]\n" f"[bold cyan]dhcpsrv v{__version__}[/]\n"
f"Server: [bold]{cfg.server_ip}[/]/{cfg.netmask} " f"{_t('panel_server')}: [bold]{cfg.server_ip}[/]/{cfg.netmask} "
f"Pool: [bold]{int2ip(cfg.pool[0])}{int2ip(cfg.pool[-1])}[/] " f"{_t('panel_pool')}: [bold]{int2ip(cfg.pool[0])}{int2ip(cfg.pool[-1])}[/] "
f"Lease: [bold]{cfg.lease}s[/] " f"{_t('panel_lease')}: [bold]{cfg.lease}s[/] "
f"TFTP: [bold]{cfg.tftp}[/]\n" f"{_t('panel_tftp')}: [bold]{cfg.tftp}[/]\n"
f"Leases: [bold]{leased}/{len(cfg.pool)}[/] " f"{_t('panel_leases')}: [bold]{leased}/{len(cfg.pool)}[/] "
f"Pkts: [dim]{st['packets']}[/] " f"{_t('panel_pkts')}: [dim]{st['packets']}[/] "
f"DISCOVER: [cyan]{st['discovers']}[/] " f"DISCOVER: [cyan]{st['discovers']}[/] "
f"REQUEST: [green]{st['requests']}[/] " f"REQUEST: [green]{st['requests']}[/] "
f"RELEASE: [yellow]{st['releases']}[/] " f"RELEASE: [yellow]{st['releases']}[/] "
f"[dim]Ctrl+C to stop[/]" f"[dim]{_t('panel_ctrlc')}[/]"
) )
return Panel(body, border_style="cyan") return Panel(body, border_style="cyan")
def _render_table(self) -> Table: def _render_table(self) -> Table:
t = Table(expand=True, header_style="bold") tbl = Table(expand=True, header_style="bold")
t.add_column("#", style="dim", width=3, justify="right") tbl.add_column("#", style="dim", width=3, justify="right")
t.add_column("IP", width=16) tbl.add_column(_t("col_ip"), width=16)
t.add_column("Hostname", min_width=10) tbl.add_column(_t("col_host"), min_width=10)
t.add_column("MAC", width=19) tbl.add_column(_t("col_mac"), width=19)
t.add_column("Last seen", style="dim", width=10) tbl.add_column(_t("col_last"), style="dim", width=10)
t.add_column("Ping", width=6, justify="center") tbl.add_column(_t("col_ping"), width=6, justify="center")
with self.server.lock: with self.server.lock:
rows = sorted(self.server.clients.items(), key=lambda kv: kv[1]["ip_int"]) 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 rows = rows[: avail - 1] # leave one slot for the "(+N more)" marker
if not rows: if not rows:
t.add_row("", "", "(no clients yet)", "", "", "") tbl.add_row("", "", _t("no_clients"), "", "", "")
else: else:
for i, (mac, c) in enumerate(rows, 1): for i, (mac, c) in enumerate(rows, 1):
ping = (Text("OK", style="bold green") ping = (Text("OK", style="bold green")
if c.get("ping_ok") else Text("--", style="bold red")) if c.get("ping_ok") else Text("--", style="bold red"))
t.add_row( tbl.add_row(
str(i), str(i),
int2ip(c["ip_int"]), int2ip(c["ip_int"]),
c.get("host") or "", c.get("host") or "",
@ -99,20 +100,20 @@ class Ui:
ping, ping,
) )
if overflow: if overflow:
t.add_row("", "", f"[dim](+{overflow} more — enlarge the window)[/]", "", "", "") tbl.add_row("", "", f"[dim]{_t('more_clients', n=overflow)}[/]", "", "", "")
return t return tbl
def _render_events(self) -> Panel: def _render_events(self) -> Panel:
with self.events_lock: with self.events_lock:
last = list(self.events)[-20:] last = list(self.events)[-20:]
body = "\n".join(last) if last else "[dim](no events yet)[/]" body = "\n".join(last) if last else f"[dim]{_t('no_events')}[/]"
return Panel(body, title="Events", border_style="dim") return Panel(body, title=_t("events_title"), border_style="dim")
def _render_screen(self) -> Layout: def _render_screen(self) -> Layout:
layout = Layout() layout = Layout()
layout.split_column( layout.split_column(
Layout(self._render_header(), name="hdr", size=HEADER_LINES), 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), Layout(self._render_events(), name="evt", size=EVENTS_LINES),
) )
return layout return layout