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

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

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

84
.github/workflows/release.yml vendored Normal file
View file

@ -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\<DDMMYYYY_HHMMSS>\<stamp>.tar.gz
ready to send to YADRO support.
OUTPUT
out\<DDMMYYYY_HHMMSS>\
<bmc_ip>\bmc\ (BMC logs)
<bmc_ip>\os\ (SDS host logs, if enabled)
archives\dump_<bmc_ip>.tar.gz
<DDMMYYYY_HHMMSS>.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

View file

@ -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/<DDMMYYYY_HHMMSS>/<stamp>.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/<DDMMYYYY_HHMMSS>/
├── <bmc_ip>/
│ ├── bmc/ BMC commands (inventory, sensors, sellog, Redfish, …)
│ └── os/ SDS host commands (lsiget, storcli, smartctl, journal, …)
├── <bmc_ip_2>/
│ ├── bmc/
│ └── os/
├── archives/
│ ├── dump_<bmc_ip>.tar.gz
│ └── dump_<bmc_ip_2>.tar.gz
├── vrc.log
└── err_out.log
out/<DDMMYYYY_HHMMSS>.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

99
README.ru.md Normal file
View file

@ -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_ip>/
│ ├── bmc/ команды для BMC (inventory, sensors, sellog, Redfish, …)
│ └── os/ команды для SDS (lsiget, storcli, smartctl, journal, …)
├── <bmc_ip_2>/
│ ├── bmc/
│ └── os/
├── archives/
│ ├── dump_<bmc_ip>.tar.gz
│ └── dump_<bmc_ip_2>.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).

