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 0000000..0bb2a4a Binary files /dev/null and b/dev/assets/icon.ico differ 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"