feat(tui): add gum dashboard and read-only workflow
This commit is contained in:
@@ -269,7 +269,7 @@ Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`.
|
||||
| M6 | DONE | Safe client removal and archival |
|
||||
| M7 | DONE | Diagnostics engine and report |
|
||||
| M8 | DONE | Backup listing/restoration and audit log UX |
|
||||
| M9 | NOT STARTED | TUI foundation, dashboard, and read-only screens |
|
||||
| M9 | DONE | TUI foundation, dashboard, and read-only screens |
|
||||
| M10 | NOT STARTED | Complete TUI mutation workflows |
|
||||
| M11 | NOT STARTED | Hardening, documentation, and MVP acceptance |
|
||||
|
||||
@@ -1677,6 +1677,20 @@ Use this template:
|
||||
- Suggested commit message: `feat(backup): add audited backup listing and restoration`
|
||||
- Next milestone: M9
|
||||
|
||||
## 2026-10-14 — M9 — DONE
|
||||
|
||||
- Implemented: validated fixed-order `gum` resolution and capability checks; isolated wrappers for style, choose, filter, input, confirm, table, pager, and spinner; interactive-terminal gating; a Russian dashboard and shallow read-only menu; backend-routed status, client list/detail, diagnostics, audit, and backup screens; stable client selection; normal Escape handling; and non-mutating add/remove placeholders.
|
||||
- Coordinator: `gpt-5.6-sol` (`high`)
|
||||
- Subagents: `milestone_worker` / `milestone_repairer`, `gpt-5.6-luna` (`medium`)
|
||||
- Repair cycles: 5
|
||||
- Sol review: replaced the worker's aliased M1-only coverage with real M9 implementation and tests; fixed production-directory temporary writes, ambiguous name/index selection, colliding display identities, literal dashboard newlines, English menu tokens, mock stdin consumption, cancellation false positives, TTY and gum-path cleanup, incomplete route/read-only evidence, 80-column labels, and fixture environment contamination; added regression coverage for every material finding.
|
||||
- Verification: `bash -n wg-manager.sh tests/run.sh tests/mocks/bin/gum`, `bash tests/run.sh gum-resolution`, `bash tests/run.sh tui-routing`, `bash tests/run.sh tui-cancel`, `bash tests/run.sh tui-read-only`, `bash tests/run.sh tui-no-gum`, `bash tests/run.sh tui-non-tty`, `bash tests/run.sh`, `git diff --check`, full changed-file and secret-pattern review.
|
||||
- Result: all required checks passed; gum-resolution/tui-routing/tui-cancel/tui-read-only/tui-no-gum/tui-non-tty passed 21/18/9/15/4/8 assertions, and the full regression suite passed 781 assertions. Optional ShellCheck and real-`gum` interactive smoke were `SKIP` because neither ShellCheck nor a usable `gum` binary is installed.
|
||||
- Decisions: none
|
||||
- Known limitations: real WireGuard, `/etc/wireguard`, networking, systemd, firewall, routes, sysctl, journal, and production terminal state were not accessed; M9 verification is fixture- and mock-based as required. Add/remove remain read-only placeholders until M10.
|
||||
- Suggested commit message: `feat(tui): add gum dashboard and read-only workflows`
|
||||
- Next milestone: M10
|
||||
|
||||
# 8. Final completion rule
|
||||
|
||||
The MVP is complete only when:
|
||||
|
||||
@@ -84,6 +84,18 @@ for an operational read/export/QR failure. `config NAME --show` is the only
|
||||
inspection form that writes the complete client configuration to stdout;
|
||||
ordinary `show`, `config`, and `qr` output remains secret-safe.
|
||||
|
||||
## M9 TUI
|
||||
|
||||
Запуск без аргументов или командой `tui` открывает read-only интерфейс `gum` в
|
||||
терминале. `gum` разрешается в порядке `WG_MANAGER_GUM`, локального `bin/gum`,
|
||||
затем `PATH`; необходимые подкоманды проверяются до вывода интерфейса. Нужны
|
||||
одновременно интерактивные stdin и stdout. Escape и отмена возвращают на
|
||||
предыдущий экран или завершают меню без изменений. Доступны status, clients/show,
|
||||
diagnose, log и backups; add/remove в M9 являются placeholder-действиями и не
|
||||
выполняют мутаций. CLI работает независимо от `gum`; отсутствие или неполная
|
||||
возможность `gum` даёт код `69` только для TUI, а неинтерактивный запуск TUI —
|
||||
код `64`.
|
||||
|
||||
## M6 удаление клиента
|
||||
|
||||
`remove NAME [--yes]` удаляет только подтверждённый managed-peer после повторной проверки точного публичного ключа, адресов и блока под lock. В интерактивном терминале без `--yes` показываются имя, адреса и только последние 8 символов публичного ключа; ответ, отличный от `y`, отменяет операцию без изменений. В неинтерактивном режиме `--yes` обязателен.
|
||||
|
||||
+44
-9
@@ -2,14 +2,49 @@
|
||||
set -Eeuo pipefail
|
||||
command_name=${0##*/}
|
||||
: "${TEST_MOCK_LOG:?TEST_MOCK_LOG must identify the isolated test log}"
|
||||
allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
|
||||
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"; for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done; printf '\n' >>"$TEST_MOCK_LOG"
|
||||
if [[ -n "$allow_empty" && "$allow_empty" != "$command_name" ]]; then
|
||||
printf 'Unexpected mock invocation: %s does not allow empty calls for %s\n' "$command_name" "$allow_empty" >&2
|
||||
exit 99
|
||||
fi
|
||||
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
|
||||
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"
|
||||
for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done
|
||||
printf '\n' >>"$TEST_MOCK_LOG"
|
||||
if (($# == 1)) && [[ $1 == --version ]]; then printf 'gum version 0.14.0\n'; exit 0; fi
|
||||
subcommand=${1:-}
|
||||
if [[ $subcommand == --help || $subcommand == '' ]]; then exit 0; fi
|
||||
if (($# >= 2)) && [[ ${!#} == --help ]]; then
|
||||
[[ ${GUM_FAIL_CAPABILITY:-} == "$subcommand" ]] && exit 98
|
||||
exit 0
|
||||
fi
|
||||
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
|
||||
exit 99
|
||||
case $subcommand in
|
||||
style)
|
||||
[[ $# -gt 1 ]] && [[ ${!#} != *'\n'* ]] || exit 98
|
||||
if [[ -n ${GUM_STYLE_CAPTURE:-} ]]; then printf '%s' "${!#}" >"$GUM_STYLE_CAPTURE"; fi
|
||||
printf '%s\n' "${!#}"
|
||||
exit 0
|
||||
;;
|
||||
table|pager)
|
||||
if [[ -n ${GUM_PAGER_CAPTURE:-} ]]; then cat >"$GUM_PAGER_CAPTURE"; else cat >/dev/null; fi
|
||||
exit 0
|
||||
;;
|
||||
spin) exit 0 ;;
|
||||
choose|filter|input|confirm)
|
||||
if [[ -n ${GUM_CHOICES_CAPTURE:-} && ( $subcommand == choose || $subcommand == filter ) ]]; then
|
||||
: >"$GUM_CHOICES_CAPTURE"
|
||||
if [[ $subcommand == filter ]]; then
|
||||
shift 3
|
||||
else
|
||||
shift
|
||||
fi
|
||||
printf '%s\n' "$@" >>"$GUM_CHOICES_CAPTURE"
|
||||
fi
|
||||
script=${GUM_SCRIPT_FILE:-}; answer=''
|
||||
if [[ -n $script && -f $script ]]; then
|
||||
IFS= read -r answer <"$script" || answer=''
|
||||
tail -n +2 -- "$script" >"${script}.next"
|
||||
mv -- "${script}.next" "$script"
|
||||
fi
|
||||
case $subcommand in
|
||||
confirm) [[ $answer == y || $answer == Y ]] || exit 1; exit 0 ;;
|
||||
input) printf '%s\n' "$answer"; exit 0 ;;
|
||||
*) [[ -n $answer ]] && printf '%s\n' "$answer" || exit 1; exit 0 ;;
|
||||
esac
|
||||
;;
|
||||
*) printf 'Unexpected gum subcommand: %s\n' "$subcommand" >&2; exit 99 ;;
|
||||
esac
|
||||
|
||||
+186
-1
@@ -11,7 +11,7 @@ source "${PROJECT_DIR}/wg-manager.sh"
|
||||
|
||||
case $# in
|
||||
0) ;;
|
||||
1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes|add-client|address-allocation|add-collisions|add-rollback|no-secret-logs|add-golden-config|add-invalid-names|show-client|config-export|qr|path-safety|no-secret-default-output|remove-client|remove-cancel|remove-exact-block|remove-rollback|archive-client|archive-path-safety|remove-preserves-neighbors|diagnostics|diagnostics-systemd|diagnostics-firewall|diagnostics-forwarding|diagnostics-runtime-diff|diagnostics-read-only|diagnostics-exit-codes|audit|audit-no-secrets|backup-list|restore|restore-invalid|restore-rollback|backup-path-safety|runtime-file-modes) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;;
|
||||
1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes|add-client|address-allocation|add-collisions|add-rollback|no-secret-logs|add-golden-config|add-invalid-names|show-client|config-export|qr|path-safety|no-secret-default-output|remove-client|remove-cancel|remove-exact-block|remove-rollback|archive-client|archive-path-safety|remove-preserves-neighbors|diagnostics|diagnostics-systemd|diagnostics-firewall|diagnostics-forwarding|diagnostics-runtime-diff|diagnostics-read-only|diagnostics-exit-codes|audit|audit-no-secrets|backup-list|restore|restore-invalid|restore-rollback|backup-path-safety|runtime-file-modes|gum-resolution|tui-routing|tui-cancel|tui-read-only|tui-no-gum|tui-non-tty) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;;
|
||||
*) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;;
|
||||
esac
|
||||
test_group=${1-}
|
||||
@@ -385,6 +385,184 @@ run_gum_tests() {
|
||||
set -e
|
||||
assert_equal "$EXIT_DEPENDENCY" "$status" 'ошибка WG_MANAGER_GUM'
|
||||
assert_contains 'WG_MANAGER_GUM' "$output" 'сообщение WG_MANAGER_GUM'
|
||||
rmdir -- "${TEST_ROOT}/bin/gum"
|
||||
rmdir -- "${TEST_ROOT}/directory-gum"
|
||||
}
|
||||
|
||||
prepare_m9_fixture() {
|
||||
local config_file="${TEST_ROOT}/m9.conf" fixture_error
|
||||
mkdir -p -- "${TEST_ROOT}/clients" "${TEST_ROOT}/metadata" "${TEST_ROOT}/backups" "${TEST_ROOT}/logs"
|
||||
cp -- "${PROJECT_DIR}/tests/fixtures/wg0.conf" "${TEST_ROOT}/wg0.conf"
|
||||
write_valid_config "$config_file"
|
||||
export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/metadata" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/manager.lock" WG_MANAGER_GUM="${PROJECT_DIR}/tests/mocks/bin/gum" WG_MANAGER_TEST_MODE=1 WG_MANAGER_TEST_TTY=both GUM_STYLE_CAPTURE="${TEST_ROOT}/gum-style.txt" GUM_PAGER_CAPTURE="${TEST_ROOT}/gum-pager.txt" GUM_CHOICES_CAPTURE="${TEST_ROOT}/gum-choices.txt"
|
||||
mkdir -p -- "${TEST_ROOT}/archive"
|
||||
printf '1\n' >"${TEST_ROOT}/ipv4-forward"
|
||||
printf '1\n' >"${TEST_ROOT}/ipv6-forward"
|
||||
export WG_MANAGER_IPV4_FORWARD_FILE="${TEST_ROOT}/ipv4-forward" WG_MANAGER_IPV6_FORWARD_FILE="${TEST_ROOT}/ipv6-forward"
|
||||
printf '[Interface]\nPrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAddress = 10.0.0.2/32\n' >"${TEST_ROOT}/clients/alice.conf"
|
||||
chmod 600 -- "${TEST_ROOT}/clients/alice.conf"
|
||||
printf 'NAME=alice\nPUBLIC_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nIPV4=192.0.2.2\nCREATED_AT=1700000000\nCONFIG_PATH=%s\nCLIENT_MODE=full-tunnel\nMANAGED=1\n' "${TEST_ROOT}/clients/alice.conf" >"${TEST_ROOT}/metadata/alice.meta"
|
||||
chmod 600 -- "${TEST_ROOT}/metadata/alice.meta"
|
||||
load_config >/dev/null
|
||||
printf 'timestamp=2023-11-14T22:13:20Z actor_uid=%s operation=tui target=fixture client=fixture key_suffix= outcome=success error=read_only\n' "$EUID" >"${TEST_ROOT}/logs/wg-manager.log"
|
||||
chmod 600 -- "${TEST_ROOT}/logs/wg-manager.log"
|
||||
if ! fixture_error=$(create_backup "$WG_CONF" tui fixture 2>&1); then
|
||||
printf 'Ошибка тестовой фикстуры: не удалось создать резервную копию: %s\n' "$fixture_error" >&2
|
||||
return 1
|
||||
fi
|
||||
: >"$TEST_MOCK_LOG"
|
||||
}
|
||||
|
||||
run_m9_tests() {
|
||||
local output status script_file tty_mode pager_count snapshot_before lock_status long_name
|
||||
case $test_group in
|
||||
gum-resolution)
|
||||
run_gum_tests
|
||||
prepare_m9_fixture
|
||||
begin_test 'gum capability probes accept the scripted mock'
|
||||
output=$(resolve_gum)
|
||||
assert_contains 'tests/mocks/bin/gum' "$output" 'gum mock resolved'
|
||||
validate_gum_capabilities "$output" >/dev/null
|
||||
assert_success "$?" 'gum capabilities'
|
||||
export GUM_FAIL_CAPABILITY=filter
|
||||
set +e; output=$(validate_gum_capabilities "$output" 2>&1); status=$?; set -e
|
||||
assert_equal "$EXIT_DEPENDENCY" "$status" 'missing gum capability status'
|
||||
assert_contains 'filter' "$output" 'missing capability diagnostic names subcommand'
|
||||
unset GUM_FAIL_CAPABILITY
|
||||
printf 'Выбор\nКлиент\ny\ny\n' >"${TEST_ROOT}/gum-wrapper-script"
|
||||
export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-wrapper-script"
|
||||
: >"$TEST_MOCK_LOG"
|
||||
gum_header 'Заголовок' >/dev/null
|
||||
gum_choose 'Клиент' 'Выход' >/dev/null
|
||||
gum_filter 'Клиент' >/dev/null
|
||||
gum_input --placeholder 'Имя' >/dev/null
|
||||
gum_confirm 'Продолжить' >/dev/null
|
||||
printf 'таблица\n' | gum_table >/dev/null
|
||||
printf 'страница\n' | gum_pager >/dev/null
|
||||
gum_spin --title 'Ожидание' >/dev/null
|
||||
begin_test 'all gum wrappers keep their raw invocation details isolated'
|
||||
for capability in style choose filter input confirm table pager spin; do
|
||||
assert_contains "gum $capability" "$(<"$TEST_MOCK_LOG")" "обёртка gum $capability"
|
||||
done
|
||||
;;
|
||||
tui-routing)
|
||||
prepare_m9_fixture
|
||||
script_file="${TEST_ROOT}/gum-script"; printf 'Состояние\nВыход\n' >"$script_file"; export GUM_SCRIPT_FILE="$script_file"
|
||||
begin_test 'TUI routes status through the dashboard loop'
|
||||
set +e; output=$(tui_screen 2>&1); status=$?; set -e
|
||||
assert_equal 0 "$status" 'TUI status route exits cleanly'
|
||||
assert_contains 'gum choose' "$(<"$TEST_MOCK_LOG")" 'menu wrapper invocation recorded'
|
||||
assert_contains 'gum pager' "$(<"$TEST_MOCK_LOG")" 'pager wrapper invocation recorded'
|
||||
assert_contains 'WireGuard Manager — wg0' "$(<"$GUM_STYLE_CAPTURE")" 'dashboard interface'
|
||||
assert_contains 'Состояние:' "$(<"$GUM_STYLE_CAPTURE")" 'dashboard runtime state'
|
||||
assert_contains 'Управляемых:' "$(<"$GUM_STYLE_CAPTURE")" 'dashboard managed count'
|
||||
assert_contains 'Активны недавно:' "$(<"$GUM_STYLE_CAPTURE")" 'dashboard active count'
|
||||
assert_contains 'Предупреждения:' "$(<"$GUM_STYLE_CAPTURE")" 'dashboard warning count'
|
||||
assert_equal 0 "$(grep -c '\\\\n' "$GUM_STYLE_CAPTURE" || true)" 'dashboard has real line breaks'
|
||||
assert_equal 0 "$(awk 'length($0) > 80 { count += 1 } END { print count + 0 }' "$GUM_STYLE_CAPTURE")" 'dashboard lines fit 80 columns'
|
||||
printf 'Неизвестно\nНеизвестно\nНеизвестно\n' >"$script_file"
|
||||
set +e; tui_screen >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal "$EXIT_OPERATION" "$status" 'three route failures are bounded'
|
||||
printf 'Диагностика\nЖурнал\nРезервные копии\nДобавить\nУдалить\nВыход\n' >"$script_file"
|
||||
: >"$TEST_MOCK_LOG"
|
||||
set +e; output=$(tui_screen 2>&1); status=$?; set -e
|
||||
assert_equal 0 "$status" 'all read-only and placeholder routes return to menu'
|
||||
assert_contains 'Действие «Добавить»' "$output" 'add placeholder is visible'
|
||||
assert_contains 'Действие «Удалить»' "$output" 'remove placeholder is visible'
|
||||
assert_equal 0 "$(grep -c '^gum pager$' "$TEST_MOCK_LOG" | awk '$1 >= 3 { print 0; next } { print 1 }')" 'diagnose/log/backups reach pager'
|
||||
printf 'Выход\n' >"$script_file"
|
||||
set +e; main >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal 0 "$status" 'zero-argument main enters TUI'
|
||||
printf 'Выход\n' >"$script_file"
|
||||
set +e; main tui >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal 0 "$status" 'explicit tui command exits cleanly'
|
||||
assert_equal '' "${TUI_GUM_PATH:-}" 'explicit tui clears resolved gum path'
|
||||
;;
|
||||
tui-cancel)
|
||||
prepare_m9_fixture
|
||||
script_file="${TEST_ROOT}/gum-script"; : >"$script_file"; export GUM_SCRIPT_FILE="$script_file"
|
||||
begin_test 'TUI cancellation returns normally without mutation'
|
||||
set +e; tui_screen >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal 0 "$status" 'menu cancellation'
|
||||
assert_equal '' "$(grep -E 'wg (syncconf|quick)|wg-quick' "$TEST_MOCK_LOG" || true)" 'cancellation has no mutation command'
|
||||
printf 'Клиенты\n\nВыход\n' >"$script_file"
|
||||
: >"$TEST_MOCK_LOG"
|
||||
set +e; tui_screen >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal 0 "$status" 'client filter cancellation returns to menu'
|
||||
assert_equal 2 "$(grep '^gum choose' "$TEST_MOCK_LOG" | grep -vc -- '--help' || true)" 'filter cancellation returns to main menu'
|
||||
assert_equal 1 "$(grep '^gum filter' "$TEST_MOCK_LOG" | grep -vc -- '--help' || true)" 'filter cancellation invokes one filter'
|
||||
assert_equal 1 "$(grep '^gum pager' "$TEST_MOCK_LOG" | grep -vc -- '--help' || true)" 'filter cancellation only pages the client list'
|
||||
assert_equal 0 "$(grep -c 'TUI завершён\|Неизвестный пункт' "$TEST_MOCK_LOG" || true)" 'filter cancellation is not a route failure'
|
||||
assert_equal '' "${TUI_GUM_PATH:-}" 'TUI gum path is cleared after cancellation'
|
||||
unset WG_MANAGER_TEST_TTY
|
||||
set +e; tui_is_terminal >/dev/null 2>&1; status=$?; set -e
|
||||
assert_failure "$status" 'unset test TTY is not accepted'
|
||||
;;
|
||||
tui-read-only)
|
||||
prepare_m9_fixture
|
||||
script_file="${TEST_ROOT}/gum-script"; printf 'Клиенты\nalice — BBBBBBBB\nВыход\n' >"$script_file"; export GUM_SCRIPT_FILE="$script_file"
|
||||
snapshot_before=$(find "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/archive" "${TEST_ROOT}/clients" "${TEST_ROOT}/metadata" -mindepth 1 -maxdepth 2 -printf '%P %y %m %s\n' | LC_ALL=C sort; sha256sum "$WG_CONF" "$WG_MANAGER_CONFIG" "${TEST_ROOT}/clients/alice.conf" "${TEST_ROOT}/metadata/alice.meta"; stat -c '%a:%u:%g:%h' "$WG_CONF" "$WG_MANAGER_CONFIG" "${TEST_ROOT}/clients/alice.conf" "${TEST_ROOT}/metadata/alice.meta")
|
||||
begin_test 'TUI client selection uses stable identity and read-only backend'
|
||||
set +e; tui_screen >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal 0 "$status" 'client route exits cleanly'
|
||||
assert_contains 'gum filter' "$(<"$TEST_MOCK_LOG")" 'filter wrapper invocation recorded'
|
||||
pager_count=$(grep -c '^gum pager$' "$TEST_MOCK_LOG" || true)
|
||||
((pager_count >= 1)) && ((TEST_ASSERTIONS += 1)) || record_failure 'client list is paged before selection'
|
||||
assert_equal '' "$(grep -E 'syncconf|wg-quick (up|down|save)|ip (add|del|replace)' "$TEST_MOCK_LOG" || true)" 'read-only screen has no mutation calls'
|
||||
CLIENT_NAMES=(same same) CLIENT_KEYS=(ABCDEFGH111111111111111111111111111111111= ABCDEFGH222222222222222222222222222222222=)
|
||||
CLIENT_ADDRESSES=(192.0.2.2/32 192.0.2.2/32) CLIENT_ENDPOINTS=(- -) CLIENT_HANDSHAKES=(0 0) CLIENT_RX=(0 0) CLIENT_TX=(0 0) CLIENT_STATES=(unmanaged unmanaged) CLIENT_ORIGINS=(unmanaged unmanaged)
|
||||
printf 'same — ABCDEFGH #2\n' >"$script_file"
|
||||
set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal 0 "$status" 'duplicate display names select the offered stable index'
|
||||
assert_contains 'ABCDEFGH222222222222222222222222222222222=' "$(<"$GUM_PAGER_CAPTURE")" 'selected duplicate identity renders the second full key'
|
||||
assert_equal 0 "$(grep -c 'ABCDEFGH111111111111111111111111111111111=' "$GUM_PAGER_CAPTURE" || true)" 'selected detail does not render the first key'
|
||||
long_name=$(printf 'n%.0s' {1..64})
|
||||
CLIENT_NAMES=("$long_name" "$long_name") CLIENT_KEYS=(ABCDEFGH111111111111111111111111111111111= ABCDEFGH222222222222222222222222222222222=)
|
||||
CLIENT_ADDRESSES=(192.0.2.2/32 192.0.2.2/32)
|
||||
printf '%s\n' "${long_name:0:60} — ABCDEFGH #2" >"$script_file"
|
||||
set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal 0 "$status" 'long client names remain selectable'
|
||||
assert_equal 0 "$(awk 'length($0) > 80 { bad += 1 } END { print bad + 0 }' "$GUM_CHOICES_CAPTURE")" 'offered client labels fit 80 columns'
|
||||
assert_contains 'ABCDEFGH222222222222222222222222222222222=' "$(<"$GUM_PAGER_CAPTURE")" 'long-name collision keeps the selected identity'
|
||||
assert_equal "$snapshot_before" "$(find "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/archive" "${TEST_ROOT}/clients" "${TEST_ROOT}/metadata" -mindepth 1 -maxdepth 2 -printf '%P %y %m %s\n' | LC_ALL=C sort; sha256sum "$WG_CONF" "$WG_MANAGER_CONFIG" "${TEST_ROOT}/clients/alice.conf" "${TEST_ROOT}/metadata/alice.meta"; stat -c '%a:%u:%g:%h' "$WG_CONF" "$WG_MANAGER_CONFIG" "${TEST_ROOT}/clients/alice.conf" "${TEST_ROOT}/metadata/alice.meta")" 'read-only files, modes, and directory entries unchanged'
|
||||
assert_equal '' "$(grep -E 'syncconf|wg-quick (up|down|save)|ip (add|del|replace)|systemctl (start|stop|restart)|nft (add|delete)|iptables -.*-(A|D|I)' "$TEST_MOCK_LOG" || true)" 'read-only command log has no mutation'
|
||||
assert_equal 0 "$(find "${WG_CONF%/*}" -maxdepth 1 -name '.wg-manager-tui.*' -print | wc -l)" 'read-only pages create no config-directory temporary files'
|
||||
if [[ -e $WG_MANAGER_LOCK_FILE ]]; then lock_status=1; else lock_status=0; fi
|
||||
assert_equal 0 "$lock_status" 'read-only screens do not create a mutation lock'
|
||||
assert_equal 0 "$(grep -hE 'AAAAAAAAAAAAAAAA|PresharedKey' "$GUM_PAGER_CAPTURE" "$GUM_STYLE_CAPTURE" "$TEST_MOCK_LOG" | wc -l)" 'read-only TUI output has no secrets'
|
||||
;;
|
||||
tui-no-gum)
|
||||
prepare_m9_fixture
|
||||
export WG_MANAGER_GUM="${TEST_ROOT}/missing-gum"
|
||||
begin_test 'TUI without gum reports dependency while CLI remains independent'
|
||||
set +e; output=$(tui_screen 2>&1); status=$?; set -e
|
||||
assert_equal "$EXIT_DEPENDENCY" "$status" 'missing gum status'
|
||||
assert_contains 'WG_MANAGER_GUM' "$output" 'missing gum diagnostic'
|
||||
assert_equal '' "${TUI_GUM_PATH:-}" 'gum path is cleared after dependency failure'
|
||||
set +e; output=$(main status 2>&1); status=$?; set -e
|
||||
assert_equal 0 "$status" 'CLI status remains independent of gum'
|
||||
;;
|
||||
tui-non-tty)
|
||||
prepare_m9_fixture
|
||||
unset WG_MANAGER_TEST_TTY
|
||||
begin_test 'TUI refuses noninteractive input before gum output'
|
||||
set +e; output=$(tui_screen 2>&1); status=$?; set -e
|
||||
assert_equal "$EXIT_USAGE" "$status" 'non-TTY status'
|
||||
assert_contains 'интерактивный терминал' "$output" 'non-TTY diagnostic'
|
||||
assert_equal '' "$(<"$TEST_MOCK_LOG")" 'non-TTY has no gum invocation'
|
||||
for tty_mode in stdin stdout none; do
|
||||
export WG_MANAGER_TEST_TTY="$tty_mode"
|
||||
set +e; tui_is_terminal >/dev/null 2>&1; status=$?; set -e
|
||||
assert_failure "$status" "TTY режим ${tty_mode} отклонён"
|
||||
done
|
||||
export WG_MANAGER_TEST_TTY=both
|
||||
set +e; tui_is_terminal >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal 0 "$status" 'оба потока TTY принимаются в изолированном режиме'
|
||||
set +e; main tui extra >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal "$EXIT_USAGE" "$status" 'tui extra argument is usage'
|
||||
;;
|
||||
esac
|
||||
unset GUM_SCRIPT_FILE WG_MANAGER_GUM WG_MANAGER_TEST_TTY WG_MANAGER_IPV4_FORWARD_FILE WG_MANAGER_IPV6_FORWARD_FILE GUM_STYLE_CAPTURE GUM_PAGER_CAPTURE GUM_CHOICES_CAPTURE GUM_FAIL_CAPABILITY
|
||||
}
|
||||
|
||||
run_dependency_tests() {
|
||||
@@ -3324,6 +3502,8 @@ elif [[ $test_group == add-client || $test_group == address-allocation || $test_
|
||||
run_m4_tests
|
||||
elif [[ $test_group == remove-client || $test_group == remove-cancel || $test_group == remove-exact-block || $test_group == remove-rollback || $test_group == archive-client || $test_group == archive-path-safety || $test_group == remove-preserves-neighbors ]]; then
|
||||
run_m6_tests
|
||||
elif [[ $test_group == gum-resolution || $test_group == tui-routing || $test_group == tui-cancel || $test_group == tui-read-only || $test_group == tui-no-gum || $test_group == tui-non-tty ]]; then
|
||||
run_m9_tests
|
||||
elif [[ $test_group == audit || $test_group == audit-no-secrets || $test_group == backup-list || $test_group == restore || $test_group == restore-invalid || $test_group == restore-rollback || $test_group == backup-path-safety || $test_group == runtime-file-modes ]]; then
|
||||
run_m8_tests
|
||||
elif [[ $test_group == diagnostics || $test_group == diagnostics-systemd || $test_group == diagnostics-firewall || $test_group == diagnostics-forwarding || $test_group == diagnostics-runtime-diff || $test_group == diagnostics-read-only || $test_group == diagnostics-exit-codes ]]; then
|
||||
@@ -3350,6 +3530,11 @@ else
|
||||
run_m8_tests
|
||||
done
|
||||
unset m8_group
|
||||
for m9_group in gum-resolution tui-routing tui-cancel tui-read-only tui-no-gum tui-non-tty; do
|
||||
test_group=$m9_group
|
||||
run_m9_tests
|
||||
done
|
||||
unset m9_group
|
||||
test_group=''
|
||||
fi
|
||||
|
||||
|
||||
+105
-4
@@ -1433,6 +1433,106 @@ resolve_gum() {
|
||||
optional_command gum || { die "$EXIT_DEPENDENCY" 'Не найден gum для TUI'; return $?; }
|
||||
}
|
||||
|
||||
validate_gum_capabilities() {
|
||||
local gum_path=$1 capability
|
||||
local -a capabilities=(style choose filter input confirm table pager spin)
|
||||
for capability in "${capabilities[@]}"; do
|
||||
"$gum_path" "$capability" --help >/dev/null 2>&1 || {
|
||||
die "$EXIT_DEPENDENCY" "gum не поддерживает команду: ${capability}"
|
||||
return $?
|
||||
}
|
||||
done
|
||||
}
|
||||
gum_command() { local gum_path=${TUI_GUM_PATH:-}; [[ -n $gum_path ]] || gum_path=$(resolve_gum) || return $?; "$gum_path" "$@"; }
|
||||
gum_header() { gum_command style --border double --padding '0 1' "$1"; }
|
||||
gum_choose() { gum_command choose "$@"; }
|
||||
gum_filter() { gum_command filter --placeholder 'Выберите клиента' "$@"; }
|
||||
gum_input() { gum_command input "$@"; }
|
||||
gum_confirm() { gum_command confirm "$@"; }
|
||||
gum_table() { gum_command table "$@"; }
|
||||
gum_pager() { gum_command pager "$@"; }
|
||||
gum_spin() { gum_command spin "$@"; }
|
||||
tui_is_terminal() {
|
||||
if [[ ${WG_MANAGER_TEST_TTY+x} && ${WG_MANAGER_TEST_TTY} == both ]] && transaction_test_hooks_enabled; then return 0; fi
|
||||
[[ -t 0 && -t 1 ]]
|
||||
}
|
||||
tui_page_command() {
|
||||
local command_name=$1 output backend_status pager_status
|
||||
shift
|
||||
if output=$("$command_name" "$@" 2>&1); then backend_status=0; else backend_status=$?; fi
|
||||
if printf '%s' "$output" | gum_pager; then pager_status=0; else pager_status=$?; fi
|
||||
((backend_status != 0)) && return "$backend_status"
|
||||
return "$pager_status"
|
||||
}
|
||||
|
||||
tui_select_client() {
|
||||
local choice index label base previous ordinal duplicate
|
||||
local -a choices=() choice_indices=()
|
||||
tui_page_command print_list || return $?
|
||||
for index in "${!CLIENT_KEYS[@]}"; do
|
||||
base="${CLIENT_NAMES[$index]} — ${CLIENT_KEYS[$index]:0:8}"
|
||||
ordinal=1; duplicate=0
|
||||
for previous in "${!CLIENT_KEYS[@]}"; do
|
||||
[[ $previous -ge $index ]] && break
|
||||
if [[ "${CLIENT_NAMES[$previous]} — ${CLIENT_KEYS[$previous]:0:8}" == "$base" ]]; then
|
||||
((ordinal += 1)); duplicate=1
|
||||
fi
|
||||
done
|
||||
for previous in "${!CLIENT_KEYS[@]}"; do
|
||||
((previous > index)) || continue
|
||||
if [[ "${CLIENT_NAMES[$previous]} — ${CLIENT_KEYS[$previous]:0:8}" == "$base" ]]; then duplicate=1; break; fi
|
||||
done
|
||||
if ((duplicate == 1)); then label="${CLIENT_NAMES[$index]:0:60} — ${CLIENT_KEYS[$index]:0:8} #${ordinal}"; else label=$base; fi
|
||||
choices+=("$label")
|
||||
choice_indices+=("$index")
|
||||
done
|
||||
((${#choices[@]} > 0)) || { printf 'Клиенты отсутствуют\n'; return 0; }
|
||||
choice=$(gum_filter "${choices[@]}") || return 0
|
||||
index=-1
|
||||
for previous in "${!choices[@]}"; do
|
||||
if [[ ${choices[$previous]} == "$choice" ]]; then
|
||||
if ((index >= 0)); then return "$EXIT_VALIDATION"; fi
|
||||
index=${choice_indices[$previous]}
|
||||
fi
|
||||
done
|
||||
((index >= 0)) || return "$EXIT_VALIDATION"
|
||||
tui_page_command print_client_details "${CLIENT_NAMES[$index]}" "$index"
|
||||
}
|
||||
|
||||
tui_screen() {
|
||||
local choice failures=0 index managed_count=0 active_count=0 warning_count=0 runtime_text dashboard status
|
||||
local -a menu_labels=('Состояние' 'Клиенты' 'Диагностика' 'Журнал' 'Резервные копии' 'Добавить' 'Удалить' 'Выход')
|
||||
unset TUI_GUM_PATH
|
||||
tui_is_terminal || { die "$EXIT_USAGE" 'TUI требует интерактивный терминал'; return $?; }
|
||||
if TUI_GUM_PATH=$(resolve_gum); then :; else status=$?; unset TUI_GUM_PATH; return "$status"; fi
|
||||
validate_gum_capabilities "$TUI_GUM_PATH" || { unset TUI_GUM_PATH; return $?; }
|
||||
load_client_model || { unset TUI_GUM_PATH; return $?; }
|
||||
for index in "${!CLIENT_ORIGINS[@]}"; do
|
||||
if [[ ${CLIENT_ORIGINS[$index]} == managed ]]; then ((managed_count += 1)); fi
|
||||
if [[ ${CLIENT_STATES[$index]} == active ]]; then ((active_count += 1)); fi
|
||||
done
|
||||
((RUNTIME_AVAILABLE == 1)) || ((warning_count += 1))
|
||||
if ((RUNTIME_AVAILABLE == 1)); then runtime_text='доступен'; else runtime_text='недоступен'; fi
|
||||
while ((failures < 3)); do
|
||||
printf -v dashboard 'WireGuard Manager — %s\nСостояние: %s\nКлиентов: %d Управляемых: %d\nАктивны недавно: %d Предупреждения: %d' "$WG_INTERFACE" "$runtime_text" "${#CLIENT_KEYS[@]}" "$managed_count" "$active_count" "$warning_count"
|
||||
if gum_header "$dashboard"; then :; else status=$?; unset TUI_GUM_PATH; return "$status"; fi
|
||||
if choice=$(gum_choose "${menu_labels[@]}"); then :; else unset TUI_GUM_PATH; return 0; fi
|
||||
case $choice in
|
||||
Состояние) tui_page_command print_status || ((failures += 1)) ;;
|
||||
Клиенты) tui_select_client || ((failures += 1)) ;;
|
||||
Диагностика) tui_page_command diagnose || ((failures += 1)) ;;
|
||||
Журнал) tui_page_command list_audit || ((failures += 1)) ;;
|
||||
'Резервные копии') tui_page_command list_backups || ((failures += 1)) ;;
|
||||
Добавить|Удалить) printf 'Действие «%s» доступно через CLI в M9; изменения не выполняются.\n' "$choice" ;;
|
||||
Выход|'') unset TUI_GUM_PATH; return 0 ;;
|
||||
*) printf 'Неизвестный пункт TUI\n' >&2; ((failures += 1)) ;;
|
||||
esac
|
||||
done
|
||||
unset TUI_GUM_PATH
|
||||
die "$EXIT_OPERATION" 'TUI завершён после повторных ошибок'
|
||||
return $?
|
||||
}
|
||||
|
||||
print_version() { printf 'WireGuard Manager %s\n' "$WG_MANAGER_VERSION"; }
|
||||
run_placeholder() { load_config || return $?; die "$EXIT_OPERATION" "Команда «$1» пока не реализована"; }
|
||||
manual_backup() {
|
||||
@@ -2262,9 +2362,9 @@ remove_client() {
|
||||
main() {
|
||||
local status
|
||||
if (( $# == 0 )); then
|
||||
if die "$EXIT_USAGE" 'Ожидается команда'; then status=0; else status=$?; fi
|
||||
print_usage >&2
|
||||
return "$status"
|
||||
load_config || return $?
|
||||
tui_screen
|
||||
return $?
|
||||
fi
|
||||
if [[ $1 == add ]]; then
|
||||
shift
|
||||
@@ -2323,7 +2423,8 @@ main() {
|
||||
remove_client "$@" ;;
|
||||
backup) manual_backup ;;
|
||||
backups) list_backups ;;
|
||||
logs|tui) run_placeholder "$1" ;;
|
||||
tui) (($# == 1)) || { die "$EXIT_USAGE" 'Команда tui не принимает аргументы'; return $?; }; load_config || return $?; tui_screen ;;
|
||||
logs) run_placeholder "$1" ;;
|
||||
*)
|
||||
if die "$EXIT_USAGE" "неизвестная команда: $1"; then status=0; else status=$?; fi
|
||||
print_usage >&2
|
||||
|
||||
Reference in New Issue
Block a user