BIN
dev/assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -1,6 +1,5 @@
"""
vrcx - Vegman Remote Collect (extended).
made by engelgardt
Portable diagnostic collector for YADRO Vegman servers. Connects (in parallel)
to one or more BMC hosts and, optionally, to their SDS service OS, runs a

View file

@ -1,7 +1,8 @@
"""
Application entry: collects credentials + IP list (BMC + optional SDS),
resolves SDS IPs via RedfishARP, 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 RedfishARP, runs BMC and OS collectors in parallel per host,
shows a live TUI, and packs the result.
"""
from __future__ import annotations
@ -16,11 +17,14 @@ from pathlib import Path
from rich.console import Console
from rich.prompt import Confirm, Prompt
from rich.table import Table
from . import __version__
from . import __version__, GITHUB_REPO
from .bmc import BmcSession
from .collector import collect_host
from .config import load_config, Config
from .discover import discover_sds_ip
from .i18n import set_language, t
from .os_collector import collect_host_os
from .platform_win import enable_vt
from .tarball import (
@ -36,7 +40,7 @@ _IP_RE = re.compile(r"^(?:\d{1,3}\.){3}\d{1,3}$")
def _parse_ips(raw: str) -> list[str]:
tokens = re.split(r"[\s,;]+", (raw or "").strip())
return [t for t in tokens if _IP_RE.match(t)]
return [tok for tok in tokens if _IP_RE.match(tok)]
@dataclass
@ -49,10 +53,10 @@ class Inputs:
sds_pass: str
def _prompt_inputs(console: Console) -> Inputs | None:
console.rule("[bold cyan]Targets")
console.print("Enter one or more BMC IP addresses, separated by spaces, commas, or newlines.")
console.print("[dim](End input with an empty line.)[/]")
def _prompt_inputs(console: Console, cfg: Config) -> Inputs | None:
console.rule(f"[bold cyan]{t('targets_rule')}")
console.print(t("enter_bmc_ips"))
console.print(f"[dim]{t('end_with_blank')}[/]")
lines: list[str] = []
while True:
try:
@ -67,43 +71,38 @@ def _prompt_inputs(console: Console) -> Inputs | None:
lines.append(line)
hosts = _parse_ips(" ".join(lines))
if not hosts:
console.print("[red]No valid IP addresses entered.[/]")
console.print(f"[red]{t('no_ips')}[/]")
return None
console.print()
bmc_user = Prompt.ask("BMC username", default="admin")
bmc_pass = Prompt.ask("BMC password (visible)")
bmc_user = Prompt.ask(t("bmc_user"), default=cfg.bmc_default_user)
bmc_pass = Prompt.ask(t("bmc_pass"))
console.print()
collect_os = Confirm.ask("Collect OS logs too?", default=False)
sds_user, sds_pass = "sds", "sds"
collect_os = Confirm.ask(t("ask_collect_os"), default=cfg.collect_by_default)
sds_user, sds_pass = cfg.sds_default_user, "sds"
if collect_os:
sds_user = Prompt.ask("SDS username", default="sds")
sds_pass = Prompt.ask("SDS password (visible)", default="sds")
sds_user = Prompt.ask(t("sds_user"), default=cfg.sds_default_user)
sds_pass = Prompt.ask(t("sds_pass"), default="sds")
return Inputs(hosts=hosts, bmc_user=bmc_user, bmc_pass=bmc_pass,
collect_os=collect_os, sds_user=sds_user, sds_pass=sds_pass)
def _resolve_sds_ip(host: str, bmc_user: str, bmc_pass: str,
def _resolve_sds_ip(host: str, bmc_user: str, bmc_pass: str, cfg: Config,
console: Console) -> str | None:
"""Open the BMC, try to discover the SDS IP. On failure prompt the user.
Returns the IP, or None when the user chose to skip OS collection."""
console.print(f"[dim]Resolving SDS IP for {host}...[/]")
console.print(f"[dim]{t('resolving_sds', host=host)}[/]")
ip: str | None = None
try:
with BmcSession(host=host, user=bmc_user, password=bmc_pass) as bmc:
ip = discover_sds_ip(bmc)
ip = discover_sds_ip(bmc, do_sweep=cfg.ping_sweep)
except Exception as exc:
console.print(f"[yellow] BMC {host}: discovery failed ({exc})[/]")
console.print(f"[yellow]{t('discovery_failed', host=host, err=exc)}[/]")
if ip:
console.print(f"[green] → SDS IP for {host}: {ip}[/]")
console.print(f"[green]{t('sds_resolved', host=host, ip=ip)}[/]")
return ip
console.print(f"[yellow] SDS IP for {host} not auto-discovered.[/]")
manual = Prompt.ask(
f" Enter SDS IP for {host} (empty to skip OS for this host)",
default="",
).strip()
console.print(f"[yellow]{t('sds_not_found', host=host)}[/]")
manual = Prompt.ask(t("manual_sds_prompt", host=host), default="").strip()
return manual or None
@ -111,19 +110,19 @@ def _host_worker(host: str, bmc_user: str, bmc_pass: str,
sds_ip: str | None, sds_user: str, sds_pass: str,
session_dir: Path, ui: Ui) -> dict:
ui.set_status(host, "CONNECTING")
ui.log(f"[cyan]{host}[/] starting...")
ui.log(f"[cyan]{t('host_starting', host=host)}[/]")
per_host = make_per_host_dir(session_dir, host)
def bmc_progress(step: int, total: int, label: str, ok_n: int, fail_n: int) -> None:
ui.set_status(host, "COLLECTING")
ui.set_progress(host, "bmc", step, total, label, ok_n, fail_n)
ui.log(f"[cyan]{host}/bmc[/] → {label}")
ui.log(f"[cyan]{t('host_step_bmc', host=host, label=label)}[/]")
def os_progress(step: int, total: int, label: str, ok_n: int, fail_n: int) -> None:
ui.set_status(host, "COLLECTING")
ui.set_progress(host, "os", step, total, label, ok_n, fail_n)
ui.log(f"[cyan]{host}/os[/] → {label}")
ui.log(f"[cyan]{t('host_step_os', host=host, label=label)}[/]")
bmc_summary: dict = {"status": "skip", "ok": 0, "fail": 0, "total": 0, "error": "", "serial": ""}
os_summary: dict = {"status": "skip", "ok": 0, "fail": 0, "total": 0, "error": ""}
@ -151,14 +150,13 @@ def _host_worker(host: str, bmc_user: str, bmc_pass: str,
if bmc_ok and os_ok:
ui.set_status(host, "DONE")
ui.set_summary(host, total_ok, total_fail, bmc_summary.get("serial", ""))
os_note = "" if sds_ip is None else f", OS {os_summary['ok']}/{os_summary['total']} ok"
ui.log(f"[green]{host}[/] done — BMC {bmc_summary['ok']}/{bmc_summary['total']} ok"
f"{os_note}.")
os_note = "" if sds_ip is None else t("os_note", ok=os_summary['ok'], total=os_summary['total'])
ui.log(f"[green]{t('host_done', host=host, bmc_ok=bmc_summary['ok'], bmc_total=bmc_summary['total'], os_note=os_note)}[/]")
else:
ui.set_status(host, "ERROR")
err = (bmc_summary.get("error") or "") if not bmc_ok else (os_summary.get("error") or "")
ui.set_summary(host, total_ok, total_fail, bmc_summary.get("serial", ""), err[:80])
ui.log(f"[red]{host}[/] FAILED — {err}")
ui.log(f"[red]{t('host_failed', host=host, err=err)}[/]")
return {
"host": host,
@ -168,28 +166,42 @@ def _host_worker(host: str, bmc_user: str, bmc_pass: str,
}
def main() -> None:
enable_vt()
console = Console(log_path=False)
console.print(f"[bold cyan]vrcx v{__version__}[/] - Vegman Remote Collect (extended)")
console.print("[dim]made by engelgardt[/]")
def _print_header(console: Console) -> None:
title = f"[bold cyan]vrcx v{__version__}[/] {t('tagline')}"
latest = check_for_update()
if latest:
release_url = f"https://github.com/{GITHUB_REPO}/releases/latest"
notice = t("update_available", tag=latest)
header = Table.grid(expand=True)
header.add_column(justify="left", ratio=1)
header.add_column(justify="right")
header.add_row(title, f"[dim][link={release_url}]{notice}[/link][/]")
console.print(header)
else:
console.print(title)
console.print()
check_for_update(console)
inputs = _prompt_inputs(console)
def main() -> None:
enable_vt()
cfg = load_config()
set_language(cfg.language)
console = Console(log_path=False)
_print_header(console)
inputs = _prompt_inputs(console, cfg)
if inputs is None:
input("Press Enter to exit"); return
input(t("press_enter")); return
# Resolve SDS IPs sequentially (so user prompts don't collide with workers).
sds_ips: dict[str, str | None] = {}
if inputs.collect_os:
console.rule("[bold cyan]SDS discovery")
console.rule(f"[bold cyan]{t('discovery_rule')}")
for h in inputs.hosts:
sds_ips[h] = _resolve_sds_ip(h, inputs.bmc_user, inputs.bmc_pass, console)
sds_ips[h] = _resolve_sds_ip(h, inputs.bmc_user, inputs.bmc_pass, cfg, console)
enabled = {h for h, ip in sds_ips.items() if ip}
# Output anchor: next to the .exe when frozen, cwd otherwise.
if getattr(sys, "frozen", False):
anchor = Path(sys.executable).resolve().parent
else:
@ -209,7 +221,8 @@ def main() -> None:
aborted = False
outer: Path | None = None
try:
with ThreadPoolExecutor(max_workers=min(8, max(2, len(inputs.hosts)))) as ex:
pool_size = min(max(2, cfg.parallel_hosts), max(2, len(inputs.hosts)))
with ThreadPoolExecutor(max_workers=pool_size) as ex:
futures: list[Future] = [
ex.submit(
_host_worker,
@ -224,11 +237,11 @@ def main() -> None:
summaries.append(fut.result())
except KeyboardInterrupt:
aborted = True
ui.log("[yellow]Aborted by user — removing the incomplete session folder...[/]")
ui.log(f"[yellow]{t('aborted')}[/]")
finally:
if aborted:
shutil.rmtree(session, ignore_errors=True)
ui.log(f"[yellow]Removed:[/] {session}")
ui.log(f"[yellow]{t('removed', path=session)}[/]")
else:
with open(session / "vrc.log", "w", encoding="utf-8") as f:
for s in summaries:
@ -242,17 +255,17 @@ def main() -> None:
)
(session / "err_out.log").write_text("", encoding="utf-8")
outer = finalize_session(session)
ui.log(f"[bold green]Bundle ready:[/] {outer}")
ui.log(f"[bold green]{t('bundle_ready', path=outer)}[/]")
stop.set()
ui_thread.join(timeout=2.0)
console.print()
if aborted:
console.print("[yellow]Aborted. Session folder removed.[/]")
console.print(f"[yellow]{t('aborted_msg')}[/]")
else:
console.print(f"[bold green]Done.[/] Bundle: {outer}")
input("Press Enter to exit")
console.print(f"[bold green]{t('done', path=outer)}[/]")
input(t("press_enter"))
if __name__ == "__main__":

146
dev/src/vrcx/config.py Normal file
View file

@ -0,0 +1,146 @@
"""
config.ini handling next to the executable.
On first run the file does not exist we ask the user which language to use
and write the answer. On every subsequent run we just read it. The .ini has
bilingual comments explaining every option so the user can edit values by
hand.
"""
from __future__ import annotations
import configparser
import sys
from dataclasses import dataclass
from pathlib import Path
SUPPORTED_LANGS = ("en", "ru")
DEFAULT_LANG = "en"
CONFIG_TEMPLATE = """\
# ---------------------------------------------------------------------------
# vrcx — Vegman Remote Collect (extended)
# Configuration. Edit any value below to change behavior.
# Конфигурация. Изменить любое значение ниже — изменить поведение программы.
# ---------------------------------------------------------------------------
[General]
# Interface language. Valid: en, ru
# Язык интерфейса. Допустимые значения: en, ru
language = {language}
[BMC]
# Default BMC username (Enter accepts this on the prompt).
# Логин BMC по умолчанию (Enter на вопросе примет это значение).
default_user = admin
[OS]
# Pre-tick "Collect OS logs too?" prompt by default (yes/no).
# Сразу включать вопрос «Собирать ещё и логи с ОС?» как «да» (yes/no).
collect_by_default = no
# Default SDS host username.
# Логин SDS по умолчанию.
default_user = sds
[Discovery]
# Run a /24 ping-sweep to warm the ARP table when SDS IP is unknown (yes/no).
# Делать ping-sweep подсети /24 для прогрева ARP, если IP SDS неизвестен (yes/no).
ping_sweep = yes
[Run]
# Max BMCs collected in parallel (each host also runs BMC+OS in parallel inside).
# Макс. число BMC, опрашиваемых параллельно (внутри хоста BMC+OS идут тоже параллельно).
parallel_hosts = 8
"""
@dataclass
class Config:
language: str = DEFAULT_LANG
bmc_default_user: str = "admin"
sds_default_user: str = "sds"
collect_by_default: bool = False
ping_sweep: bool = True
parallel_hosts: int = 8
def app_dir() -> Path:
"""Directory holding the running executable (or source folder in dev mode)."""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
def config_path() -> Path:
return app_dir() / "config.ini"
def _ask_language() -> str:
"""First-run language prompt — stdin only, before the Rich console exists."""
print()
print("Select language / Выберите язык:")
print(" 1) English")
print(" 2) Русский")
while True:
try:
choice = input("> ").strip()
except (EOFError, KeyboardInterrupt):
return DEFAULT_LANG
if choice == "1":
return "en"
if choice == "2":
return "ru"
print("Please enter 1 or 2 / Введи 1 или 2")
def _write_default_config(language: str) -> None:
path = config_path()
try:
path.write_text(CONFIG_TEMPLATE.format(language=language), encoding="utf-8")
except OSError:
# Read-only deployment location — fall back to in-memory defaults.
pass
def _to_bool(s: str, default: bool) -> bool:
s = (s or "").strip().lower()
if s in ("yes", "true", "y", "1", "on"): return True
if s in ("no", "false", "n", "0", "off"): return False
return default
def _to_int(s: str, default: int) -> int:
try:
return int((s or "").strip())
except ValueError:
return default
def load_config() -> Config:
"""Read config.ini next to the exe. On first run, prompt for language and
write a fresh, fully-commented config file."""
path = config_path()
if not path.exists():
lang = _ask_language()
_write_default_config(lang)
return Config(language=lang)
cp = configparser.ConfigParser()
try:
cp.read(path, encoding="utf-8")
except (configparser.Error, OSError):
return Config()
lang = (cp.get("General", "language", fallback=DEFAULT_LANG) or DEFAULT_LANG).strip().lower()
if lang not in SUPPORTED_LANGS:
lang = DEFAULT_LANG
return Config(
language = lang,
bmc_default_user = cp.get("BMC", "default_user", fallback="admin").strip() or "admin",
sds_default_user = cp.get("OS", "default_user", fallback="sds").strip() or "sds",
collect_by_default = _to_bool(cp.get("OS", "collect_by_default", fallback="no"), False),
ping_sweep = _to_bool(cp.get("Discovery", "ping_sweep", fallback="yes"), True),
parallel_hosts = _to_int (cp.get("Run", "parallel_hosts", fallback="8"), 8),
)

145
dev/src/vrcx/i18n.py Normal file
View file

@ -0,0 +1,145 @@
"""
Tiny in-memory translation table EN/RU. No .po/.mo machinery for a small
CLI tool: a flat dict per language is enough.
Usage:
from .i18n import t, set_language
set_language("ru")
print(t("no_ips"))
`t(key, **params)` runs `.format(**params)` on the result, so placeholders
work just like f-strings.
"""
from __future__ import annotations
_lang = "en"
STRINGS: dict[str, dict[str, str]] = {
"en": {
# banner / header
"tagline": "- Vegman Remote Collect (extended)",
"update_available": "Update available ({tag})",
# input prompts
"targets_rule": "Targets",
"enter_bmc_ips": "Enter one or more BMC IP addresses, separated by spaces, commas, or newlines.",
"end_with_blank": "(End input with an empty line.)",
"no_ips": "No valid IP addresses entered.",
"bmc_user": "BMC username",
"bmc_pass": "BMC password (visible)",
"ask_collect_os": "Collect OS logs too?",
"sds_user": "SDS username",
"sds_pass": "SDS password (visible)",
# SDS discovery
"discovery_rule": "SDS discovery",
"resolving_sds": "Resolving SDS IP for {host}...",
"discovery_failed": " BMC {host}: discovery failed ({err})",
"sds_resolved": " → SDS IP for {host}: {ip}",
"sds_not_found": " SDS IP for {host} not auto-discovered.",
"manual_sds_prompt": " Enter SDS IP for {host} (empty to skip OS for this host)",
# progress / status
"host_starting": "{host} starting...",
"host_step_bmc": "{host}/bmc → {label}",
"host_step_os": "{host}/os → {label}",
"host_done": "{host} done — BMC {bmc_ok}/{bmc_total} ok{os_note}.",
"host_failed": "{host} FAILED — {err}",
"os_note": ", OS {ok}/{total} ok",
# finalization
"aborted": "Aborted by user — removing the incomplete session folder...",
"removed": "Removed: {path}",
"bundle_ready": "Bundle ready: {path}",
"aborted_msg": "Aborted. Session folder removed.",
"done": "Done. Bundle: {path}",
"press_enter": "Press Enter to exit",
# UI table
"col_host": "Host",
"col_status": "Status",
"col_step": "Step",
"col_okfail": "OK/Fail",
"col_serial": "Serial",
"col_note": "Note",
"events_title": "Events",
"hosts_title": "Hosts",
"no_events": "(no events yet)",
"more_hosts": "(+{n} more — enlarge window)",
"session_label": "Session",
"output_label": "Output",
"ctrlc_hint": "Ctrl+C — abort and delete this session folder.",
},
"ru": {
"tagline": "— Vegman Remote Collect (расширенный)",
"update_available": "Доступно обновление ({tag})",
"targets_rule": "Цели",
"enter_bmc_ips": "Введи один или несколько IP-адресов BMC через пробел, запятую или с новой строки.",
"end_with_blank": "(Закончи ввод пустой строкой.)",
"no_ips": "Корректные IP-адреса не введены.",
"bmc_user": "Логин BMC",
"bmc_pass": "Пароль BMC (виден на экране)",
"ask_collect_os": "Собирать ещё и логи с ОС?",
"sds_user": "Логин SDS",
"sds_pass": "Пароль SDS (виден на экране)",
"discovery_rule": "Поиск SDS",
"resolving_sds": "Ищу IP SDS для {host}...",
"discovery_failed": " BMC {host}: поиск не удался ({err})",
"sds_resolved": " → IP SDS для {host}: {ip}",
"sds_not_found": " IP SDS для {host} автоматически не найден.",
"manual_sds_prompt": " Введи IP SDS для {host} (пусто — пропустить ОС для этого хоста)",
"host_starting": "{host} стартую...",
"host_step_bmc": "{host}/bmc → {label}",
"host_step_os": "{host}/os → {label}",
"host_done": "{host} готово — BMC {bmc_ok}/{bmc_total} ок{os_note}.",
"host_failed": "{host} ОШИБКА — {err}",
"os_note": ", OS {ok}/{total} ок",
"aborted": "Отменено пользователем — удаляю незавершённую сессию...",
"removed": "Удалено: {path}",
"bundle_ready": "Архив готов: {path}",
"aborted_msg": "Отменено. Папка сессии удалена.",
"done": "Готово. Архив: {path}",
"press_enter": "Нажми Enter для выхода",
"col_host": "Хост",
"col_status": "Статус",
"col_step": "Шаг",
"col_okfail": "ОК/Ош",
"col_serial": "Серийник",
"col_note": "Примечание",
"events_title": "События",
"hosts_title": "Хосты",
"no_events": "(пока пусто)",
"more_hosts": "(ещё +{n} — увеличь окно)",
"session_label": "Сессия",
"output_label": "Вывод",
"ctrlc_hint": "Ctrl+C — прервать и удалить папку сессии.",
},
}
def set_language(lang: str) -> None:
global _lang
if lang in STRINGS:
_lang = lang
def language() -> str:
return _lang
def t(key: str, **params) -> str:
"""Translate `key`; fall back to EN if missing in the active language."""
s = STRINGS.get(_lang, {}).get(key) or STRINGS["en"].get(key, key)
if params:
try:
return s.format(**params)
except (KeyError, IndexError):
return s
return s

View file

@ -1,7 +1,7 @@
"""
Rich-based TUI:
vrcx v0.2.0 made by engelgardt
vrcx v0.2.0
Session: 16052026_124500 out/16052026_124500/...
Hosts
@ -29,7 +29,8 @@ from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from . import __version__
from . import __version__
from .i18n import t
HEADER_LINES = 4
@ -118,8 +119,9 @@ class Ui:
def _render_header(self) -> Panel:
body = (
f"[bold cyan]vrcx v{__version__}[/]\n"
f"Session: [bold]{self.session_label}[/] Output: [dim]{self.out_path}[/]\n"
f"[bold yellow]Ctrl+C[/] — abort and delete this session folder."
f"{t('session_label')}: [bold]{self.session_label}[/] "
f"{t('output_label')}: [dim]{self.out_path}[/]\n"
f"[bold yellow]Ctrl+C[/] — {t('ctrlc_hint')}"
)
return Panel(body, border_style="cyan")
@ -131,14 +133,14 @@ class Ui:
return f"{label} {r[prefix + 'step']}/{total} {r[prefix + 'label']}"
def _render_table(self) -> Table:
t = Table(expand=True, header_style="bold")
t.add_column("#", style="dim", width=3, justify="right")
t.add_column("Host", width=18)
t.add_column("Status", width=12)
t.add_column("Step", overflow="ellipsis")
t.add_column("OK/Fail", width=10, justify="right")
t.add_column("Serial", width=14)
t.add_column("Note", overflow="ellipsis")
tbl = Table(expand=True, header_style="bold")
tbl.add_column("#", style="dim", width=3, justify="right")
tbl.add_column(t("col_host"), width=18)
tbl.add_column(t("col_status"), width=12)
tbl.add_column(t("col_step"), overflow="ellipsis")
tbl.add_column(t("col_okfail"), width=10, justify="right")
tbl.add_column(t("col_serial"), width=14)
tbl.add_column(t("col_note"), overflow="ellipsis")
with self.rows_lock:
items = list(self.rows.items())
@ -158,7 +160,7 @@ class Ui:
ok_total = r["bmc_ok"] + r["os_ok"]
fail_total = r["bmc_fail"] + r["os_fail"]
note = r.get("error") or ""
t.add_row(
tbl.add_row(
str(i),
host,
Text(r["status"], style=style),
@ -168,20 +170,20 @@ class Ui:
note,
)
if overflow:
t.add_row("", "", "", f"(+{overflow} more — enlarge window)", "", "", "")
return t
tbl.add_row("", "", "", t("more_hosts", n=overflow), "", "", "")
return tbl
def _render_events(self) -> Panel:
with self.events_lock:
last = list(self.events)[-20:]
body = "\n".join(last) if last else "[dim](no events yet)[/]"
return Panel(body, title="Events", border_style="dim")
body = "\n".join(last) if last else f"[dim]{t('no_events')}[/]"
return Panel(body, title=t("events_title"), border_style="dim")
def _render_screen(self) -> Layout:
layout = Layout()
layout.split_column(
Layout(self._render_header(), name="hdr", size=HEADER_LINES),
Layout(Panel(self._render_table(), title="Hosts", border_style="cyan"), name="tbl"),
Layout(Panel(self._render_table(), title=t("hosts_title"), border_style="cyan"), name="tbl"),
Layout(self._render_events(), name="evt", size=EVENTS_LINES),
)
return layout

View file

@ -1,12 +1,14 @@
"""Auto-update check on startup. Same pattern as in dhcpsrv/netswitch."""
"""
Auto-update check.
On startup, ask GitHub for the latest release tag. If it's newer than the
local `__version__`, return the tag string the caller renders a clickable
hint next to the title. Silent on any error (offline, rate-limit, etc.).
"""
from __future__ import annotations
import json
import urllib.request
import webbrowser
from rich.console import Console
from rich.prompt import Confirm
from . import __version__, GITHUB_REPO
@ -22,7 +24,10 @@ def _parse_version(s: str) -> tuple[int, int, int]:
return (0, 0, 0)
def check_for_update(console: Console) -> None:
def check_for_update() -> str | None:
"""Return the latest release tag (e.g. 'v1.2.0') when it is newer than
the currently running version. Returns None when up-to-date, offline,
rate-limited or on any error caller decides how to render."""
try:
url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
req = urllib.request.Request(url, headers={
@ -32,16 +37,8 @@ def check_for_update(console: Console) -> None:
with urllib.request.urlopen(req, timeout=3) as r:
data = json.loads(r.read().decode("utf-8", errors="replace"))
latest = (data.get("tag_name") or "").strip()
page = data.get("html_url") or f"https://github.com/{GITHUB_REPO}/releases/latest"
if _parse_version(latest) > _parse_version(__version__):
console.rule("[bold yellow]Update available")
console.print(f"Current: [dim]v{__version__}[/] Latest: [bold green]{latest}[/]")
try:
if Confirm.ask("Open the download page in your browser?", default=True):
webbrowser.open(page)
except (EOFError, KeyboardInterrupt):
pass
console.print()
if latest and _parse_version(latest) > _parse_version(__version__):
return latest
except Exception:
pass
return None

69
dev/tools/make_icon.ps1 Normal file
View file

@ -0,0 +1,69 @@
# Regenerate assets/icon.ico for vrcx.
# Edit $Text / colors below and re-run.
param(
[string]$Text = "VRCX",
[int[]]$ColorFrom = @(0, 200, 220),
[int[]]$ColorTo = @(0, 90, 130),
[string]$OutPath = "$PSScriptRoot\..\assets\icon.ico"
)
Add-Type -AssemblyName System.Drawing
$outDir = Split-Path $OutPath -Parent
if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }
$bgFrom = [System.Drawing.Color]::FromArgb(255, $ColorFrom[0], $ColorFrom[1], $ColorFrom[2])
$bgTo = [System.Drawing.Color]::FromArgb(255, $ColorTo[0], $ColorTo[1], $ColorTo[2])
$sizes = 256, 128, 64, 48, 32, 16
$pngs = @()
foreach ($s in $sizes) {
$bmp = New-Object System.Drawing.Bitmap $s, $s
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = 'AntiAlias'
$g.TextRenderingHint = 'AntiAliasGridFit'
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
$r = $s - 2
$path.AddEllipse(1, 1, $r, $r)
$brush = New-Object System.Drawing.Drawing2D.PathGradientBrush($path)
$brush.CenterColor = $bgFrom
$brush.SurroundColors = @($bgTo)
$g.FillEllipse($brush, 1, 1, $r, $r)
$pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(60, 0, 0, 0)), ([float]($s/64))
$g.DrawEllipse($pen, 1, 1, $r, $r)
$fontSize = [float]($s * 0.32)
$font = New-Object System.Drawing.Font "Segoe UI Black", $fontSize, ([System.Drawing.FontStyle]::Bold), ([System.Drawing.GraphicsUnit]::Pixel)
$sf = New-Object System.Drawing.StringFormat
$sf.Alignment = 'Center'; $sf.LineAlignment = 'Center'
$shadow = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(80, 0, 0, 0))
$g.DrawString($Text, $font, $shadow, [float]($s/2 + $s*0.02), [float]($s/2 + $s*0.02), $sf)
$g.DrawString($Text, $font, [System.Drawing.Brushes]::White, [float]($s/2), [float]($s/2), $sf)
$g.Dispose()
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$pngs += ,@{ size = $s; bytes = $ms.ToArray() }
$ms.Dispose(); $bmp.Dispose()
}
$fs = [System.IO.File]::Create($OutPath)
$bw = New-Object System.IO.BinaryWriter $fs
$bw.Write([uint16]0)
$bw.Write([uint16]1)
$bw.Write([uint16]$pngs.Count)
$offset = 6 + 16 * $pngs.Count
foreach ($p in $pngs) {
$w = if ($p.size -ge 256) { 0 } else { $p.size }
$bw.Write([byte]$w); $bw.Write([byte]$w)
$bw.Write([byte]0); $bw.Write([byte]0)
$bw.Write([uint16]1); $bw.Write([uint16]32)
$bw.Write([uint32]$p.bytes.Length); $bw.Write([uint32]$offset)
$offset += $p.bytes.Length
}
foreach ($p in $pngs) { $bw.Write($p.bytes) }
$bw.Close(); $fs.Close()
Write-Host "Wrote $OutPath"