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:
parent
93cc775e80
commit
b923b9ebe7
11 changed files with 713 additions and 109 deletions
84
.github/workflows/release.yml
vendored
Normal file
84
.github/workflows/release.yml
vendored
Normal 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
|
||||||
88
README.md
88
README.md
|
|
@ -1,48 +1,98 @@
|
||||||
# bmccollect
|
# vrcx
|
||||||
|
|
||||||
[](https://github.com/Engelgardt23/bmccollect/releases/latest)
|
[](https://github.com/Engelgardt23/vrcx/releases/latest)
|
||||||
|
[](https://github.com/Engelgardt23/vrcx/actions)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
[](#)
|
||||||
|
|
||||||
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
|
## Download
|
||||||
|
|
||||||
Grab the latest release: [**releases page**](https://github.com/Engelgardt23/bmccollect/releases/latest).
|
Grab the latest release: [**releases page**](https://github.com/Engelgardt23/vrcx/releases/latest).
|
||||||
The asset is `bmccollect-portable-vX.Y.Z.zip`.
|
The asset is `vrcx-portable-vX.Y.Z.zip`.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
1. Unzip anywhere.
|
1. Unzip anywhere.
|
||||||
2. Double-click `bmccollect.exe`.
|
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 / newline separated). End input with an empty line.
|
3. Paste one or more **BMC** IPs (whitespace, comma, or newline separated). End input with an empty line.
|
||||||
4. Enter username (default `admin`) and password.
|
4. Enter the BMC user (default `admin`) and password.
|
||||||
5. Watch the live progress table while the tool collects each BMC in parallel.
|
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. When it's done you get a single `out/<DDMMYYYY_HHMMSS>/<stamp>.tar.gz` ready to send to support.
|
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
|
## Compatibility
|
||||||
|
|
||||||
- Output structure mirrors VRC v1.1b — YADRO support flow is unchanged.
|
- Output structure mirrors VRC v1.1b — YADRO support flow is unchanged for the BMC side.
|
||||||
- Tested against `vegman-sx20` BMC firmware.
|
- Targets: YADRO Vegman servers with OpenBMC + the SDS service OS (CentOS Stream 10 base).
|
||||||
- Windows 10 / 11 host (the only place this tool runs).
|
- Host: Windows 10 / 11. No Python required on the laptop or on the server.
|
||||||
|
|
||||||
## Build from source
|
## Build from source
|
||||||
|
|
||||||
```
|
```
|
||||||
|
git clone https://github.com/Engelgardt23/vrcx.git
|
||||||
|
cd vrcx
|
||||||
python -m pip install rich paramiko pyinstaller
|
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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
99
README.ru.md
Normal file
99
README.ru.md
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
# vrcx
|
||||||
|
|
||||||
|
[](https://github.com/Engelgardt23/vrcx/releases/latest)
|
||||||
|
[](https://github.com/Engelgardt23/vrcx/actions)
|
||||||
|
[](LICENSE)
|
||||||
|
[](#)
|
||||||
|
|
||||||
|
[🇺🇸 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
BIN
dev/assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
|
|
@ -1,6 +1,5 @@
|
||||||
"""
|
"""
|
||||||
vrcx - Vegman Remote Collect (extended).
|
vrcx - Vegman Remote Collect (extended).
|
||||||
made by engelgardt
|
|
||||||
|
|
||||||
Portable diagnostic collector for YADRO Vegman servers. Connects (in parallel)
|
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
|
to one or more BMC hosts and, optionally, to their SDS service OS, runs a
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""
|
"""
|
||||||
Application entry: collects credentials + IP list (BMC + optional SDS),
|
Application entry: loads config (with first-run language prompt), runs the
|
||||||
resolves SDS IPs via Redfish→ARP, runs the BMC and OS collectors in
|
update check, collects credentials + IP list (BMC + optional SDS), resolves
|
||||||
parallel per host, shows a live TUI, and packs the result.
|
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
|
from __future__ import annotations
|
||||||
|
|
@ -16,11 +17,14 @@ from pathlib import Path
|
||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.prompt import Confirm, Prompt
|
from rich.prompt import Confirm, Prompt
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__, GITHUB_REPO
|
||||||
from .bmc import BmcSession
|
from .bmc import BmcSession
|
||||||
from .collector import collect_host
|
from .collector import collect_host
|
||||||
|
from .config import load_config, Config
|
||||||
from .discover import discover_sds_ip
|
from .discover import discover_sds_ip
|
||||||
|
from .i18n import set_language, t
|
||||||
from .os_collector import collect_host_os
|
from .os_collector import collect_host_os
|
||||||
from .platform_win import enable_vt
|
from .platform_win import enable_vt
|
||||||
from .tarball import (
|
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]:
|
def _parse_ips(raw: str) -> list[str]:
|
||||||
tokens = re.split(r"[\s,;]+", (raw or "").strip())
|
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
|
@dataclass
|
||||||
|
|
@ -49,10 +53,10 @@ class Inputs:
|
||||||
sds_pass: str
|
sds_pass: str
|
||||||
|
|
||||||
|
|
||||||
def _prompt_inputs(console: Console) -> Inputs | None:
|
def _prompt_inputs(console: Console, cfg: Config) -> Inputs | None:
|
||||||
console.rule("[bold cyan]Targets")
|
console.rule(f"[bold cyan]{t('targets_rule')}")
|
||||||
console.print("Enter one or more BMC IP addresses, separated by spaces, commas, or newlines.")
|
console.print(t("enter_bmc_ips"))
|
||||||
console.print("[dim](End input with an empty line.)[/]")
|
console.print(f"[dim]{t('end_with_blank')}[/]")
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|
@ -67,43 +71,38 @@ def _prompt_inputs(console: Console) -> Inputs | None:
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
hosts = _parse_ips(" ".join(lines))
|
hosts = _parse_ips(" ".join(lines))
|
||||||
if not hosts:
|
if not hosts:
|
||||||
console.print("[red]No valid IP addresses entered.[/]")
|
console.print(f"[red]{t('no_ips')}[/]")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
console.print()
|
console.print()
|
||||||
bmc_user = Prompt.ask("BMC username", default="admin")
|
bmc_user = Prompt.ask(t("bmc_user"), default=cfg.bmc_default_user)
|
||||||
bmc_pass = Prompt.ask("BMC password (visible)")
|
bmc_pass = Prompt.ask(t("bmc_pass"))
|
||||||
|
|
||||||
console.print()
|
console.print()
|
||||||
collect_os = Confirm.ask("Collect OS logs too?", default=False)
|
collect_os = Confirm.ask(t("ask_collect_os"), default=cfg.collect_by_default)
|
||||||
sds_user, sds_pass = "sds", "sds"
|
sds_user, sds_pass = cfg.sds_default_user, "sds"
|
||||||
if collect_os:
|
if collect_os:
|
||||||
sds_user = Prompt.ask("SDS username", default="sds")
|
sds_user = Prompt.ask(t("sds_user"), default=cfg.sds_default_user)
|
||||||
sds_pass = Prompt.ask("SDS password (visible)", default="sds")
|
sds_pass = Prompt.ask(t("sds_pass"), default="sds")
|
||||||
|
|
||||||
return Inputs(hosts=hosts, bmc_user=bmc_user, bmc_pass=bmc_pass,
|
return Inputs(hosts=hosts, bmc_user=bmc_user, bmc_pass=bmc_pass,
|
||||||
collect_os=collect_os, sds_user=sds_user, sds_pass=sds_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:
|
console: Console) -> str | None:
|
||||||
"""Open the BMC, try to discover the SDS IP. On failure prompt the user.
|
console.print(f"[dim]{t('resolving_sds', host=host)}[/]")
|
||||||
Returns the IP, or None when the user chose to skip OS collection."""
|
|
||||||
console.print(f"[dim]Resolving SDS IP for {host}...[/]")
|
|
||||||
ip: str | None = None
|
ip: str | None = None
|
||||||
try:
|
try:
|
||||||
with BmcSession(host=host, user=bmc_user, password=bmc_pass) as bmc:
|
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:
|
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:
|
if ip:
|
||||||
console.print(f"[green] → SDS IP for {host}: {ip}[/]")
|
console.print(f"[green]{t('sds_resolved', host=host, ip=ip)}[/]")
|
||||||
return ip
|
return ip
|
||||||
console.print(f"[yellow] SDS IP for {host} not auto-discovered.[/]")
|
console.print(f"[yellow]{t('sds_not_found', host=host)}[/]")
|
||||||
manual = Prompt.ask(
|
manual = Prompt.ask(t("manual_sds_prompt", host=host), default="").strip()
|
||||||
f" Enter SDS IP for {host} (empty to skip OS for this host)",
|
|
||||||
default="",
|
|
||||||
).strip()
|
|
||||||
return manual or None
|
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,
|
sds_ip: str | None, sds_user: str, sds_pass: str,
|
||||||
session_dir: Path, ui: Ui) -> dict:
|
session_dir: Path, ui: Ui) -> dict:
|
||||||
ui.set_status(host, "CONNECTING")
|
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)
|
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:
|
def bmc_progress(step: int, total: int, label: str, ok_n: int, fail_n: int) -> None:
|
||||||
ui.set_status(host, "COLLECTING")
|
ui.set_status(host, "COLLECTING")
|
||||||
ui.set_progress(host, "bmc", step, total, label, ok_n, fail_n)
|
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:
|
def os_progress(step: int, total: int, label: str, ok_n: int, fail_n: int) -> None:
|
||||||
ui.set_status(host, "COLLECTING")
|
ui.set_status(host, "COLLECTING")
|
||||||
ui.set_progress(host, "os", step, total, label, ok_n, fail_n)
|
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": ""}
|
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": ""}
|
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:
|
if bmc_ok and os_ok:
|
||||||
ui.set_status(host, "DONE")
|
ui.set_status(host, "DONE")
|
||||||
ui.set_summary(host, total_ok, total_fail, bmc_summary.get("serial", ""))
|
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"
|
os_note = "" if sds_ip is None else t("os_note", ok=os_summary['ok'], total=os_summary['total'])
|
||||||
ui.log(f"[green]{host}[/] done — BMC {bmc_summary['ok']}/{bmc_summary['total']} ok"
|
ui.log(f"[green]{t('host_done', host=host, bmc_ok=bmc_summary['ok'], bmc_total=bmc_summary['total'], os_note=os_note)}[/]")
|
||||||
f"{os_note}.")
|
|
||||||
else:
|
else:
|
||||||
ui.set_status(host, "ERROR")
|
ui.set_status(host, "ERROR")
|
||||||
err = (bmc_summary.get("error") or "") if not bmc_ok else (os_summary.get("error") or "")
|
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.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 {
|
return {
|
||||||
"host": host,
|
"host": host,
|
||||||
|
|
@ -168,28 +166,42 @@ def _host_worker(host: str, bmc_user: str, bmc_pass: str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def _print_header(console: Console) -> None:
|
||||||
enable_vt()
|
title = f"[bold cyan]vrcx v{__version__}[/] {t('tagline')}"
|
||||||
console = Console(log_path=False)
|
latest = check_for_update()
|
||||||
console.print(f"[bold cyan]vrcx v{__version__}[/] - Vegman Remote Collect (extended)")
|
if latest:
|
||||||
console.print("[dim]made by engelgardt[/]")
|
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()
|
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:
|
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] = {}
|
sds_ips: dict[str, str | None] = {}
|
||||||
if inputs.collect_os:
|
if inputs.collect_os:
|
||||||
console.rule("[bold cyan]SDS discovery")
|
console.rule(f"[bold cyan]{t('discovery_rule')}")
|
||||||
for h in inputs.hosts:
|
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}
|
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):
|
if getattr(sys, "frozen", False):
|
||||||
anchor = Path(sys.executable).resolve().parent
|
anchor = Path(sys.executable).resolve().parent
|
||||||
else:
|
else:
|
||||||
|
|
@ -209,7 +221,8 @@ def main() -> None:
|
||||||
aborted = False
|
aborted = False
|
||||||
outer: Path | None = None
|
outer: Path | None = None
|
||||||
try:
|
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] = [
|
futures: list[Future] = [
|
||||||
ex.submit(
|
ex.submit(
|
||||||
_host_worker,
|
_host_worker,
|
||||||
|
|
@ -224,11 +237,11 @@ def main() -> None:
|
||||||
summaries.append(fut.result())
|
summaries.append(fut.result())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
aborted = True
|
aborted = True
|
||||||
ui.log("[yellow]Aborted by user — removing the incomplete session folder...[/]")
|
ui.log(f"[yellow]{t('aborted')}[/]")
|
||||||
finally:
|
finally:
|
||||||
if aborted:
|
if aborted:
|
||||||
shutil.rmtree(session, ignore_errors=True)
|
shutil.rmtree(session, ignore_errors=True)
|
||||||
ui.log(f"[yellow]Removed:[/] {session}")
|
ui.log(f"[yellow]{t('removed', path=session)}[/]")
|
||||||
else:
|
else:
|
||||||
with open(session / "vrc.log", "w", encoding="utf-8") as f:
|
with open(session / "vrc.log", "w", encoding="utf-8") as f:
|
||||||
for s in summaries:
|
for s in summaries:
|
||||||
|
|
@ -242,17 +255,17 @@ def main() -> None:
|
||||||
)
|
)
|
||||||
(session / "err_out.log").write_text("", encoding="utf-8")
|
(session / "err_out.log").write_text("", encoding="utf-8")
|
||||||
outer = finalize_session(session)
|
outer = finalize_session(session)
|
||||||
ui.log(f"[bold green]Bundle ready:[/] {outer}")
|
ui.log(f"[bold green]{t('bundle_ready', path=outer)}[/]")
|
||||||
|
|
||||||
stop.set()
|
stop.set()
|
||||||
ui_thread.join(timeout=2.0)
|
ui_thread.join(timeout=2.0)
|
||||||
|
|
||||||
console.print()
|
console.print()
|
||||||
if aborted:
|
if aborted:
|
||||||
console.print("[yellow]Aborted. Session folder removed.[/]")
|
console.print(f"[yellow]{t('aborted_msg')}[/]")
|
||||||
else:
|
else:
|
||||||
console.print(f"[bold green]Done.[/] Bundle: {outer}")
|
console.print(f"[bold green]{t('done', path=outer)}[/]")
|
||||||
input("Press Enter to exit")
|
input(t("press_enter"))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
146
dev/src/vrcx/config.py
Normal file
146
dev/src/vrcx/config.py
Normal 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
145
dev/src/vrcx/i18n.py
Normal 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
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Rich-based TUI:
|
Rich-based TUI:
|
||||||
|
|
||||||
┌─ vrcx v0.2.0 made by engelgardt ────────────────────────┐
|
┌─ vrcx v0.2.0 ────────────────────────────────────────────┐
|
||||||
│ Session: 16052026_124500 out/16052026_124500/... │
|
│ Session: 16052026_124500 out/16052026_124500/... │
|
||||||
└──────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────┘
|
||||||
┌─ Hosts ──────────────────────────────────────────────────┐
|
┌─ Hosts ──────────────────────────────────────────────────┐
|
||||||
|
|
@ -30,6 +30,7 @@ from rich.table import Table
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
from .i18n import t
|
||||||
|
|
||||||
|
|
||||||
HEADER_LINES = 4
|
HEADER_LINES = 4
|
||||||
|
|
@ -118,8 +119,9 @@ class Ui:
|
||||||
def _render_header(self) -> Panel:
|
def _render_header(self) -> Panel:
|
||||||
body = (
|
body = (
|
||||||
f"[bold cyan]vrcx v{__version__}[/]\n"
|
f"[bold cyan]vrcx v{__version__}[/]\n"
|
||||||
f"Session: [bold]{self.session_label}[/] Output: [dim]{self.out_path}[/]\n"
|
f"{t('session_label')}: [bold]{self.session_label}[/] "
|
||||||
f"[bold yellow]Ctrl+C[/] — abort and delete this session folder."
|
f"{t('output_label')}: [dim]{self.out_path}[/]\n"
|
||||||
|
f"[bold yellow]Ctrl+C[/] — {t('ctrlc_hint')}"
|
||||||
)
|
)
|
||||||
return Panel(body, border_style="cyan")
|
return Panel(body, border_style="cyan")
|
||||||
|
|
||||||
|
|
@ -131,14 +133,14 @@ class Ui:
|
||||||
return f"{label} {r[prefix + 'step']}/{total} {r[prefix + 'label']}"
|
return f"{label} {r[prefix + 'step']}/{total} {r[prefix + 'label']}"
|
||||||
|
|
||||||
def _render_table(self) -> Table:
|
def _render_table(self) -> Table:
|
||||||
t = Table(expand=True, header_style="bold")
|
tbl = Table(expand=True, header_style="bold")
|
||||||
t.add_column("#", style="dim", width=3, justify="right")
|
tbl.add_column("#", style="dim", width=3, justify="right")
|
||||||
t.add_column("Host", width=18)
|
tbl.add_column(t("col_host"), width=18)
|
||||||
t.add_column("Status", width=12)
|
tbl.add_column(t("col_status"), width=12)
|
||||||
t.add_column("Step", overflow="ellipsis")
|
tbl.add_column(t("col_step"), overflow="ellipsis")
|
||||||
t.add_column("OK/Fail", width=10, justify="right")
|
tbl.add_column(t("col_okfail"), width=10, justify="right")
|
||||||
t.add_column("Serial", width=14)
|
tbl.add_column(t("col_serial"), width=14)
|
||||||
t.add_column("Note", overflow="ellipsis")
|
tbl.add_column(t("col_note"), overflow="ellipsis")
|
||||||
|
|
||||||
with self.rows_lock:
|
with self.rows_lock:
|
||||||
items = list(self.rows.items())
|
items = list(self.rows.items())
|
||||||
|
|
@ -158,7 +160,7 @@ class Ui:
|
||||||
ok_total = r["bmc_ok"] + r["os_ok"]
|
ok_total = r["bmc_ok"] + r["os_ok"]
|
||||||
fail_total = r["bmc_fail"] + r["os_fail"]
|
fail_total = r["bmc_fail"] + r["os_fail"]
|
||||||
note = r.get("error") or ""
|
note = r.get("error") or ""
|
||||||
t.add_row(
|
tbl.add_row(
|
||||||
str(i),
|
str(i),
|
||||||
host,
|
host,
|
||||||
Text(r["status"], style=style),
|
Text(r["status"], style=style),
|
||||||
|
|
@ -168,20 +170,20 @@ class Ui:
|
||||||
note,
|
note,
|
||||||
)
|
)
|
||||||
if overflow:
|
if overflow:
|
||||||
t.add_row("…", "", "", f"(+{overflow} more — enlarge window)", "", "", "")
|
tbl.add_row("…", "", "", t("more_hosts", n=overflow), "", "", "")
|
||||||
return t
|
return tbl
|
||||||
|
|
||||||
def _render_events(self) -> Panel:
|
def _render_events(self) -> Panel:
|
||||||
with self.events_lock:
|
with self.events_lock:
|
||||||
last = list(self.events)[-20:]
|
last = list(self.events)[-20:]
|
||||||
body = "\n".join(last) if last else "[dim](no events yet)[/]"
|
body = "\n".join(last) if last else f"[dim]{t('no_events')}[/]"
|
||||||
return Panel(body, title="Events", border_style="dim")
|
return Panel(body, title=t("events_title"), border_style="dim")
|
||||||
|
|
||||||
def _render_screen(self) -> Layout:
|
def _render_screen(self) -> Layout:
|
||||||
layout = Layout()
|
layout = Layout()
|
||||||
layout.split_column(
|
layout.split_column(
|
||||||
Layout(self._render_header(), name="hdr", size=HEADER_LINES),
|
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),
|
Layout(self._render_events(), name="evt", size=EVENTS_LINES),
|
||||||
)
|
)
|
||||||
return layout
|
return layout
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
"""Auto-update check on startup. Same pattern as in dhcpsrv/netswitch."""
|
"""
|
||||||
|
Auto-update check.
|
||||||
|
|
||||||
|
On startup, ask GitHub for the latest release tag. If it's newer than the
|
||||||
|
local `__version__`, return the tag string — the caller renders a clickable
|
||||||
|
hint next to the title. Silent on any error (offline, rate-limit, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import webbrowser
|
|
||||||
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.prompt import Confirm
|
|
||||||
|
|
||||||
from . import __version__, GITHUB_REPO
|
from . import __version__, GITHUB_REPO
|
||||||
|
|
||||||
|
|
@ -22,7 +24,10 @@ def _parse_version(s: str) -> tuple[int, int, int]:
|
||||||
return (0, 0, 0)
|
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:
|
try:
|
||||||
url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
||||||
req = urllib.request.Request(url, headers={
|
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:
|
with urllib.request.urlopen(req, timeout=3) as r:
|
||||||
data = json.loads(r.read().decode("utf-8", errors="replace"))
|
data = json.loads(r.read().decode("utf-8", errors="replace"))
|
||||||
latest = (data.get("tag_name") or "").strip()
|
latest = (data.get("tag_name") or "").strip()
|
||||||
page = data.get("html_url") or f"https://github.com/{GITHUB_REPO}/releases/latest"
|
if latest and _parse_version(latest) > _parse_version(__version__):
|
||||||
|
return 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()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
return None
|
||||||
|
|
|
||||||
69
dev/tools/make_icon.ps1
Normal file
69
dev/tools/make_icon.ps1
Normal 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"
|
||||||
Loading…
Reference in a new issue