vrcx: i18n (en/ru), config.ini, clickable update check, prettier README

Adds first-run language prompt + persistent config.ini with bilingual
inline comments. Update check now returns just the tag; app.py renders
it as a clickable [link=...] in the header (matches dhcpsrv/netswitch
pattern). Strips all 'made by engelgardt' lines.

- new dev/src/vrcx/i18n.py (RU/EN translation table)
- new dev/src/vrcx/config.py (config.ini next to the exe)
- update_check.py: returns tag, no console writes
- app.py: load config, set lang, render clickable header, use config
  defaults for BMC/SDS user and parallel_hosts; pass cfg.ping_sweep to
  discover_sds_ip
- ui.py: all visible strings via t()
- README.md / README.ru.md: rewritten under the vrcx name and brief
This commit is contained in:
Engelgardt23
2026-05-18 17:57:29 +03:00
parent 93cc775e80
commit b923b9ebe7
11 changed files with 713 additions and 109 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

-1
View File
@@ -1,6 +1,5 @@
"""
vrcx - Vegman Remote Collect (extended).
made by engelgardt
Portable diagnostic collector for YADRO Vegman servers. Connects (in parallel)
to one or more BMC hosts and, optionally, to their SDS service OS, runs a
+67 -54
View File
@@ -1,7 +1,8 @@
"""
Application entry: collects credentials + IP list (BMC + optional SDS),
resolves SDS IPs via Redfish→ARP, runs the BMC and OS collectors in
parallel per host, shows a live TUI, and packs the result.
Application entry: loads config (with first-run language prompt), runs the
update check, collects credentials + IP list (BMC + optional SDS), resolves
SDS IPs via Redfish→ARP, runs BMC and OS collectors in parallel per host,
shows a live TUI, and packs the result.
"""
from __future__ import annotations
@@ -16,11 +17,14 @@ from pathlib import Path
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 .bmc import BmcSession
from .collector import collect_host
from .config import load_config, Config
from .discover import discover_sds_ip
from .i18n import set_language, t
from .os_collector import collect_host_os
from .platform_win import enable_vt
from .tarball import (
@@ -36,7 +40,7 @@ _IP_RE = re.compile(r"^(?:\d{1,3}\.){3}\d{1,3}$")
def _parse_ips(raw: str) -> list[str]:
tokens = re.split(r"[\s,;]+", (raw or "").strip())
return [t for t in tokens if _IP_RE.match(t)]
return [tok for tok in tokens if _IP_RE.match(tok)]
@dataclass
@@ -49,10 +53,10 @@ class Inputs:
sds_pass: str
def _prompt_inputs(console: Console) -> Inputs | None:
console.rule("[bold cyan]Targets")
console.print("Enter one or more BMC IP addresses, separated by spaces, commas, or newlines.")
console.print("[dim](End input with an empty line.)[/]")
def _prompt_inputs(console: Console, cfg: Config) -> Inputs | None:
console.rule(f"[bold cyan]{t('targets_rule')}")
console.print(t("enter_bmc_ips"))
console.print(f"[dim]{t('end_with_blank')}[/]")
lines: list[str] = []
while True:
try:
@@ -67,43 +71,38 @@ def _prompt_inputs(console: Console) -> Inputs | None:
lines.append(line)
hosts = _parse_ips(" ".join(lines))
if not hosts:
console.print("[red]No valid IP addresses entered.[/]")
console.print(f"[red]{t('no_ips')}[/]")
return None
console.print()
bmc_user = Prompt.ask("BMC username", default="admin")
bmc_pass = Prompt.ask("BMC password (visible)")
bmc_user = Prompt.ask(t("bmc_user"), default=cfg.bmc_default_user)
bmc_pass = Prompt.ask(t("bmc_pass"))
console.print()
collect_os = Confirm.ask("Collect OS logs too?", default=False)
sds_user, sds_pass = "sds", "sds"
collect_os = Confirm.ask(t("ask_collect_os"), default=cfg.collect_by_default)
sds_user, sds_pass = cfg.sds_default_user, "sds"
if collect_os:
sds_user = Prompt.ask("SDS username", default="sds")
sds_pass = Prompt.ask("SDS password (visible)", default="sds")
sds_user = Prompt.ask(t("sds_user"), default=cfg.sds_default_user)
sds_pass = Prompt.ask(t("sds_pass"), default="sds")
return Inputs(hosts=hosts, bmc_user=bmc_user, bmc_pass=bmc_pass,
collect_os=collect_os, sds_user=sds_user, sds_pass=sds_pass)
def _resolve_sds_ip(host: str, bmc_user: str, bmc_pass: str,
def _resolve_sds_ip(host: str, bmc_user: str, bmc_pass: str, cfg: Config,
console: Console) -> str | None:
"""Open the BMC, try to discover the SDS IP. On failure prompt the user.
Returns the IP, or None when the user chose to skip OS collection."""
console.print(f"[dim]Resolving SDS IP for {host}...[/]")
console.print(f"[dim]{t('resolving_sds', host=host)}[/]")
ip: str | None = None
try:
with BmcSession(host=host, user=bmc_user, password=bmc_pass) as bmc:
ip = discover_sds_ip(bmc)
ip = discover_sds_ip(bmc, do_sweep=cfg.ping_sweep)
except Exception as exc:
console.print(f"[yellow] BMC {host}: discovery failed ({exc})[/]")
console.print(f"[yellow]{t('discovery_failed', host=host, err=exc)}[/]")
if ip:
console.print(f"[green] → SDS IP for {host}: {ip}[/]")
console.print(f"[green]{t('sds_resolved', host=host, ip=ip)}[/]")
return ip
console.print(f"[yellow] SDS IP for {host} not auto-discovered.[/]")
manual = Prompt.ask(
f" Enter SDS IP for {host} (empty to skip OS for this host)",
default="",
).strip()
console.print(f"[yellow]{t('sds_not_found', host=host)}[/]")
manual = Prompt.ask(t("manual_sds_prompt", host=host), default="").strip()
return manual or None
@@ -111,19 +110,19 @@ def _host_worker(host: str, bmc_user: str, bmc_pass: str,
sds_ip: str | None, sds_user: str, sds_pass: str,
session_dir: Path, ui: Ui) -> dict:
ui.set_status(host, "CONNECTING")
ui.log(f"[cyan]{host}[/] starting...")
ui.log(f"[cyan]{t('host_starting', host=host)}[/]")
per_host = make_per_host_dir(session_dir, host)
def bmc_progress(step: int, total: int, label: str, ok_n: int, fail_n: int) -> None:
ui.set_status(host, "COLLECTING")
ui.set_progress(host, "bmc", step, total, label, ok_n, fail_n)
ui.log(f"[cyan]{host}/bmc[/] → {label}")
ui.log(f"[cyan]{t('host_step_bmc', host=host, label=label)}[/]")
def os_progress(step: int, total: int, label: str, ok_n: int, fail_n: int) -> None:
ui.set_status(host, "COLLECTING")
ui.set_progress(host, "os", step, total, label, ok_n, fail_n)
ui.log(f"[cyan]{host}/os[/] → {label}")
ui.log(f"[cyan]{t('host_step_os', host=host, label=label)}[/]")
bmc_summary: dict = {"status": "skip", "ok": 0, "fail": 0, "total": 0, "error": "", "serial": ""}
os_summary: dict = {"status": "skip", "ok": 0, "fail": 0, "total": 0, "error": ""}
@@ -151,14 +150,13 @@ def _host_worker(host: str, bmc_user: str, bmc_pass: str,
if bmc_ok and os_ok:
ui.set_status(host, "DONE")
ui.set_summary(host, total_ok, total_fail, bmc_summary.get("serial", ""))
os_note = "" if sds_ip is None else f", OS {os_summary['ok']}/{os_summary['total']} ok"
ui.log(f"[green]{host}[/] done — BMC {bmc_summary['ok']}/{bmc_summary['total']} ok"
f"{os_note}.")
os_note = "" if sds_ip is None else t("os_note", ok=os_summary['ok'], total=os_summary['total'])
ui.log(f"[green]{t('host_done', host=host, bmc_ok=bmc_summary['ok'], bmc_total=bmc_summary['total'], os_note=os_note)}[/]")
else:
ui.set_status(host, "ERROR")
err = (bmc_summary.get("error") or "") if not bmc_ok else (os_summary.get("error") or "")
ui.set_summary(host, total_ok, total_fail, bmc_summary.get("serial", ""), err[:80])
ui.log(f"[red]{host}[/] FAILED — {err}")
ui.log(f"[red]{t('host_failed', host=host, err=err)}[/]")
return {
"host": host,
@@ -168,28 +166,42 @@ def _host_worker(host: str, bmc_user: str, bmc_pass: str,
}
def main() -> None:
enable_vt()
console = Console(log_path=False)
console.print(f"[bold cyan]vrcx v{__version__}[/] - Vegman Remote Collect (extended)")
console.print("[dim]made by engelgardt[/]")
def _print_header(console: Console) -> None:
title = f"[bold cyan]vrcx 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()
check_for_update(console)
inputs = _prompt_inputs(console)
def main() -> None:
enable_vt()
cfg = load_config()
set_language(cfg.language)
console = Console(log_path=False)
_print_header(console)
inputs = _prompt_inputs(console, cfg)
if inputs is None:
input("Press Enter to exit"); return
input(t("press_enter")); return
# Resolve SDS IPs sequentially (so user prompts don't collide with workers).
sds_ips: dict[str, str | None] = {}
if inputs.collect_os:
console.rule("[bold cyan]SDS discovery")
console.rule(f"[bold cyan]{t('discovery_rule')}")
for h in inputs.hosts:
sds_ips[h] = _resolve_sds_ip(h, inputs.bmc_user, inputs.bmc_pass, console)
sds_ips[h] = _resolve_sds_ip(h, inputs.bmc_user, inputs.bmc_pass, cfg, console)
enabled = {h for h, ip in sds_ips.items() if ip}
# Output anchor: next to the .exe when frozen, cwd otherwise.
if getattr(sys, "frozen", False):
anchor = Path(sys.executable).resolve().parent
else:
@@ -209,7 +221,8 @@ def main() -> None:
aborted = False
outer: Path | None = None
try:
with ThreadPoolExecutor(max_workers=min(8, max(2, len(inputs.hosts)))) as ex:
pool_size = min(max(2, cfg.parallel_hosts), max(2, len(inputs.hosts)))
with ThreadPoolExecutor(max_workers=pool_size) as ex:
futures: list[Future] = [
ex.submit(
_host_worker,
@@ -224,11 +237,11 @@ def main() -> None:
summaries.append(fut.result())
except KeyboardInterrupt:
aborted = True
ui.log("[yellow]Aborted by user — removing the incomplete session folder...[/]")
ui.log(f"[yellow]{t('aborted')}[/]")
finally:
if aborted:
shutil.rmtree(session, ignore_errors=True)
ui.log(f"[yellow]Removed:[/] {session}")
ui.log(f"[yellow]{t('removed', path=session)}[/]")
else:
with open(session / "vrc.log", "w", encoding="utf-8") as f:
for s in summaries:
@@ -242,17 +255,17 @@ def main() -> None:
)
(session / "err_out.log").write_text("", encoding="utf-8")
outer = finalize_session(session)
ui.log(f"[bold green]Bundle ready:[/] {outer}")
ui.log(f"[bold green]{t('bundle_ready', path=outer)}[/]")
stop.set()
ui_thread.join(timeout=2.0)
console.print()
if aborted:
console.print("[yellow]Aborted. Session folder removed.[/]")
console.print(f"[yellow]{t('aborted_msg')}[/]")
else:
console.print(f"[bold green]Done.[/] Bundle: {outer}")
input("Press Enter to exit")
console.print(f"[bold green]{t('done', path=outer)}[/]")
input(t("press_enter"))
if __name__ == "__main__":
+146
View File
@@ -0,0 +1,146 @@
"""
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. On every subsequent run we just read it. The .ini has
bilingual comments explaining every option so the user can edit values by
hand.
"""
from __future__ import annotations
import configparser
import sys
from dataclasses import dataclass
from pathlib import Path
SUPPORTED_LANGS = ("en", "ru")
DEFAULT_LANG = "en"
CONFIG_TEMPLATE = """\
# ---------------------------------------------------------------------------
# vrcx — Vegman Remote Collect (extended)
# Configuration. Edit any value below to change behavior.
# Конфигурация. Изменить любое значение ниже — изменить поведение программы.
# ---------------------------------------------------------------------------
[General]
# Interface language. Valid: en, ru
# Язык интерфейса. Допустимые значения: en, ru
language = {language}
[BMC]
# Default BMC username (Enter accepts this on the prompt).
# Логин BMC по умолчанию (Enter на вопросе примет это значение).
default_user = admin
[OS]
# Pre-tick "Collect OS logs too?" prompt by default (yes/no).
# Сразу включать вопрос «Собирать ещё и логи с ОС?» как «да» (yes/no).
collect_by_default = no
# Default SDS host username.
# Логин SDS по умолчанию.
default_user = sds
[Discovery]
# Run a /24 ping-sweep to warm the ARP table when SDS IP is unknown (yes/no).
# Делать ping-sweep подсети /24 для прогрева ARP, если IP SDS неизвестен (yes/no).
ping_sweep = yes
[Run]
# Max BMCs collected in parallel (each host also runs BMC+OS in parallel inside).
# Макс. число BMC, опрашиваемых параллельно (внутри хоста BMC+OS идут тоже параллельно).
parallel_hosts = 8
"""
@dataclass
class Config:
language: str = DEFAULT_LANG
bmc_default_user: str = "admin"
sds_default_user: str = "sds"
collect_by_default: bool = False
ping_sweep: bool = True
parallel_hosts: int = 8
def app_dir() -> Path:
"""Directory holding the running executable (or source folder in dev mode)."""
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 language prompt — stdin only, before the Rich console exists."""
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_default_config(language: str) -> None:
path = config_path()
try:
path.write_text(CONFIG_TEMPLATE.format(language=language), encoding="utf-8")
except OSError:
# Read-only deployment location — fall back to in-memory defaults.
pass
def _to_bool(s: str, default: bool) -> bool:
s = (s or "").strip().lower()
if s in ("yes", "true", "y", "1", "on"): return True
if s in ("no", "false", "n", "0", "off"): return False
return default
def _to_int(s: str, default: int) -> int:
try:
return int((s or "").strip())
except ValueError:
return default
def load_config() -> Config:
"""Read config.ini next to the exe. On first run, prompt for language and
write a fresh, fully-commented config file."""
path = config_path()
if not path.exists():
lang = _ask_language()
_write_default_config(lang)
return Config(language=lang)
cp = configparser.ConfigParser()
try:
cp.read(path, encoding="utf-8")
except (configparser.Error, OSError):
return Config()
lang = (cp.get("General", "language", fallback=DEFAULT_LANG) or DEFAULT_LANG).strip().lower()
if lang not in SUPPORTED_LANGS:
lang = DEFAULT_LANG
return Config(
language = lang,
bmc_default_user = cp.get("BMC", "default_user", fallback="admin").strip() or "admin",
sds_default_user = cp.get("OS", "default_user", fallback="sds").strip() or "sds",
collect_by_default = _to_bool(cp.get("OS", "collect_by_default", fallback="no"), False),
ping_sweep = _to_bool(cp.get("Discovery", "ping_sweep", fallback="yes"), True),
parallel_hosts = _to_int (cp.get("Run", "parallel_hosts", fallback="8"), 8),
)
+145
View File
@@ -0,0 +1,145 @@
"""
Tiny in-memory translation table — EN/RU. No .po/.mo machinery for a small
CLI tool: a flat dict per language is enough.
Usage:
from .i18n import t, set_language
set_language("ru")
print(t("no_ips"))
`t(key, **params)` runs `.format(**params)` on the result, so placeholders
work just like f-strings.
"""
from __future__ import annotations
_lang = "en"
STRINGS: dict[str, dict[str, str]] = {
"en": {
# banner / header
"tagline": "- Vegman Remote Collect (extended)",
"update_available": "Update available ({tag})",
# input prompts
"targets_rule": "Targets",
"enter_bmc_ips": "Enter one or more BMC IP addresses, separated by spaces, commas, or newlines.",
"end_with_blank": "(End input with an empty line.)",
"no_ips": "No valid IP addresses entered.",
"bmc_user": "BMC username",
"bmc_pass": "BMC password (visible)",
"ask_collect_os": "Collect OS logs too?",
"sds_user": "SDS username",
"sds_pass": "SDS password (visible)",
# SDS discovery
"discovery_rule": "SDS discovery",
"resolving_sds": "Resolving SDS IP for {host}...",
"discovery_failed": " BMC {host}: discovery failed ({err})",
"sds_resolved": " → SDS IP for {host}: {ip}",
"sds_not_found": " SDS IP for {host} not auto-discovered.",
"manual_sds_prompt": " Enter SDS IP for {host} (empty to skip OS for this host)",
# progress / status
"host_starting": "{host} starting...",
"host_step_bmc": "{host}/bmc → {label}",
"host_step_os": "{host}/os → {label}",
"host_done": "{host} done — BMC {bmc_ok}/{bmc_total} ok{os_note}.",
"host_failed": "{host} FAILED — {err}",
"os_note": ", OS {ok}/{total} ok",
# finalization
"aborted": "Aborted by user — removing the incomplete session folder...",
"removed": "Removed: {path}",
"bundle_ready": "Bundle ready: {path}",
"aborted_msg": "Aborted. Session folder removed.",
"done": "Done. Bundle: {path}",
"press_enter": "Press Enter to exit",
# UI table
"col_host": "Host",
"col_status": "Status",
"col_step": "Step",
"col_okfail": "OK/Fail",
"col_serial": "Serial",
"col_note": "Note",
"events_title": "Events",
"hosts_title": "Hosts",
"no_events": "(no events yet)",
"more_hosts": "(+{n} more — enlarge window)",
"session_label": "Session",
"output_label": "Output",
"ctrlc_hint": "Ctrl+C — abort and delete this session folder.",
},
"ru": {
"tagline": "— Vegman Remote Collect (расширенный)",
"update_available": "Доступно обновление ({tag})",
"targets_rule": "Цели",
"enter_bmc_ips": "Введи один или несколько IP-адресов BMC через пробел, запятую или с новой строки.",
"end_with_blank": "(Закончи ввод пустой строкой.)",
"no_ips": "Корректные IP-адреса не введены.",
"bmc_user": "Логин BMC",
"bmc_pass": "Пароль BMC (виден на экране)",
"ask_collect_os": "Собирать ещё и логи с ОС?",
"sds_user": "Логин SDS",
"sds_pass": "Пароль SDS (виден на экране)",
"discovery_rule": "Поиск SDS",
"resolving_sds": "Ищу IP SDS для {host}...",
"discovery_failed": " BMC {host}: поиск не удался ({err})",
"sds_resolved": " → IP SDS для {host}: {ip}",
"sds_not_found": " IP SDS для {host} автоматически не найден.",
"manual_sds_prompt": " Введи IP SDS для {host} (пусто — пропустить ОС для этого хоста)",
"host_starting": "{host} стартую...",
"host_step_bmc": "{host}/bmc → {label}",
"host_step_os": "{host}/os → {label}",
"host_done": "{host} готово — BMC {bmc_ok}/{bmc_total} ок{os_note}.",
"host_failed": "{host} ОШИБКА — {err}",
"os_note": ", OS {ok}/{total} ок",
"aborted": "Отменено пользователем — удаляю незавершённую сессию...",
"removed": "Удалено: {path}",
"bundle_ready": "Архив готов: {path}",
"aborted_msg": "Отменено. Папка сессии удалена.",
"done": "Готово. Архив: {path}",
"press_enter": "Нажми Enter для выхода",
"col_host": "Хост",
"col_status": "Статус",
"col_step": "Шаг",
"col_okfail": "ОК/Ош",
"col_serial": "Серийник",
"col_note": "Примечание",
"events_title": "События",
"hosts_title": "Хосты",
"no_events": "(пока пусто)",
"more_hosts": "(ещё +{n} — увеличь окно)",
"session_label": "Сессия",
"output_label": "Вывод",
"ctrlc_hint": "Ctrl+C — прервать и удалить папку сессии.",
},
}
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`; fall back to EN if missing in the active language."""
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
+20 -18
View File
@@ -1,7 +1,7 @@
"""
Rich-based TUI:
┌─ vrcx v0.2.0 made by engelgardt ────────────────────────┐
┌─ vrcx v0.2.0 ────────────────────────────────────────────┐
│ Session: 16052026_124500 out/16052026_124500/... │
└──────────────────────────────────────────────────────────┘
┌─ Hosts ──────────────────────────────────────────────────┐
@@ -29,7 +29,8 @@ from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from . import __version__
from . import __version__
from .i18n import t
HEADER_LINES = 4
@@ -118,8 +119,9 @@ class Ui:
def _render_header(self) -> Panel:
body = (
f"[bold cyan]vrcx v{__version__}[/]\n"
f"Session: [bold]{self.session_label}[/] Output: [dim]{self.out_path}[/]\n"
f"[bold yellow]Ctrl+C[/] — abort and delete this session folder."
f"{t('session_label')}: [bold]{self.session_label}[/] "
f"{t('output_label')}: [dim]{self.out_path}[/]\n"
f"[bold yellow]Ctrl+C[/] — {t('ctrlc_hint')}"
)
return Panel(body, border_style="cyan")
@@ -131,14 +133,14 @@ class Ui:
return f"{label} {r[prefix + 'step']}/{total} {r[prefix + 'label']}"
def _render_table(self) -> Table:
t = Table(expand=True, header_style="bold")
t.add_column("#", style="dim", width=3, justify="right")
t.add_column("Host", width=18)
t.add_column("Status", width=12)
t.add_column("Step", overflow="ellipsis")
t.add_column("OK/Fail", width=10, justify="right")
t.add_column("Serial", width=14)
t.add_column("Note", overflow="ellipsis")
tbl = Table(expand=True, header_style="bold")
tbl.add_column("#", style="dim", width=3, justify="right")
tbl.add_column(t("col_host"), width=18)
tbl.add_column(t("col_status"), width=12)
tbl.add_column(t("col_step"), overflow="ellipsis")
tbl.add_column(t("col_okfail"), width=10, justify="right")
tbl.add_column(t("col_serial"), width=14)
tbl.add_column(t("col_note"), overflow="ellipsis")
with self.rows_lock:
items = list(self.rows.items())
@@ -158,7 +160,7 @@ class Ui:
ok_total = r["bmc_ok"] + r["os_ok"]
fail_total = r["bmc_fail"] + r["os_fail"]
note = r.get("error") or ""
t.add_row(
tbl.add_row(
str(i),
host,
Text(r["status"], style=style),
@@ -168,20 +170,20 @@ class Ui:
note,
)
if overflow:
t.add_row("", "", "", f"(+{overflow} more — enlarge window)", "", "", "")
return t
tbl.add_row("", "", "", t("more_hosts", 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="Hosts", border_style="cyan"), name="tbl"),
Layout(Panel(self._render_table(), title=t("hosts_title"), border_style="cyan"), name="tbl"),
Layout(self._render_events(), name="evt", size=EVENTS_LINES),
)
return layout
+14 -17
View File
@@ -1,12 +1,14 @@
"""Auto-update check on startup. Same pattern as in dhcpsrv/netswitch."""
"""
Auto-update check.
On startup, ask GitHub for the latest release tag. If it's newer than the
local `__version__`, return the tag string — the caller renders a clickable
hint next to the title. 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
@@ -22,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') when it is newer than
the currently running version. Returns None when up-to-date, offline,
rate-limited or on any error — caller decides how to render."""
try:
url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
req = urllib.request.Request(url, headers={
@@ -32,16 +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:
pass
return None
+69
View File
@@ -0,0 +1,69 @@
# Regenerate assets/icon.ico for vrcx.
# Edit $Text / colors below and re-run.
param(
[string]$Text = "VRCX",
[int[]]$ColorFrom = @(0, 200, 220),
[int[]]$ColorTo = @(0, 90, 130),
[string]$OutPath = "$PSScriptRoot\..\assets\icon.ico"
)
Add-Type -AssemblyName System.Drawing
$outDir = Split-Path $OutPath -Parent
if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }
$bgFrom = [System.Drawing.Color]::FromArgb(255, $ColorFrom[0], $ColorFrom[1], $ColorFrom[2])
$bgTo = [System.Drawing.Color]::FromArgb(255, $ColorTo[0], $ColorTo[1], $ColorTo[2])
$sizes = 256, 128, 64, 48, 32, 16
$pngs = @()
foreach ($s in $sizes) {
$bmp = New-Object System.Drawing.Bitmap $s, $s
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = 'AntiAlias'
$g.TextRenderingHint = 'AntiAliasGridFit'
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
$r = $s - 2
$path.AddEllipse(1, 1, $r, $r)
$brush = New-Object System.Drawing.Drawing2D.PathGradientBrush($path)
$brush.CenterColor = $bgFrom
$brush.SurroundColors = @($bgTo)
$g.FillEllipse($brush, 1, 1, $r, $r)
$pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(60, 0, 0, 0)), ([float]($s/64))
$g.DrawEllipse($pen, 1, 1, $r, $r)
$fontSize = [float]($s * 0.32)
$font = New-Object System.Drawing.Font "Segoe UI Black", $fontSize, ([System.Drawing.FontStyle]::Bold), ([System.Drawing.GraphicsUnit]::Pixel)
$sf = New-Object System.Drawing.StringFormat
$sf.Alignment = 'Center'; $sf.LineAlignment = 'Center'
$shadow = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(80, 0, 0, 0))
$g.DrawString($Text, $font, $shadow, [float]($s/2 + $s*0.02), [float]($s/2 + $s*0.02), $sf)
$g.DrawString($Text, $font, [System.Drawing.Brushes]::White, [float]($s/2), [float]($s/2), $sf)
$g.Dispose()
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$pngs += ,@{ size = $s; bytes = $ms.ToArray() }
$ms.Dispose(); $bmp.Dispose()
}
$fs = [System.IO.File]::Create($OutPath)
$bw = New-Object System.IO.BinaryWriter $fs
$bw.Write([uint16]0)
$bw.Write([uint16]1)
$bw.Write([uint16]$pngs.Count)
$offset = 6 + 16 * $pngs.Count
foreach ($p in $pngs) {
$w = if ($p.size -ge 256) { 0 } else { $p.size }
$bw.Write([byte]$w); $bw.Write([byte]$w)
$bw.Write([byte]0); $bw.Write([byte]0)
$bw.Write([uint16]1); $bw.Write([uint16]32)
$bw.Write([uint32]$p.bytes.Length); $bw.Write([uint32]$offset)
$offset += $p.bytes.Length
}
foreach ($p in $pngs) { $bw.Write($p.bytes) }
$bw.Close(); $fs.Close()
Write-Host "Wrote $OutPath"