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:
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user