feat(tui): add gum dashboard and read-only workflow

This commit is contained in:
2026-10-14 21:56:18 +03:00
parent 72c0d21977
commit 8b40b8cd65
5 changed files with 362 additions and 15 deletions
+15 -1
View File
@@ -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:
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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