From b923b9ebe708b864205d2a0caf11fe4c9f8a7c2b Mon Sep 17 00:00:00 2001 From: Engelgardt23 Date: Mon, 18 May 2026 17:57:29 +0300 Subject: [PATCH] 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 --- .github/workflows/release.yml | 84 +++++++++++++++++++ README.md | 88 +++++++++++++++----- README.ru.md | 99 +++++++++++++++++++++++ dev/assets/icon.ico | Bin 0 -> 47038 bytes dev/src/vrcx/__init__.py | 1 - dev/src/vrcx/app.py | 121 +++++++++++++++------------- dev/src/vrcx/config.py | 146 ++++++++++++++++++++++++++++++++++ dev/src/vrcx/i18n.py | 145 +++++++++++++++++++++++++++++++++ dev/src/vrcx/ui.py | 38 ++++----- dev/src/vrcx/update_check.py | 31 ++++---- dev/tools/make_icon.ps1 | 69 ++++++++++++++++ 11 files changed, 713 insertions(+), 109 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 README.ru.md create mode 100644 dev/assets/icon.ico create mode 100644 dev/src/vrcx/config.py create mode 100644 dev/src/vrcx/i18n.py create mode 100644 dev/tools/make_icon.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9d62a97 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,84 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write + +jobs: + build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build dependencies + run: python -m pip install --upgrade pip pyinstaller rich paramiko + + - name: Resolve version from tag + id: ver + shell: bash + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Build executable + run: python -m PyInstaller --onefile --console --name vrcx --icon dev/assets/icon.ico --paths dev/src dev/vrcx-launcher.py + + - name: Package portable folder + shell: pwsh + run: | + $ver = '${{ steps.ver.outputs.version }}' + $folder = "vrcx-v$ver" + New-Item -ItemType Directory -Path $folder | Out-Null + Copy-Item dist/vrcx.exe $folder/ + @" + vrcx v$ver - portable edition + + Vegman Remote Collect (extended) — diagnostic log collector for + YADRO Vegman servers. Pulls logs from the BMC and, optionally, the + SDS service OS in parallel. + + USAGE + Double-click vrcx.exe. + Paste one or more BMC IPs (space/comma/newline separated; empty line to end). + Enter BMC username (default admin) and password. + Answer "Collect OS logs too? [y/N]" — if yes, enter SDS user/pass. + Watch the live progress table. + When done you get a single out\\.tar.gz + ready to send to YADRO support. + + OUTPUT + out\\ + \bmc\ (BMC logs) + \os\ (SDS host logs, if enabled) + archives\dump_.tar.gz + .tar.gz (one-click bundle for support) + + NOTES + - Nothing is installed. Delete the folder to remove. + - BMC-only mode is 1:1 compatible with the original VRC tool. + "@ | Out-File -FilePath "$folder/README.txt" -Encoding UTF8 + Compress-Archive -Path $folder -DestinationPath "vrcx-portable-v$ver.zip" + + - name: Generate SHA-256 checksum + shell: pwsh + run: | + $ver = '${{ steps.ver.outputs.version }}' + $zip = "vrcx-portable-v$ver.zip" + $hash = (Get-FileHash -Algorithm SHA256 $zip).Hash.ToLower() + "$hash $zip" | Out-File -FilePath "$zip.sha256" -Encoding ASCII -NoNewline + Get-Content "$zip.sha256" + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + files: | + vrcx-portable-v${{ steps.ver.outputs.version }}.zip + vrcx-portable-v${{ steps.ver.outputs.version }}.zip.sha256 + generate_release_notes: true diff --git a/README.md b/README.md index c7347ff..95e97ff 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,98 @@ -# bmccollect +# vrcx -[![Latest release](https://img.shields.io/github/v/release/Engelgardt23/bmccollect)](https://github.com/Engelgardt23/bmccollect/releases/latest) +[![Latest release](https://img.shields.io/github/v/release/Engelgardt23/vrcx?include_prereleases&label=release)](https://github.com/Engelgardt23/vrcx/releases/latest) +[![Build](https://img.shields.io/github/actions/workflow/status/Engelgardt23/vrcx/release.yml?label=build)](https://github.com/Engelgardt23/vrcx/actions) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Lang: en \| ru](https://img.shields.io/badge/lang-en%20%7C%20ru-blue)](#) -A portable collector of YADRO BMC diagnostic logs. Re-implementation of the original VRC tool, packaged as a maintainable Python project — same output structure expected by YADRO support, but readable source, modular layout, CI-built releases. +🇺🇸 English | [🇷🇺 Русский](README.ru.md) -> **Made by engelgardt.** +**vrcx** — *Vegman Remote Collect (extended)*. A portable, scripted replacement for YADRO's `VRC.exe` that pulls diagnostic logs from a Vegman server **and** (optionally) from its SDS service OS, in parallel, into a single archive ready to send to support. + +The original VRC only touches the BMC. In real incidents support almost always asks for OS-side logs too (`lsiget`, `storcli`, `smartctl`, journals). With vrcx that's one double-click instead of a USB-stick run. --- +## What it does + +- Connects to **N BMCs in parallel**. Per host, also opens a **second** SSH session to the SDS service OS and runs both branches concurrently. +- SDS IP is discovered automatically: vrcx asks the BMC over Redfish for the host NIC's MAC, then matches it against the laptop's ARP table (warming it with a quick `/24` ping-sweep when needed). +- Each host gets a clean `bmc/` and `os/` sub-folder; all per-host tarballs land in a shared `archives/` folder and an outer bundle wraps the whole session. +- BMC-only mode (when *Collect OS logs too?* is answered *no*) produces an artefact set 1:1 compatible with the original VRC support flow. + ## Download -Grab the latest release: [**releases page**](https://github.com/Engelgardt23/bmccollect/releases/latest). -The asset is `bmccollect-portable-vX.Y.Z.zip`. +Grab the latest release: [**releases page**](https://github.com/Engelgardt23/vrcx/releases/latest). +The asset is `vrcx-portable-vX.Y.Z.zip`. ## Run 1. Unzip anywhere. -2. Double-click `bmccollect.exe`. -3. Paste one or more BMC IPs (whitespace / comma / newline separated). End input with an empty line. -4. Enter username (default `admin`) and password. -5. Watch the live progress table while the tool collects each BMC in parallel. -6. When it's done you get a single `out//.tar.gz` ready to send to support. +2. Double-click `vrcx.exe`. On the very first launch pick a language; the choice is saved into `config.ini` next to the exe. +3. Paste one or more **BMC** IPs (whitespace, comma, or newline separated). End input with an empty line. +4. Enter the BMC user (default `admin`) and password. +5. Answer **Collect OS logs too?** — if `yes`, enter SDS user/password (defaults `sds`/`sds`). vrcx will discover each SDS IP via Redfish→ARP, falling back to a manual prompt when needed. +6. Watch the live progress table — each row shows `BMC ok/total | OS ok/total` while collection runs. -`Ctrl+C` aborts. The output folder is kept regardless — you can pack it manually if needed. +`Ctrl+C` aborts and removes the incomplete session folder. -## What it collects +## Output -For each BMC: `inventory.json`, `lsinventory.json`, `sensors.log`, `sellog.log`, `bmc-state.txt`, `host-state.txt`, `bmc-net-cfg.log`, `cpuinfo`, `meminfo`, `osrelease`, `disk-usage.log`, `failed-services.log`, `top.log`, `bmc-journal_full_date.log`, journals for `obmc-console` and `obmc-yadro-vrm-setter`, a Redfish `/redfish/v1/Systems` dump, and others — see [`commands.py`](src/bmccollect/commands.py) for the full command table. Adding a new artefact is one line in that table. +``` +out// +├── / +│ ├── bmc/ BMC commands (inventory, sensors, sellog, Redfish, …) +│ └── os/ SDS host commands (lsiget, storcli, smartctl, journal, …) +├── / +│ ├── bmc/ +│ └── os/ +├── archives/ +│ ├── dump_.tar.gz +│ └── dump_.tar.gz +├── vrc.log +└── err_out.log + +out/.tar.gz ← one-click bundle for support +``` + +## Configuration + +`config.ini` lives next to the exe and is created on first run. Every option carries an inline description (EN + RU). Typical knobs: + +| Section | Key | Default | What | +|--- |--- |--- |--- | +| `General` | `language` | (asked) | `en` / `ru` | +| `BMC` | `default_user` | `admin` | Hit Enter on the BMC user prompt to accept this | +| `OS` | `collect_by_default` | `no` | Pre-tick the *Collect OS logs too?* answer | +| `OS` | `default_user` | `sds` | Default SDS user | +| `Discovery` | `ping_sweep` | `yes` | Warm the ARP table with a `/24` ping-sweep when SDS IP is unknown | +| `Run` | `parallel_hosts` | `8` | Max BMCs collected in parallel | + +## What gets collected + +| Side | Where it comes from | +|---|---| +| **BMC** | Per-spec table in [`commands.py`](dev/src/vrcx/commands.py): BMC CLI commands (`bmc info version`, `lsinventory -j`, `health logs show sellog`), `journalctl` units, file reads (`/proc/cpuinfo`, `/etc/os-release`, …), Redfish `/redfish/v1/Systems`. | +| **SDS host** | Per-spec table in [`os_commands.py`](dev/src/vrcx/os_commands.py): `lsigetlinux.sh`, `storcli64 /call show all`, `nvme list`, `smartctl -x` per drive, `dmidecode`, `dmesg -T`, `journalctl -b`, `/var/log/messages`, `lspci`, `lsblk`. | + +Adding a new artefact = one line in the relevant table. ## Compatibility -- Output structure mirrors VRC v1.1b — YADRO support flow is unchanged. -- Tested against `vegman-sx20` BMC firmware. -- Windows 10 / 11 host (the only place this tool runs). +- Output structure mirrors VRC v1.1b — YADRO support flow is unchanged for the BMC side. +- Targets: YADRO Vegman servers with OpenBMC + the SDS service OS (CentOS Stream 10 base). +- Host: Windows 10 / 11. No Python required on the laptop or on the server. ## Build from source ``` +git clone https://github.com/Engelgardt23/vrcx.git +cd vrcx python -m pip install rich paramiko pyinstaller -python -m PyInstaller --onefile --console --name bmccollect --paths src bmccollect-launcher.py +python -m PyInstaller --onefile --console --name vrcx --icon dev/assets/icon.ico --paths dev/src dev/vrcx-launcher.py ``` -See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full layout and release flow. +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full repo layout (`dev/` / `prod/` / `old/`) and release flow. ## License diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..743afc5 --- /dev/null +++ b/README.ru.md @@ -0,0 +1,99 @@ +# vrcx + +[![Последний релиз](https://img.shields.io/github/v/release/Engelgardt23/vrcx?include_prereleases&label=release)](https://github.com/Engelgardt23/vrcx/releases/latest) +[![Сборка](https://img.shields.io/github/actions/workflow/status/Engelgardt23/vrcx/release.yml?label=build)](https://github.com/Engelgardt23/vrcx/actions) +[![Лицензия: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Язык: en \| ru](https://img.shields.io/badge/lang-en%20%7C%20ru-blue)](#) + +[🇺🇸 English](README.md) | 🇷🇺 Русский + +**vrcx** — *Vegman Remote Collect (расширенный)*. Портативная замена штатного YADRO `VRC.exe`: вытаскивает диагностические логи с сервера Vegman **и**, по желанию, с сервисной ОС SDS — параллельно, в единый архив, готовый отправить в саппорт. + +Штатный VRC ходит только в BMC. На реальных инцидентах саппорту почти всегда нужны ещё и логи с ОС (`lsiget`, `storcli`, `smartctl`, journal). С vrcx это **один двойной клик** вместо беготни с флешкой. + +--- + +## Что делает + +- Параллельно опрашивает **N BMC**. Внутри каждого хоста — ещё одна SSH-сессия в сервисную ОС SDS, обе ветки идут одновременно. +- IP SDS определяет сам: спрашивает у BMC по Redfish MAC хостового порта, ищет его в ARP-таблице ноутбука (предварительно прогревая её `/24`-пинг-свипом). +- Каждый хост получает свои подпапки `bmc/` и `os/`; готовые архивы складываются в общий `archives/`, а вся сессия упаковывается во внешний tar.gz одним кликом. +- Если `Собирать ещё и логи с ОС? — нет` — получается набор файлов 1:1 совместимый с маршрутом саппорта по штатному VRC. + +## Скачать + +Последний релиз: [**страница релизов**](https://github.com/Engelgardt23/vrcx/releases/latest). +Архив: `vrcx-portable-vX.Y.Z.zip`. + +## Запуск + +1. Распакуй куда угодно. +2. Двойной клик по `vrcx.exe`. При первом запуске выбери язык; выбор сохраняется в `config.ini` рядом с exe. +3. Вставь один или несколько IP-адресов **BMC** через пробел, запятую или с новой строки. Заверши ввод пустой строкой. +4. Введи логин BMC (по умолчанию `admin`) и пароль. +5. Ответь на **Собирать ещё и логи с ОС?** — если `да`, введи логин/пароль SDS (по умолчанию `sds`/`sds`). vrcx сам найдёт IP SDS через Redfish→ARP; если не нашёл — спросит руками. +6. Смотри живую таблицу: каждая строка показывает `BMC ok/total | OS ok/total` пока идёт сбор. + +`Ctrl+C` — прерывание, неполная сессия удаляется. + +## Структура вывода + +``` +out/<ДДММГГГГ_ЧЧММСС>/ +├── / +│ ├── bmc/ команды для BMC (inventory, sensors, sellog, Redfish, …) +│ └── os/ команды для SDS (lsiget, storcli, smartctl, journal, …) +├── / +│ ├── bmc/ +│ └── os/ +├── archives/ +│ ├── dump_.tar.gz +│ └── dump_.tar.gz +├── vrc.log +└── err_out.log + +out/<ДДММГГГГ_ЧЧММСС>.tar.gz ← готовый бандл для саппорта +``` + +## Конфигурация + +`config.ini` лежит рядом с exe и создаётся при первом запуске. Каждая опция снабжена комментарием (EN + RU). Что можно крутить: + +| Секция | Ключ | Дефолт | Что делает | +|--- |--- |--- |--- | +| `General` | `language` | (спросит) | `en` / `ru` | +| `BMC` | `default_user` | `admin` | Enter на вопросе логина BMC примет это значение | +| `OS` | `collect_by_default` | `no` | Сразу включать «Собирать ещё и логи с ОС?» как «да» | +| `OS` | `default_user` | `sds` | Логин SDS по умолчанию | +| `Discovery` | `ping_sweep` | `yes` | Прогревать ARP-таблицу `/24`-пингом, если IP SDS неизвестен | +| `Run` | `parallel_hosts` | `8` | Макс. количество BMC, опрашиваемых параллельно | + +## Что собирает + +| Источник | Откуда | +|---|---| +| **BMC** | Таблица в [`commands.py`](dev/src/vrcx/commands.py): команды YADRO CLI (`bmc info version`, `lsinventory -j`, `health logs show sellog`), `journalctl`-юниты, чтение файлов (`/proc/cpuinfo`, `/etc/os-release`, …), Redfish `/redfish/v1/Systems`. | +| **SDS (ОС)** | Таблица в [`os_commands.py`](dev/src/vrcx/os_commands.py): `lsigetlinux.sh`, `storcli64 /call show all`, `nvme list`, `smartctl -x` по каждому диску, `dmidecode`, `dmesg -T`, `journalctl -b`, `/var/log/messages`, `lspci`, `lsblk`. | + +Добавить новый артефакт — одна строка в нужной таблице. + +## Совместимость + +- Структура вывода повторяет VRC v1.1b — маршрут саппорта по BMC-части не меняется. +- Цель: серверы YADRO Vegman с OpenBMC + сервисная ОС SDS (база CentOS Stream 10). +- Хост: Windows 10 / 11. Python ни на ноутбуке, ни на сервере не нужен. + +## Сборка из исходников + +``` +git clone https://github.com/Engelgardt23/vrcx.git +cd vrcx +python -m pip install rich paramiko pyinstaller +python -m PyInstaller --onefile --console --name vrcx --icon dev/assets/icon.ico --paths dev/src dev/vrcx-launcher.py +``` + +Раскладка репозитория (`dev/` / `prod/` / `old/`) и релизный flow описаны в [`CONTRIBUTING.md`](CONTRIBUTING.md). + +## Лицензия + +MIT — см. [LICENSE](LICENSE). diff --git a/dev/assets/icon.ico b/dev/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..0bb2a4a700afb8b348630a52ac2418d51962075a GIT binary patch literal 47038 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 literal 0 HcmV?d00001 diff --git a/dev/src/vrcx/__init__.py b/dev/src/vrcx/__init__.py index e4fac8b..011e321 100644 --- a/dev/src/vrcx/__init__.py +++ b/dev/src/vrcx/__init__.py @@ -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 diff --git a/dev/src/vrcx/app.py b/dev/src/vrcx/app.py index c0b330b..b40bd0e 100644 --- a/dev/src/vrcx/app.py +++ b/dev/src/vrcx/app.py @@ -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__": diff --git a/dev/src/vrcx/config.py b/dev/src/vrcx/config.py new file mode 100644 index 0000000..e749ce9 --- /dev/null +++ b/dev/src/vrcx/config.py @@ -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), + ) diff --git a/dev/src/vrcx/i18n.py b/dev/src/vrcx/i18n.py new file mode 100644 index 0000000..0b28484 --- /dev/null +++ b/dev/src/vrcx/i18n.py @@ -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 diff --git a/dev/src/vrcx/ui.py b/dev/src/vrcx/ui.py index ac399ca..782356c 100644 --- a/dev/src/vrcx/ui.py +++ b/dev/src/vrcx/ui.py @@ -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 diff --git a/dev/src/vrcx/update_check.py b/dev/src/vrcx/update_check.py index 1e36c98..ae967c9 100644 --- a/dev/src/vrcx/update_check.py +++ b/dev/src/vrcx/update_check.py @@ -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 diff --git a/dev/tools/make_icon.ps1 b/dev/tools/make_icon.ps1 new file mode 100644 index 0000000..656150d --- /dev/null +++ b/dev/tools/make_icon.ps1 @@ -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"