7 Commits

11 changed files with 411 additions and 63 deletions
+4 -1
View File
@@ -7,7 +7,10 @@ dist/
__pycache__/
*.py[cod]
# Distribution staging folders (built per-version, attached to GitHub Releases)
# Local build cache (prod/test/old portable folders per version)
builds/
# Legacy staging folders (kept for compatibility with old checkouts)
portable-v*/
# Local backup of release archives (kept locally for history, not in repo)
+23 -1
View File
@@ -6,6 +6,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
## [Unreleased]
## [1.2.1] - 2026-05-18
### Changed
- The `Update available (vX.Y.Z)` hint in the header is now a clickable hyperlink that opens the GitHub releases page (OSC 8 terminal hyperlink). Modern terminals (Windows Terminal, VS Code, WezTerm, most Linux/macOS terminals) render it as a link — `Ctrl+Click` to follow. Older consoles show the plain text, so nothing breaks.
- Russian tagline tightened: dropped the `для инженера` phrase, the wording was carried over from an earlier draft and felt out of place.
## [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.
### Fixed
- Bumped `__version__` from `1.1.1` to `1.1.3` after the v1.1.2 release packaged a binary that still self-reported as v1.1.1 (the source constant was not bumped before tagging). The source is now once again the single source of truth.
## [1.1.2] - 2026-05-17
### Changed
- Dropped the `made by engelgardt` line from the startup banner too — author credit lives in the README only.
@@ -34,6 +51,11 @@ 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.0...HEAD
[Unreleased]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.2.1...HEAD
[1.2.1]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.2.0...v1.2.1
[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
[1.1.0]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/Engelgardt23/dhcpsrv/releases/tag/v1.0.0
+5 -3
View File
@@ -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.
@@ -43,8 +45,8 @@ The asset is `dhcpsrv-portable-vX.Y.Z.zip` (~12 MB).
└─────────────────────────────────────────────────────────────────────────┘
┌─ Clients ───────────────────────────────────────────────────────────────┐
│ # │ IP │ Hostname │ MAC │ Last seen │ Ping │
│ 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 │ -- │
│ 1 │ 10.10.10.2 │ server-01 │ a0:c5:f2:13:57:46 │ 17:42:18 │ OK │
│ 2 │ 10.10.10.3 │ server-02 │ 70:b3:d5:11:22:33 │ 17:42:21 │ -- │
└─────────────────────────────────────────────────────────────────────────┘
┌─ Events ────────────────────────────────────────────────────────────────┐
│ [17:42:18] DISCOVER a0:c5:f2:13:57:46 → OFFER 10.10.10.2 │
@@ -54,7 +56,7 @@ The asset is `dhcpsrv-portable-vX.Y.Z.zip` (~12 MB).
## Typical scenarios
- **VEGMAN with shared LOM** — one cable into the BMC/host port, BMC and the host OS both get IPs from this DHCP.
- **Server with shared LOM** — one cable into the BMC/host port, BMC and the host OS both get IPs from this DHCP.
- **8-port switch** — laptop on one port, up to 7 servers on the rest; the 50-address pool covers everyone.
- **Direct cable into a dedicated Mgmt port** — single client (the BMC).
+99
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 │ server-01 │ a0:c5:f2:13:57:46 │ 17:42:18 │ OK │
│ 2 │ 10.10.10.3 │ server-02 │ 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 │
└─────────────────────────────────────────────────────────────────────────┘
```
## Типичные сценарии
- **Сервер с общим 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).
+1 -1
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.
"""
__version__ = "1.1.1"
__version__ = "1.2.1"
GITHUB_REPO = "Engelgardt23/dhcpsrv"
+34 -14
View File
@@ -9,47 +9,67 @@ import threading
from rich.console import Console
from rich.prompt import Confirm, Prompt
from rich.table import Table
from . import __version__
from . import __version__, GITHUB_REPO
from .platform_win import enable_vt, require_admin
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)
console.print(f"[bold cyan]dhcpsrv v{__version__}[/] - portable laptop-side DHCP server")
console.print()
check_for_update(console)
title = f"[bold cyan]dhcpsrv v{__version__}[/] {t('tagline')}"
latest = check_for_update()
if latest:
release_url = f"https://github.com/{GITHUB_REPO}/releases/latest"
notice = t("update_available", tag=latest)
header = Table.grid(expand=True)
header.add_column(justify="left", ratio=1)
header.add_column(justify="right")
header.add_row(title, f"[dim][link={release_url}]{notice}[/link][/]")
console.print(header)
else:
console.print(title)
console.print()
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.
@@ -65,15 +85,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)
+94
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}
+4 -3
View File
@@ -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))
+115
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
+22 -21
View File
@@ -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
+10 -19
View File
@@ -2,16 +2,13 @@
Auto-update check.
On startup, ask GitHub for the latest release tag. If it's newer than the
local `__version__`, ask the user whether to open the download page in a
browser. Silent on any error (offline, rate-limit, etc.)."""
local `__version__`, return the tag string so the caller can show a quiet
hint in the header. Silent on any error (offline, rate-limit, etc.).
"""
from __future__ import annotations
import json
import urllib.request
import webbrowser
from rich.console import Console
from rich.prompt import Confirm
from . import __version__, GITHUB_REPO
@@ -27,7 +24,10 @@ def _parse_version(s: str) -> tuple[int, int, int]:
return (0, 0, 0)
def check_for_update(console: Console) -> None:
def check_for_update() -> str | None:
"""Return the latest release tag (e.g. 'v1.2.0') if it is newer than the
currently running version. Returns None when up to date, offline, or on
any error — the caller decides how (or whether) to render the hint."""
try:
url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
req = urllib.request.Request(url, headers={
@@ -37,17 +37,8 @@ def check_for_update(console: Console) -> None:
with urllib.request.urlopen(req, timeout=3) as r:
data = json.loads(r.read().decode("utf-8", errors="replace"))
latest = (data.get("tag_name") or "").strip()
page = data.get("html_url") or f"https://github.com/{GITHUB_REPO}/releases/latest"
if _parse_version(latest) > _parse_version(__version__):
console.rule("[bold yellow]Update available")
console.print(f"Current: [dim]v{__version__}[/] Latest: [bold green]{latest}[/]")
try:
if Confirm.ask("Open the download page in your browser?", default=True):
webbrowser.open(page)
except (EOFError, KeyboardInterrupt):
pass
console.print()
if latest and _parse_version(latest) > _parse_version(__version__):
return latest
except Exception:
# Offline / API error — silent on purpose.
pass
return None