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)
|
||||
[](#)
|
||||
|
||||
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
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).
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""
|
||||
Application entry: collects credentials + IP list (BMC + optional SDS),
|
||||
resolves SDS IPs via Redfish→ARP, runs the BMC and OS collectors in
|
||||
parallel per host, shows a live TUI, and packs the result.
|
||||
Application entry: loads config (with first-run language prompt), runs the
|
||||
update check, collects credentials + IP list (BMC + optional SDS), resolves
|
||||
SDS IPs via Redfish→ARP, runs BMC and OS collectors in parallel per host,
|
||||
shows a live TUI, and packs the result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -16,11 +17,14 @@ from pathlib import Path
|
|||
|
||||
from rich.console import Console
|
||||
from rich.prompt import Confirm, Prompt
|
||||
from rich.table import Table
|
||||
|
||||
from . import __version__
|
||||
from . import __version__, GITHUB_REPO
|
||||
from .bmc import BmcSession
|
||||
from .collector import collect_host
|
||||
from .config import load_config, Config
|
||||
from .discover import discover_sds_ip
|
||||
from .i18n import set_language, t
|
||||
from .os_collector import collect_host_os
|
||||
from .platform_win import enable_vt
|
||||
from .tarball import (
|
||||
|
|
@ -36,7 +40,7 @@ _IP_RE = re.compile(r"^(?:\d{1,3}\.){3}\d{1,3}$")
|
|||
|
||||
def _parse_ips(raw: str) -> list[str]:
|
||||
tokens = re.split(r"[\s,;]+", (raw or "").strip())
|
||||
return [t for t in tokens if _IP_RE.match(t)]
|
||||
return [tok for tok in tokens if _IP_RE.match(tok)]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -49,10 +53,10 @@ class Inputs:
|
|||
sds_pass: str
|
||||
|
||||
|
||||
def _prompt_inputs(console: Console) -> Inputs | None:
|
||||
console.rule("[bold cyan]Targets")
|
||||
console.print("Enter one or more BMC IP addresses, separated by spaces, commas, or newlines.")
|
||||
console.print("[dim](End input with an empty line.)[/]")
|
||||
def _prompt_inputs(console: Console, cfg: Config) -> Inputs | None:
|
||||
console.rule(f"[bold cyan]{t('targets_rule')}")
|
||||
console.print(t("enter_bmc_ips"))
|
||||
console.print(f"[dim]{t('end_with_blank')}[/]")
|
||||
lines: list[str] = []
|
||||
while True:
|
||||
try:
|
||||
|
|
@ -67,43 +71,38 @@ def _prompt_inputs(console: Console) -> Inputs | None:
|
|||
lines.append(line)
|
||||
hosts = _parse_ips(" ".join(lines))
|
||||
if not hosts:
|
||||
console.print("[red]No valid IP addresses entered.[/]")
|
||||
console.print(f"[red]{t('no_ips')}[/]")
|
||||
return None
|
||||
|
||||
console.print()
|
||||
bmc_user = Prompt.ask("BMC username", default="admin")
|
||||
bmc_pass = Prompt.ask("BMC password (visible)")
|
||||
bmc_user = Prompt.ask(t("bmc_user"), default=cfg.bmc_default_user)
|
||||
bmc_pass = Prompt.ask(t("bmc_pass"))
|
||||
|
||||
console.print()
|
||||
collect_os = Confirm.ask("Collect OS logs too?", default=False)
|
||||
sds_user, sds_pass = "sds", "sds"
|
||||
collect_os = Confirm.ask(t("ask_collect_os"), default=cfg.collect_by_default)
|
||||
sds_user, sds_pass = cfg.sds_default_user, "sds"
|
||||
if collect_os:
|
||||
sds_user = Prompt.ask("SDS username", default="sds")
|
||||
sds_pass = Prompt.ask("SDS password (visible)", default="sds")
|
||||
sds_user = Prompt.ask(t("sds_user"), default=cfg.sds_default_user)
|
||||
sds_pass = Prompt.ask(t("sds_pass"), default="sds")
|
||||
|
||||
return Inputs(hosts=hosts, bmc_user=bmc_user, bmc_pass=bmc_pass,
|
||||
collect_os=collect_os, sds_user=sds_user, sds_pass=sds_pass)
|
||||
|
||||
|
||||
def _resolve_sds_ip(host: str, bmc_user: str, bmc_pass: str,
|
||||
def _resolve_sds_ip(host: str, bmc_user: str, bmc_pass: str, cfg: Config,
|
||||
console: Console) -> str | None:
|
||||
"""Open the BMC, try to discover the SDS IP. On failure prompt the user.
|
||||
Returns the IP, or None when the user chose to skip OS collection."""
|
||||
console.print(f"[dim]Resolving SDS IP for {host}...[/]")
|
||||
console.print(f"[dim]{t('resolving_sds', host=host)}[/]")
|
||||
ip: str | None = None
|
||||
try:
|
||||
with BmcSession(host=host, user=bmc_user, password=bmc_pass) as bmc:
|
||||
ip = discover_sds_ip(bmc)
|
||||
ip = discover_sds_ip(bmc, do_sweep=cfg.ping_sweep)
|
||||
except Exception as exc:
|
||||
console.print(f"[yellow] BMC {host}: discovery failed ({exc})[/]")
|
||||
console.print(f"[yellow]{t('discovery_failed', host=host, err=exc)}[/]")
|
||||
if ip:
|
||||
console.print(f"[green] → SDS IP for {host}: {ip}[/]")
|
||||
console.print(f"[green]{t('sds_resolved', host=host, ip=ip)}[/]")
|
||||
return ip
|
||||
console.print(f"[yellow] SDS IP for {host} not auto-discovered.[/]")
|
||||
manual = Prompt.ask(
|
||||
f" Enter SDS IP for {host} (empty to skip OS for this host)",
|
||||
default="",
|
||||
).strip()
|
||||
console.print(f"[yellow]{t('sds_not_found', host=host)}[/]")
|
||||
manual = Prompt.ask(t("manual_sds_prompt", host=host), default="").strip()
|
||||
return manual or None
|
||||
|
||||
|
||||
|
|
@ -111,19 +110,19 @@ def _host_worker(host: str, bmc_user: str, bmc_pass: str,
|
|||
sds_ip: str | None, sds_user: str, sds_pass: str,
|
||||
session_dir: Path, ui: Ui) -> dict:
|
||||
ui.set_status(host, "CONNECTING")
|
||||
ui.log(f"[cyan]{host}[/] starting...")
|
||||
ui.log(f"[cyan]{t('host_starting', host=host)}[/]")
|
||||
|
||||
per_host = make_per_host_dir(session_dir, host)
|
||||
|
||||
def bmc_progress(step: int, total: int, label: str, ok_n: int, fail_n: int) -> None:
|
||||
ui.set_status(host, "COLLECTING")
|
||||
ui.set_progress(host, "bmc", step, total, label, ok_n, fail_n)
|
||||
ui.log(f"[cyan]{host}/bmc[/] → {label}")
|
||||
ui.log(f"[cyan]{t('host_step_bmc', host=host, label=label)}[/]")
|
||||
|
||||
def os_progress(step: int, total: int, label: str, ok_n: int, fail_n: int) -> None:
|
||||
ui.set_status(host, "COLLECTING")
|
||||
ui.set_progress(host, "os", step, total, label, ok_n, fail_n)
|
||||
ui.log(f"[cyan]{host}/os[/] → {label}")
|
||||
ui.log(f"[cyan]{t('host_step_os', host=host, label=label)}[/]")
|
||||
|
||||
bmc_summary: dict = {"status": "skip", "ok": 0, "fail": 0, "total": 0, "error": "", "serial": ""}
|
||||
os_summary: dict = {"status": "skip", "ok": 0, "fail": 0, "total": 0, "error": ""}
|
||||
|
|
@ -151,14 +150,13 @@ def _host_worker(host: str, bmc_user: str, bmc_pass: str,
|
|||
if bmc_ok and os_ok:
|
||||
ui.set_status(host, "DONE")
|
||||
ui.set_summary(host, total_ok, total_fail, bmc_summary.get("serial", ""))
|
||||
os_note = "" if sds_ip is None else f", OS {os_summary['ok']}/{os_summary['total']} ok"
|
||||
ui.log(f"[green]{host}[/] done — BMC {bmc_summary['ok']}/{bmc_summary['total']} ok"
|
||||
f"{os_note}.")
|
||||
os_note = "" if sds_ip is None else t("os_note", ok=os_summary['ok'], total=os_summary['total'])
|
||||
ui.log(f"[green]{t('host_done', host=host, bmc_ok=bmc_summary['ok'], bmc_total=bmc_summary['total'], os_note=os_note)}[/]")
|
||||
else:
|
||||
ui.set_status(host, "ERROR")
|
||||
err = (bmc_summary.get("error") or "") if not bmc_ok else (os_summary.get("error") or "")
|
||||
ui.set_summary(host, total_ok, total_fail, bmc_summary.get("serial", ""), err[:80])
|
||||
ui.log(f"[red]{host}[/] FAILED — {err}")
|
||||
ui.log(f"[red]{t('host_failed', host=host, err=err)}[/]")
|
||||
|
||||
return {
|
||||
"host": host,
|
||||
|
|
@ -168,28 +166,42 @@ def _host_worker(host: str, bmc_user: str, bmc_pass: str,
|
|||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
enable_vt()
|
||||
console = Console(log_path=False)
|
||||
console.print(f"[bold cyan]vrcx v{__version__}[/] - Vegman Remote Collect (extended)")
|
||||
console.print("[dim]made by engelgardt[/]")
|
||||
def _print_header(console: Console) -> None:
|
||||
title = f"[bold cyan]vrcx v{__version__}[/] {t('tagline')}"
|
||||
latest = check_for_update()
|
||||
if latest:
|
||||
release_url = f"https://github.com/{GITHUB_REPO}/releases/latest"
|
||||
notice = t("update_available", tag=latest)
|
||||
header = Table.grid(expand=True)
|
||||
header.add_column(justify="left", ratio=1)
|
||||
header.add_column(justify="right")
|
||||
header.add_row(title, f"[dim][link={release_url}]{notice}[/link][/]")
|
||||
console.print(header)
|
||||
else:
|
||||
console.print(title)
|
||||
console.print()
|
||||
|
||||
check_for_update(console)
|
||||
|
||||
inputs = _prompt_inputs(console)
|
||||
def main() -> None:
|
||||
enable_vt()
|
||||
|
||||
cfg = load_config()
|
||||
set_language(cfg.language)
|
||||
|
||||
console = Console(log_path=False)
|
||||
_print_header(console)
|
||||
|
||||
inputs = _prompt_inputs(console, cfg)
|
||||
if inputs is None:
|
||||
input("Press Enter to exit"); return
|
||||
input(t("press_enter")); return
|
||||
|
||||
# Resolve SDS IPs sequentially (so user prompts don't collide with workers).
|
||||
sds_ips: dict[str, str | None] = {}
|
||||
if inputs.collect_os:
|
||||
console.rule("[bold cyan]SDS discovery")
|
||||
console.rule(f"[bold cyan]{t('discovery_rule')}")
|
||||
for h in inputs.hosts:
|
||||
sds_ips[h] = _resolve_sds_ip(h, inputs.bmc_user, inputs.bmc_pass, console)
|
||||
sds_ips[h] = _resolve_sds_ip(h, inputs.bmc_user, inputs.bmc_pass, cfg, console)
|
||||
enabled = {h for h, ip in sds_ips.items() if ip}
|
||||
|
||||
# Output anchor: next to the .exe when frozen, cwd otherwise.
|
||||
if getattr(sys, "frozen", False):
|
||||
anchor = Path(sys.executable).resolve().parent
|
||||
else:
|
||||
|
|
@ -209,7 +221,8 @@ def main() -> None:
|
|||
aborted = False
|
||||
outer: Path | None = None
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=min(8, max(2, len(inputs.hosts)))) as ex:
|
||||
pool_size = min(max(2, cfg.parallel_hosts), max(2, len(inputs.hosts)))
|
||||
with ThreadPoolExecutor(max_workers=pool_size) as ex:
|
||||
futures: list[Future] = [
|
||||
ex.submit(
|
||||
_host_worker,
|
||||
|
|
@ -224,11 +237,11 @@ def main() -> None:
|
|||
summaries.append(fut.result())
|
||||
except KeyboardInterrupt:
|
||||
aborted = True
|
||||
ui.log("[yellow]Aborted by user — removing the incomplete session folder...[/]")
|
||||
ui.log(f"[yellow]{t('aborted')}[/]")
|
||||
finally:
|
||||
if aborted:
|
||||
shutil.rmtree(session, ignore_errors=True)
|
||||
ui.log(f"[yellow]Removed:[/] {session}")
|
||||
ui.log(f"[yellow]{t('removed', path=session)}[/]")
|
||||
else:
|
||||
with open(session / "vrc.log", "w", encoding="utf-8") as f:
|
||||
for s in summaries:
|
||||
|
|
@ -242,17 +255,17 @@ def main() -> None:
|
|||
)
|
||||
(session / "err_out.log").write_text("", encoding="utf-8")
|
||||
outer = finalize_session(session)
|
||||
ui.log(f"[bold green]Bundle ready:[/] {outer}")
|
||||
ui.log(f"[bold green]{t('bundle_ready', path=outer)}[/]")
|
||||
|
||||
stop.set()
|
||||
ui_thread.join(timeout=2.0)
|
||||
|
||||
console.print()
|
||||
if aborted:
|
||||
console.print("[yellow]Aborted. Session folder removed.[/]")
|
||||
console.print(f"[yellow]{t('aborted_msg')}[/]")
|
||||
else:
|
||||
console.print(f"[bold green]Done.[/] Bundle: {outer}")
|
||||
input("Press Enter to exit")
|
||||
console.print(f"[bold green]{t('done', path=outer)}[/]")
|
||||
input(t("press_enter"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
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:
|
||||
|
||||
┌─ vrcx v0.2.0 made by engelgardt ────────────────────────┐
|
||||
┌─ vrcx v0.2.0 ────────────────────────────────────────────┐
|
||||
│ Session: 16052026_124500 out/16052026_124500/... │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
┌─ Hosts ──────────────────────────────────────────────────┐
|
||||
|
|
@ -30,6 +30,7 @@ from rich.table import Table
|
|||
from rich.text import Text
|
||||
|
||||
from . import __version__
|
||||
from .i18n import t
|
||||
|
||||
|
||||
HEADER_LINES = 4
|
||||
|
|
@ -118,8 +119,9 @@ class Ui:
|
|||
def _render_header(self) -> Panel:
|
||||
body = (
|
||||
f"[bold cyan]vrcx v{__version__}[/]\n"
|
||||
f"Session: [bold]{self.session_label}[/] Output: [dim]{self.out_path}[/]\n"
|
||||
f"[bold yellow]Ctrl+C[/] — abort and delete this session folder."
|
||||
f"{t('session_label')}: [bold]{self.session_label}[/] "
|
||||
f"{t('output_label')}: [dim]{self.out_path}[/]\n"
|
||||
f"[bold yellow]Ctrl+C[/] — {t('ctrlc_hint')}"
|
||||
)
|
||||
return Panel(body, border_style="cyan")
|
||||
|
||||
|
|
@ -131,14 +133,14 @@ class Ui:
|
|||
return f"{label} {r[prefix + 'step']}/{total} {r[prefix + 'label']}"
|
||||
|
||||
def _render_table(self) -> Table:
|
||||
t = Table(expand=True, header_style="bold")
|
||||
t.add_column("#", style="dim", width=3, justify="right")
|
||||
t.add_column("Host", width=18)
|
||||
t.add_column("Status", width=12)
|
||||
t.add_column("Step", overflow="ellipsis")
|
||||
t.add_column("OK/Fail", width=10, justify="right")
|
||||
t.add_column("Serial", width=14)
|
||||
t.add_column("Note", overflow="ellipsis")
|
||||
tbl = Table(expand=True, header_style="bold")
|
||||
tbl.add_column("#", style="dim", width=3, justify="right")
|
||||
tbl.add_column(t("col_host"), width=18)
|
||||
tbl.add_column(t("col_status"), width=12)
|
||||
tbl.add_column(t("col_step"), overflow="ellipsis")
|
||||
tbl.add_column(t("col_okfail"), width=10, justify="right")
|
||||
tbl.add_column(t("col_serial"), width=14)
|
||||
tbl.add_column(t("col_note"), overflow="ellipsis")
|
||||
|
||||
with self.rows_lock:
|
||||
items = list(self.rows.items())
|
||||
|
|
@ -158,7 +160,7 @@ class Ui:
|
|||
ok_total = r["bmc_ok"] + r["os_ok"]
|
||||
fail_total = r["bmc_fail"] + r["os_fail"]
|
||||
note = r.get("error") or ""
|
||||
t.add_row(
|
||||
tbl.add_row(
|
||||
str(i),
|
||||
host,
|
||||
Text(r["status"], style=style),
|
||||
|
|
@ -168,20 +170,20 @@ class Ui:
|
|||
note,
|
||||
)
|
||||
if overflow:
|
||||
t.add_row("…", "", "", f"(+{overflow} more — enlarge window)", "", "", "")
|
||||
return t
|
||||
tbl.add_row("…", "", "", t("more_hosts", n=overflow), "", "", "")
|
||||
return tbl
|
||||
|
||||
def _render_events(self) -> Panel:
|
||||
with self.events_lock:
|
||||
last = list(self.events)[-20:]
|
||||
body = "\n".join(last) if last else "[dim](no events yet)[/]"
|
||||
return Panel(body, title="Events", border_style="dim")
|
||||
body = "\n".join(last) if last else f"[dim]{t('no_events')}[/]"
|
||||
return Panel(body, title=t("events_title"), border_style="dim")
|
||||
|
||||
def _render_screen(self) -> Layout:
|
||||
layout = Layout()
|
||||
layout.split_column(
|
||||
Layout(self._render_header(), name="hdr", size=HEADER_LINES),
|
||||
Layout(Panel(self._render_table(), title="Hosts", border_style="cyan"), name="tbl"),
|
||||
Layout(Panel(self._render_table(), title=t("hosts_title"), border_style="cyan"), name="tbl"),
|
||||
Layout(self._render_events(), name="evt", size=EVENTS_LINES),
|
||||
)
|
||||
return layout
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
"""Auto-update check on startup. Same pattern as in dhcpsrv/netswitch."""
|
||||
"""
|
||||
Auto-update check.
|
||||
|
||||
On startup, ask GitHub for the latest release tag. If it's newer than the
|
||||
local `__version__`, return the tag string — the caller renders a clickable
|
||||
hint next to the title. Silent on any error (offline, rate-limit, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
|
||||
from rich.console import Console
|
||||
from rich.prompt import Confirm
|
||||
|
||||
from . import __version__, GITHUB_REPO
|
||||
|
||||
|
|
@ -22,7 +24,10 @@ def _parse_version(s: str) -> tuple[int, int, int]:
|
|||
return (0, 0, 0)
|
||||
|
||||
|
||||
def check_for_update(console: Console) -> None:
|
||||
def check_for_update() -> str | None:
|
||||
"""Return the latest release tag (e.g. 'v1.2.0') when it is newer than
|
||||
the currently running version. Returns None when up-to-date, offline,
|
||||
rate-limited or on any error — caller decides how to render."""
|
||||
try:
|
||||
url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
||||
req = urllib.request.Request(url, headers={
|
||||
|
|
@ -32,16 +37,8 @@ def check_for_update(console: Console) -> None:
|
|||
with urllib.request.urlopen(req, timeout=3) as r:
|
||||
data = json.loads(r.read().decode("utf-8", errors="replace"))
|
||||
latest = (data.get("tag_name") or "").strip()
|
||||
page = data.get("html_url") or f"https://github.com/{GITHUB_REPO}/releases/latest"
|
||||
|
||||
if _parse_version(latest) > _parse_version(__version__):
|
||||
console.rule("[bold yellow]Update available")
|
||||
console.print(f"Current: [dim]v{__version__}[/] Latest: [bold green]{latest}[/]")
|
||||
try:
|
||||
if Confirm.ask("Open the download page in your browser?", default=True):
|
||||
webbrowser.open(page)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
pass
|
||||
console.print()
|
||||
if latest and _parse_version(latest) > _parse_version(__version__):
|
||||
return latest
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
|
|
|||
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