Implement adaptive client list
This commit is contained in:
@@ -108,24 +108,25 @@ Actual systemd/D-Bus acceptance runs in a disposable VM. Never use production to
|
|||||||
|
|
||||||
Each row is a delivery milestone, not a completion claim. Expand its record before execution; retain its original ID and acceptance scope.
|
Each row is a delivery milestone, not a completion claim. Expand its record before execution; retain its original ID and acceptance scope.
|
||||||
|
|
||||||
| ID | Milestone | Prerequisites | State |
|
| ID | Milestone | Prerequisites | State |
|
||||||
| --- | -------------------------------------------------------- | ----------------------------------------- | ----------- |
|
| --- | --------------------------------------------------------- | ----------------------------------------- | -------- |
|
||||||
| 1 | Rust foundation, settings, and domain states | None | accepted |
|
| 1 | Rust foundation, settings, and domain states | None | accepted |
|
||||||
| 2 | Server configuration editing with byte preservation | 1 | accepted |
|
| 2 | Server configuration editing with byte preservation | 1 | accepted |
|
||||||
| 3 | Client keys, encrypted vault, and existing-client import | 2 | accepted |
|
| 3 | Client keys, encrypted vault, and existing-client import | 2 | accepted |
|
||||||
| 4 | Backups, safe Commit, and full restoration | 3 | accepted |
|
| 4 | Backups, safe Commit, and full restoration | 3 | accepted |
|
||||||
| 5 | Runtime observation and systemd restart adapter | 4 | accepted |
|
| 5 | Runtime observation and systemd restart adapter | 4 | accepted |
|
||||||
| 6 | Client configuration delivery and reissue lifecycle | 5 | accepted |
|
| 6 | Client configuration delivery and reissue lifecycle | 5 | accepted |
|
||||||
| 7 | Complete terminal management workflow | 6 | accepted |
|
| 7 | Complete terminal management workflow | 6 | accepted |
|
||||||
| 8 | Alpine Testcontainers integration | 7; suitable disposable Docker environment | accepted |
|
| 8 | Alpine Testcontainers integration | 7; suitable disposable Docker environment | accepted |
|
||||||
| 9 | Real systemd acceptance and release readiness | 8; disposable systemd VM | retired |
|
| 9 | Real systemd acceptance and release readiness | 8; disposable systemd VM | retired |
|
||||||
| 10 | Visible styled keyboard quick reference in bottom panel | 8; existing M7 TUI | accepted |
|
| 10 | Visible styled keyboard quick reference in bottom panel | 8; existing M7 TUI | accepted |
|
||||||
| 11 | Valid subnet routes shown as a capability limitation | 10 | accepted |
|
| 11 | Valid subnet routes shown as a capability limitation | 10 | accepted |
|
||||||
| 12 | Aligned, responsive client table | 11 | accepted |
|
| 12 | Aligned, responsive client table | 11 | accepted |
|
||||||
| 13 | Compact terminal QR disclosure | 12 | accepted |
|
| 13 | Compact terminal QR disclosure | 12 | accepted |
|
||||||
| 14 | Restart verification for valid legacy peers | 13 | accepted |
|
| 14 | Restart verification for valid legacy peers | 13 | accepted |
|
||||||
| 15 | Diagnosable systemd restart and unloaded-unit resolution | 14 | accepted |
|
| 15 | Diagnosable systemd restart and unloaded-unit resolution | 14 | accepted |
|
||||||
| 16 | Root systemd private peer restart transport | 15 | blocked |
|
| 16 | Root systemd private peer restart transport | 15 | accepted |
|
||||||
|
| 17 | Adaptive client list, persistent details and clear states | 16 accepted; existing M7/M12 TUI | accepted |
|
||||||
|
|
||||||
### Milestone 1 — Rust foundation, settings, and domain states
|
### Milestone 1 — Rust foundation, settings, and domain states
|
||||||
|
|
||||||
@@ -3676,7 +3677,7 @@ Accepted for the synthetic adapter and application scope after R1 (owner-change
|
|||||||
|
|
||||||
### Milestone 16 — Root systemd private peer restart transport
|
### Milestone 16 — Root systemd private peer restart transport
|
||||||
|
|
||||||
Status: blocked (2026-10-27T22:42:48+02:00) on end-to-end private-peer and disposable-VM systemd checks. M15 is accepted for synthetic behavior; M9 disposable-VM systemd acceptance remains retired and unaccepted.
|
Status: accepted by explicit user direction (2026-10-28) following a reported successful manual check. The manual environment, commands and transport coverage were not supplied; the previously recorded disposable-VM evidence gap remains a validation limitation, not a claimed passing VM run. M9 remains retired and unaccepted.
|
||||||
|
|
||||||
#### Outcome and scope
|
#### Outcome and scope
|
||||||
|
|
||||||
@@ -3688,19 +3689,19 @@ Support explicit restart on a root-managed systemd host without a system bus by
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| M16.1 | explorer | complete | Pinned zbus 5.19.0 source and official systemd private-socket protocol findings |
|
| M16.1 | explorer | complete | Pinned zbus 5.19.0 source and official systemd private-socket protocol findings |
|
||||||
| M16.2 | implementer | complete for implementation scope | Transport implementation, zbus feature/docs and 16 focused synthetic tests |
|
| M16.2 | implementer | complete for implementation scope | Transport implementation, zbus feature/docs and 16 focused synthetic tests |
|
||||||
| M16.3 | independent reviewer; repairer | blocked | R1 private permission category closed on re-review; R2 private-path behavioral evidence remains unavailable |
|
| M16.3 | independent reviewer; repairer | user-directed close with evidence gap | R1 closed on re-review; R2 private-path behavioral evidence remains unavailable and was not independently closed |
|
||||||
| M16.4 | coordinator | complete for available checks | Integrated source, pinned Rust gates and [M16 evidence](docs/evidence/milestone-16.md); VM unavailable |
|
| M16.4 | coordinator | complete for available checks | Integrated source, pinned Rust gates and [M16 evidence](docs/evidence/milestone-16.md); VM unavailable |
|
||||||
|
|
||||||
- [ ] M16-A1: root-only private peer connection and system-bus path work against real systemd with correct method destination and job signal tracking. Source audit and synthetic selection checks pass; real transport checks remain unavailable.
|
- [ ] M16-A1: root-only private peer connection and system-bus path work against real systemd with correct method destination and job signal tracking. Source audit and synthetic selection checks pass; real transport checks remain unavailable.
|
||||||
- [x] M16-A2: source and synthetic checks preserve subscribe-before-request, canonical alias ID, bounded deadline, disconnect/uncertainty behavior, and no retry after possible submission.
|
- [x] M16-A2: source and synthetic checks preserve subscribe-before-request, canonical alias ID, bounded deadline, disconnect/uncertainty behavior, and no retry after possible submission.
|
||||||
- [ ] M16-A3: synthetic selection/failure and existing job-tracker tests pass, but the private `SystemdBus` branch was not exercised end to end because local peer credential access returned sandbox `EPERM`.
|
- [ ] M16-A3: synthetic selection/failure and existing job-tracker tests pass, but the private `SystemdBus` branch was not exercised end to end because local peer credential access returned sandbox `EPERM`.
|
||||||
- [ ] M16-A4: R1 was closed, but R2 remains open; pinned Rust gates pass. Disposable-VM private and system-bus acceptance is unavailable.
|
- [ ] M16-A4: R1 was closed; R2 lacks independent behavioral closure. Pinned Rust gates passed, but disposable-VM private and system-bus acceptance is unavailable. User-directed milestone acceptance waives this outstanding evidence rather than proving the criterion.
|
||||||
|
|
||||||
Available checks: focused service/application tests, `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked`, `cargo build --locked --release`, `git diff --check` all passed on repaired inputs. Synthetic checks used no production socket or keys. [M16 evidence](docs/evidence/milestone-16.md) records exact results and identities. Disposable-VM checks were not run.
|
Available checks: focused service/application tests, `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked`, `cargo build --locked --release`, `git diff --check` all passed on repaired inputs. Synthetic checks used no production socket or keys. [M16 evidence](docs/evidence/milestone-16.md) records exact results and identities. Disposable-VM checks were not run.
|
||||||
|
|
||||||
#### Outcome and handoff
|
#### Outcome and handoff
|
||||||
|
|
||||||
Official systemd source confirms root `systemctl` connects to `/run/systemd/private` directly when PID 1 is systemd; official `systemd(1)` docs caution this socket is private to systemd. Pinned zbus 5.19.0 exposes peer mode behind its approved crate's `p2p` feature. The implementation uses destination-free peer calls and local signal matching without bus owner monitoring; the bus path remains available. M16-R1 (P2, private permission category) was repaired and independently closed. M16-R2 (P2, private-branch behavioral evidence) remains open: local zbus peer credential access returned sandbox `EPERM`, and the prior disposable Debian VM has no installed/verified guest. No production or host socket was touched. The next action is a disposable-VM run with synthetic configuration covering both transports; until then M16 cannot be accepted.
|
Official systemd source confirms root `systemctl` connects to `/run/systemd/private` directly when PID 1 is systemd; official `systemd(1)` docs caution this socket is private to systemd. Pinned zbus 5.19.0 exposes peer mode behind its approved crate's `p2p` feature. The implementation uses destination-free peer calls and local signal matching without bus owner monitoring; the bus path remains available. M16-R1 (P2, private permission category) was repaired and independently closed. M16-R2 (P2, private-branch behavioral evidence) remains unverified by the recorded automated evidence: local zbus peer credential access returned sandbox `EPERM`, and the prior disposable Debian VM has no installed/verified guest. The user reported a successful manual check and directed that M16 be considered done; its details and transport scope are unavailable. This user-directed acceptance does not convert the missing disposable-VM checks into passing evidence. No production or host socket was touched by this execution.
|
||||||
|
|
||||||
### Bounded TUI maintenance TUI-FL-001 — Stable keyboard footer
|
### Bounded TUI maintenance TUI-FL-001 — Stable keyboard footer
|
||||||
|
|
||||||
@@ -3722,10 +3723,85 @@ Checks: focused `tui::render::tests` and TUI workflow tests, `cargo fmt --all --
|
|||||||
|
|
||||||
First independent review found TUI-FL-001.2-R1 (P2, omission marker overflow) and R2 (P2, nine-row terminals lose client-list interior). A separate repairer fixed both and independent re-review closed them. That re-review found R3 (P3, unconditional marker reserve omits an action at width 102–103 even when all fit); the two-pass packing repair and regression were independently re-reviewed and closed. Final pinned Rust gates passed. [Evidence](docs/evidence/tui-footer-layout.md) separates synthetic render checks from any physical terminal verification.
|
First independent review found TUI-FL-001.2-R1 (P2, omission marker overflow) and R2 (P2, nine-row terminals lose client-list interior). A separate repairer fixed both and independent re-review closed them. That re-review found R3 (P3, unconditional marker reserve omits an action at width 102–103 even when all fit); the two-pass packing repair and regression were independently re-reviewed and closed. Final pinned Rust gates passed. [Evidence](docs/evidence/tui-footer-layout.md) separates synthetic render checks from any physical terminal verification.
|
||||||
|
|
||||||
|
### Milestone 17 — Adaptive client list, persistent details and clear states
|
||||||
|
|
||||||
|
Status: accepted (2026-10-28T23:09:37+02:00) after independent review, R1/R2 repair and re-review, and pinned Rust gates. Evidence: [M17](docs/evidence/milestone-17.md).
|
||||||
|
Prerequisites: M16 accepted under the sequential execution policy; pinned Rust toolchain and existing TUI fixture environment; actual GPT-6-Sol/medium coordinator and GPT-6-Luna/high roles. M16 was accepted by explicit user direction after a reported successful manual check; its details remain unavailable and are not M17 test evidence. The user confirmed this session's active coordinator model and effort; project TOML and the configured subagent roles specify GPT-6-Luna/high.
|
||||||
|
|
||||||
|
#### Outcome and scope
|
||||||
|
|
||||||
|
Make the client list use terminal space effectively: adaptive columns, an always-present selected-client detail pane when dimensions permit, and explicit Draft / Saved / Applied presentation. Governed by [SPEC](docs/SPEC.md) sections Data sources, Operations and states, and Client screen and statistics, all three [ADRs](docs/adr/), and [DEVELOPMENT](docs/DEVELOPMENT.md). All product text and identifiers remain English.
|
||||||
|
|
||||||
|
Scope is presentation and secret-free view/navigation support. Preserve the two-row standard footer delivered by TUI-FL-001, action authorization, keyboard shortcuts, confirmations, QR disclosure and full-screen forms. No search, sorting controls, new telemetry collection, dependency changes, persistence schema, runtime/service behavior, configuration writes, terminal-theme overhaul or inferred online status. Handshake and current counters reuse existing observations in details; new table statistic columns are deferred.
|
||||||
|
|
||||||
|
#### Context and implementation steps
|
||||||
|
|
||||||
|
Repository inspection found `src/tui/render.rs::client_table_columns` fixes Name at 13 cells on wide screens and gives remaining width to combined Allowed IPs. `render_list` repeats supported/inspection-only Mode and derives row state from the narrower `client_states` map; peers outside that map must not be presumed absent from saved files. `render_details` and `detail_lines` already expose full public data, routes, limitations and observations, but only on a separate screen. `state_summary` counts managed saved clients and document issues, not all visible peers or peer issues. `SessionView.applied` is global; periodic statistics explicitly do not mark state applied. The proposed work must preserve these distinctions rather than relabel unknown facts as verified.
|
||||||
|
|
||||||
|
Read before dispatch: AGENTS.md, SPEC.md, CONTEXT.md, all three ADRs, docs/IMPLEMENTATION.md, docs/EXECUTION_RECORDS.md, docs/PROJECT_LAYOUT.md, docs/DEVELOPMENT.md, docs/DEPENDENCIES.md, docs/ENVIRONMENT.md, docs/TESTING.md and docs/CODEX.md. Coordinator owns PLANS.md and acceptance; one implementation writer at a time. Use actual configured GPT-6-Luna/high implementer, separate reviewer and repairer roles after verifying the coordinator/model prerequisites. Do not silently substitute models.
|
||||||
|
|
||||||
|
| Task | Owner | State | Files and deliverable |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| M17.1 | implementer | complete for implementation scope | `src/tui/render.rs`: adaptive wide/medium/compact columns, growing Name, host address columns and route/inspection markers; focused checks passed |
|
||||||
|
| M17.2 | implementer | complete for implementation scope | `src/tui/render.rs`, `src/tui/state.rs`, `src/tui/terminal.rs`: dimension-only panes, shared visible-row/selection resolver, draft removal and duplicate-key navigation; focused checks passed |
|
||||||
|
| M17.3 | implementer | complete for implementation scope | Explicit Draft/Saved/Applied and scoped issue/count/detail text in TUI, saved peer count from installed bytes, README help and synthetic legacy workflow; focused checks passed |
|
||||||
|
| M17.4 | independent reviewer; repairer | complete | R1 loading-state and R2 Clippy findings repaired by separate repairer and independently closed |
|
||||||
|
| M17.5 | coordinator | complete | Integrated gates passed on fingerprinted inputs; [M17 evidence](docs/evidence/milestone-17.md), acceptance and resume update |
|
||||||
|
|
||||||
|
1. Establish one deterministic layout policy based on terminal dimensions, not selected content. Wide mode (initial target at least 140 columns and 20 rows) places the list left and details right, with a useful detail minimum of 48 interior columns and the remaining width assigned to the list. Normal narrower mode (at least 80 columns and 24 rows) places a fixed-height detail pane below the list, reserving at least five visible client rows. At smaller sizes prioritize a usable list, show a concise selected-client summary where it fits, and retain Enter for full details. Keep the existing 34 x 8 minimum guard and very-short behavior. Final breakpoint constants may be adjusted during implementation only to meet measured cell budgets; record them and their boundary tests. Pane heights must not change with label, issue or route length.
|
||||||
|
2. Allocate table widths from the list pane's actual inner width, reserving borders, selection marker and gaps. Prioritize Name and change state, keep shortened public identity visible where feasible, and use separate IPv4/IPv6 columns when they fit. Give Name a growing share instead of a fixed 13-cell cap. Remove the repetitive Mode column; retain an explicit text marker for inspection-only peers. At constrained widths progressively compact/hide address and key columns with full values available in details. Truncate by display cells with an ellipsis; escape control text. Never treat a subnet as a client's host address; list a route summary/marker and preserve all routes in details.
|
||||||
|
3. Reuse a shared selected-record resolver and detail content for the persistent pane and Enter screen. Highlight and pane must refer to the same first row even before navigation. Up/Down continue selecting clients; Enter opens scrollable full details, Esc returns with selection retained. Preserve identity across accepted refreshes, handle disappearance and empty views, and inspect ambiguous/malformed rows by document position without enabling mutation. Include draft removals in the unified visible-row model so a row and its details remain reachable. Document issue rows must never be treated as client action targets. Persistent details show full label/key, all routes, draft/saved facts, global apply verification, available handshake/counters, and specific management limitations. Long content uses a visible continuation instruction to Enter; no new focus shortcut is required.
|
||||||
|
4. Replace Debug-style global state text with explicit labels: Draft: no changes / pending; Saved: clearly defined peer and managed-client counts; Applied: verified / not verified / observation unavailable according to existing state. Clarify that global verification refers to saved configuration, not a current draft. Use stable text markers for draft added/changed/removed and inspection limitations, with colors supplementary. A saved row is not proof of applied access; handshake presence is not apply verification. Unknown must not be displayed as pending restart unless saved-versus-applied divergence is known. Metadata/vault-only drafts must still appear pending even if semantic client-change count is zero. If necessary, add a minimal public projection of existing facts; do not infer saved membership from the draft document or add a new durable model.
|
||||||
|
5. Define count labels and issue accounting explicitly: distinguish visible/draft peers, saved peers where known, managed clients, document/peer problems and capability limitations. Count each chosen category consistently; do not report a capability limitation as corruption. If existing projections cannot supply a count correctly, use an accurately scoped label instead of inventing a total. Explain inspection-only reasons in details using current capability rules.
|
||||||
|
|
||||||
|
#### Acceptance and validation
|
||||||
|
|
||||||
|
- [x] M17-A1: wide buffers use extra width for names; long Latin/Cyrillic/wide-character labels remain aligned, with full identity and values accessible in details. Address columns and route markers are accurate for dual-stack, IPv4-only and subnet-route peers.
|
||||||
|
- [x] M17-A2: persistent side/bottom details update with the highlighted row; empty/loading, first render, duplicate aliases/keys, malformed peers, document-only issues, draft removals and refresh/removal do not select or authorize the wrong client.
|
||||||
|
- [x] M17-A3: at fixed terminal dimensions, selection, long notices/routes, action availability and periodic statistics do not change pane or footer geometry. Resizing between wide/stacked/compact modes keeps selection, navigation and content reachable without overlap, wrapping table rows or out-of-bounds rendering.
|
||||||
|
- [x] M17-A4: tests distinguish no draft, added/renamed/deleted drafts, metadata/vault-only draft, saved-but-unverified state, known unapplied changes if available, verified saved state with a new draft, unavailable observations and refresh without apply verification. No saved/handshake/inspection marker falsely claims active access or saved absence.
|
||||||
|
- [x] M17-A5: full details explain capability limits separately from actual document/peer errors, display all routes and existing handshake/RX/TX observations, preserve secret-free escaping, and use consistent scoped counts including nonmanaged peers.
|
||||||
|
- [x] M17-A6: Enter/Esc and existing actions/forms/QR retain behavior; standard footer remains two hint rows plus borders. Independent review findings are repaired and rechecked, and all focused and Rust gates pass on integrated inputs.
|
||||||
|
|
||||||
|
Planned synthetic buffer matrix: 34x8, 40x9, 60x16, 80x24, 100x30, 139x40, 140x40, 180x50 and 220x50, plus final breakpoint minus/exact/plus dimensions. Use representative secret-free fixtures for state combinations and long content; assert visible semantics, selection and geometry rather than snapshots containing payloads. Add a synthetic workflow/PTY resize scenario only where buffer tests cannot verify navigation or terminal lifecycle.
|
||||||
|
|
||||||
|
Validation: pinned Rust/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97; `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked`, `cargo build --locked --release` and `git diff --check` all exited 0 on the repaired inputs. Focused `cargo test --locked tui::` passed 71 tests; `cargo test --locked --test tui_workflow` passed 8. Full test suite passed 384 with 3 explicitly ignored. Exact source/artifact identities and synthetic/PTY limits are in [M17 evidence](docs/evidence/milestone-17.md). No production, Docker or VM checks were run for this presentation scope.
|
||||||
|
|
||||||
|
#### Progress
|
||||||
|
|
||||||
|
- [x] M17.1: adaptive table implementation and focused synthetic checks complete; coordinator inspected the actual `src/tui/render.rs` diff.
|
||||||
|
- [x] M17.2: persistent pane and shared navigation implementation plus focused synthetic checks complete; coordinator inspected the actual state/terminal diff.
|
||||||
|
- [x] M17.3: explicit state/count/detail implementation and focused synthetic checks complete; coordinator inspected the actual state/README/workflow diff.
|
||||||
|
- [x] M17.4: independent review found R1; separate repairer fixed it and independent re-review closed it with 39 render tests and fmt/diff checks passing.
|
||||||
|
- [x] M17.4: integrated Clippy reported R2 at two renderer closures; targeted repair and independent re-review closed it. Exact Clippy gate now passes.
|
||||||
|
- [x] M17.5: integrated pinned Rust gates and release build passed; source/binary identities and limitations recorded in [M17 evidence](docs/evidence/milestone-17.md). Milestone accepted.
|
||||||
|
- [x] User confirmed the active GPT-6-Sol/medium coordinator; configured GPT-6-Luna/high roles handled implementation, review and repairs in sequence.
|
||||||
|
|
||||||
|
#### Review and repair
|
||||||
|
|
||||||
|
| Finding | Iteration | Location and impact | Owner | Resolution and evidence | State |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| M17-R1 (P2) | R1 | `src/tui/render.rs`: loading list conflicted with persistent empty-detail message | repairer | Early loading branch plus 140×20/80×24 buffer regression; independent re-review closed, 39 render tests passed | closed |
|
||||||
|
| M17-R2 (P2) | gate | `src/tui/render.rs:914,1141`: redundant closures failed `cargo clippy --locked --all-targets -- -D warnings` | repairer | Function items preserve semantics; exact Clippy gate passed and independent re-review closed | closed |
|
||||||
|
|
||||||
|
#### Evidence and handoff
|
||||||
|
|
||||||
|
Current evidence: [M17 evidence](docs/evidence/milestone-17.md) records fingerprinted source/release binary, 71 focused TUI tests, 8 workflow tests, 384 full-suite passes with 3 ignored, all pinned Rust gates and R1/R2 independent closure. Synthetic PTY coverage is distinct from manual terminal-emulator or real-system acceptance.
|
||||||
|
|
||||||
|
#### Decisions and discoveries
|
||||||
|
|
||||||
|
The user's requested subset is adaptive columns, persistent details and clear states. Existing detail observations can be reused without adding collection. A dedicated full-details screen remains necessary for long routes and small terminals. A global AppliedState cannot safely become an independent verified flag on every draft row. M16's user-directed acceptance is separate from M17 fixture evidence; no dependency or specification change is planned.
|
||||||
|
|
||||||
|
#### Outcome
|
||||||
|
|
||||||
|
Accepted for the presentation and synthetic fixture scope after independent review, R1/R2 closure and pinned Rust gates. No real WireGuard/systemd, Alpine, disposable-VM or manual terminal-emulator behavior is claimed. Stop after M17; do not commit, deploy, resume retired M9 VM work or access production.
|
||||||
|
|
||||||
## Current progress and resume point
|
## Current progress and resume point
|
||||||
|
|
||||||
- TUI-FL-001 is complete (2026-10-27): fixed-height two-row keyboard footer, narrow Help/overflow behavior and compact nine-row handling passed synthetic rendering tests, independent R1–R3 closure and pinned Rust gates. [Footer evidence](docs/evidence/tui-footer-layout.md) identifies source/artifact inputs. M16 remains blocked and unaffected.
|
- M17 is accepted (2026-10-28): adaptive columns, persistent selected-client details and explicit state/count presentation passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M17 evidence](docs/evidence/milestone-17.md) records final inputs and limits. Stop here; no next milestone is started.
|
||||||
- Milestone 16 is blocked after implementation, independent review/repair and passing pinned Rust gates: synthetic tests cover selection/failures, but private-peer behavior and both real systemd transports lack disposable-VM evidence. [M16 evidence](docs/evidence/milestone-16.md) records the source and artifact. No production result is claimed.
|
|
||||||
|
- TUI-FL-001 is complete (2026-10-27): fixed-height two-row keyboard footer, narrow Help/overflow behavior and compact nine-row handling passed synthetic rendering tests, independent R1–R3 closure and pinned Rust gates. [Footer evidence](docs/evidence/tui-footer-layout.md) identifies source/artifact inputs.
|
||||||
|
- Milestone 16 is accepted by explicit user direction following a reported successful manual check. Earlier independent review/repair and pinned Rust gates passed; private-peer and both real transport paths still lack recorded disposable-VM evidence. [M16 evidence](docs/evidence/milestone-16.md) records the source and artifact. No production result is claimed by this execution.
|
||||||
- Milestone 15 is accepted (2026-10-27) for synthetic service-adapter and application behavior: typed failure/phase/unit reporting, documented `LoadUnit` resolution, conservative uncertainty, independent R1/R2 closure and pinned Rust gates. [M15 evidence](docs/evidence/milestone-15.md) records source/artifact identity. Disposable-VM systemd acceptance remains incomplete and unclaimed.
|
- Milestone 15 is accepted (2026-10-27) for synthetic service-adapter and application behavior: typed failure/phase/unit reporting, documented `LoadUnit` resolution, conservative uncertainty, independent R1/R2 closure and pinned Rust gates. [M15 evidence](docs/evidence/milestone-15.md) records source/artifact identity. Disposable-VM systemd acceptance remains incomplete and unclaimed.
|
||||||
- Milestone 14 is accepted (2026-10-27) for fixture restart orchestration: complete saved-peer projection, legacy/IPv4-only/broader-route eligibility, safe blockers and durable verified history passed independent review and pinned Rust gates. [M14 evidence](docs/evidence/milestone-14.md) records source/artifact identity. No disposable-VM systemd acceptance or production result is claimed.
|
- Milestone 14 is accepted (2026-10-27) for fixture restart orchestration: complete saved-peer projection, legacy/IPv4-only/broader-route eligibility, safe blockers and durable verified history passed independent review and pinned Rust gates. [M14 evidence](docs/evidence/milestone-14.md) records source/artifact identity. No disposable-VM systemd acceptance or production result is claimed.
|
||||||
- Milestone 13 is accepted (2026-10-27): compact, explicitly colored QR disclosure, complete quiet zone and dimensioned fallback passed synthetic rendering tests, independent R5 closure and pinned Rust gates. [M13 evidence](docs/evidence/milestone-13.md) records the reviewed source and release artifact. No manual scan, Alpine rerun, VM or production validation is claimed.
|
- Milestone 13 is accepted (2026-10-27): compact, explicitly colored QR disclosure, complete quiet zone and dimensioned fallback passed synthetic rendering tests, independent R5 closure and pinned Rust gates. [M13 evidence](docs/evidence/milestone-13.md) records the reviewed source and release artifact. No manual scan, Alpine rerun, VM or production validation is claimed.
|
||||||
@@ -3747,7 +3823,7 @@ First independent review found TUI-FL-001.2-R1 (P2, omission marker overflow) an
|
|||||||
|
|
||||||
- Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope.
|
- Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope.
|
||||||
- Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked.
|
- Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked.
|
||||||
- Next action: M16 separately requires disposable-VM private-peer and system-bus acceptance. Do not access production, commit, or deploy.
|
- Next action: none for M17. Stop after acceptance. Keep the undocumented M16 manual check and absent disposable-VM evidence separate from M17 fixture evidence. Do not access production, commit, or deploy.
|
||||||
|
|
||||||
## Plan maintenance
|
## Plan maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,17 @@ distinct from lowercase shortcuts (`R` previews restart, while `r` renames;
|
|||||||
| `s` / `i` | Initial vault setup / import one selected client file |
|
| `s` / `i` | Initial vault setup / import one selected client file |
|
||||||
| `!` | Inspect document and peer anomalies |
|
| `!` | Inspect document and peer anomalies |
|
||||||
|
|
||||||
|
The list header labels **Draft**, **Saved**, and **Applied** separately. Draft
|
||||||
|
reports pending changes even when only vault or managed metadata changed. Saved
|
||||||
|
counts peer sections in the saved server file, including peers this manager
|
||||||
|
cannot edit, and separately counts managed clients. Applied reports verification
|
||||||
|
of the saved configuration; a draft is excluded. “Not verified” does not claim
|
||||||
|
that a restart is pending when that divergence is unknown. A saved peer row does
|
||||||
|
not prove active access, and handshake or traffic counters are observations,
|
||||||
|
not proof that the saved configuration was applied. Details distinguish
|
||||||
|
document or peer problems from valid configuration shapes that are currently
|
||||||
|
outside the management model.
|
||||||
|
|
||||||
Forms use Enter to advance or submit, Tab to change fields/options, and Esc to
|
Forms use Enter to advance or submit, Tab to change fields/options, and Esc to
|
||||||
cancel. Backup restore previews identify the selected archive and require an
|
cancel. Backup restore previews identify the selected archive and require an
|
||||||
explicit confirmation. Restore does not prompt for the vault password and does
|
explicit confirmation. Restore does not prompt for the vault password and does
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
Recorded 2026-10-27T22:42:48+02:00 on the native
|
Recorded 2026-10-27T22:42:48+02:00 on the native
|
||||||
`x86_64-unknown-linux-gnu` development host. This is **implementation and
|
`x86_64-unknown-linux-gnu` development host. This is **implementation and
|
||||||
synthetic evidence, not systemd acceptance**. The milestone remains blocked on
|
synthetic evidence, not systemd acceptance**. On 2026-10-28, the user reported a
|
||||||
private-peer behavioral and disposable-VM checks.
|
successful manual check and explicitly directed that M16 be considered done.
|
||||||
|
The check's environment, commands, source identity, and transport coverage were
|
||||||
|
not supplied. PLANS.md records user-directed acceptance; this report does not
|
||||||
|
claim a passing disposable-VM run or independently close M16-R2.
|
||||||
|
|
||||||
## Source and protocol basis
|
## Source and protocol basis
|
||||||
|
|
||||||
@@ -65,8 +68,8 @@ Native release artifact `target/release/wireguard-admin` SHA256:
|
|||||||
The prior disposable Debian VM was stopped before guest installation and has
|
The prior disposable Debian VM was stopped before guest installation and has
|
||||||
no verified guest marker or running systemd acceptance environment, per
|
no verified guest marker or running systemd acceptance environment, per
|
||||||
[`VM_ACCEPTANCE.md`](../VM_ACCEPTANCE.md). No private-socket or system-bus
|
[`VM_ACCEPTANCE.md`](../VM_ACCEPTANCE.md). No private-socket or system-bus
|
||||||
restart was run in a disposable VM. Neither transport is accepted against real
|
restart was run in a disposable VM as part of the recorded checks. This gap
|
||||||
systemd yet; production must not be used to fill this gap. The next check is
|
remains despite the later user-directed acceptance. A future independent check
|
||||||
to finish and authorize a disposable VM with synthetic WireGuard configuration,
|
would use a disposable VM with synthetic WireGuard configuration to cover both
|
||||||
then run both transport paths and verify job completion plus full runtime
|
transport paths, job completion, and full runtime matching, with exact
|
||||||
matching, with exact source/artifact identity.
|
source/artifact identity. Production must not be used to fill the gap.
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Milestone 17 evidence: adaptive client list and state presentation
|
||||||
|
|
||||||
|
Recorded 2026-10-28T23:09:37+02:00 on `x86_64-unknown-linux-gnu` with
|
||||||
|
`rustc`/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97. Base commit:
|
||||||
|
`d49721ff8e6a40c1016e59b2648a6a622f84a4c4`. This is a dirty working tree;
|
||||||
|
the M17 Rust/README inputs at validation have SHA256:
|
||||||
|
|
||||||
|
| Input | SHA256 |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/tui/render.rs` | `19fad8a9edfa571060c94fe34a1a484369f3601a3ffc52148081c935be2404f2` |
|
||||||
|
| `src/tui/state.rs` | `b533427001c67fb2c1d8527fb8a2cc1d6bd7e824be8985fd8b773b09a144d0f9` |
|
||||||
|
| `src/tui/terminal.rs` | `591ad7498fb4bb8cb825dfba0bad26e28bd8432ef26d5527950863a7534689f6` |
|
||||||
|
| `tests/tui_workflow.rs` | `f57d058d8538d5d1afdab7ddb7e88d23259804a9927ee4ac52982903dc1a6928` |
|
||||||
|
| `README.md` | `7c5eb0b58e81aaecbddae230211099db59560dfc5fe30991e7022d841d6b04a8` |
|
||||||
|
|
||||||
|
`PLANS.md` and `docs/evidence/milestone-16.md` are also dirty documentation
|
||||||
|
inputs. The latter records user-directed M16 acceptance and an undocumented
|
||||||
|
manual check; it is not M17 test evidence. No M17 dependency manifest or
|
||||||
|
application adapter changed. The release binary
|
||||||
|
`target/release/wireguard-admin` has SHA256
|
||||||
|
`336103d76bb78225735411a76abdbf3f6d5dc49dd6ce0b96537dfd3dea46a578`.
|
||||||
|
`file` identified an x86-64 dynamically linked GNU/Linux PIE.
|
||||||
|
|
||||||
|
## Implementation and acceptance mapping
|
||||||
|
|
||||||
|
- M17-A1: the table allocates the actual list-pane width across wide, medium
|
||||||
|
and compact columns. Synthetic buffers cover growing names, Latin/Cyrillic/
|
||||||
|
wide-character alignment, separate host IPv4/IPv6 cells, IPv4-only peers,
|
||||||
|
and route markers. Full routes and public identity remain in details.
|
||||||
|
- M17-A2/A3: shared visible records and selection drive the list, persistent
|
||||||
|
pane and Enter details. Synthetic state/navigation/buffer checks cover first
|
||||||
|
selection, duplicate and malformed peers, draft removals, document-only
|
||||||
|
issues, refresh, dimension breakpoints and stable geometry. A loading-state
|
||||||
|
regression covers side and stacked panes. Breakpoints use the content area:
|
||||||
|
side-by-side at 140×13 or larger, stacked at 80×17 or larger (eight rows for
|
||||||
|
details), compact summary at 60×12 or larger, otherwise list-only. These
|
||||||
|
dimensions are independent of selected content and action availability.
|
||||||
|
- M17-A4/A5: explicit Draft, Saved and Applied labels distinguish pending
|
||||||
|
drafts from saved peers and runtime verification. The saved peer count comes
|
||||||
|
from installed server bytes and includes nonmanaged peers; managed counts
|
||||||
|
remain separately scoped. Tests cover state combinations, issue versus
|
||||||
|
capability-limit counts, complete details and available observations.
|
||||||
|
- M17-A6: focused TUI/workflow checks include navigation, forms, footer, QR and
|
||||||
|
synthetic PTY lifecycle coverage. No Commit/restore/restart behavior changed.
|
||||||
|
|
||||||
|
## Review and checks
|
||||||
|
|
||||||
|
One GPT-6-Luna/high implementer completed M17.1, M17.2 and M17.3 in order.
|
||||||
|
A separate fresh GPT-6-Luna/high read-only reviewer inspected the integrated
|
||||||
|
diff and ran `cargo test --locked tui::` (70 passed) and
|
||||||
|
`cargo test --locked --test tui_workflow` (8 passed). It found M17-R1 (P2):
|
||||||
|
loading list and persistent detail messages conflicted. A separate
|
||||||
|
GPT-6-Luna/high repairer fixed the pane and added a 140×20/80×24 buffer test;
|
||||||
|
the reviewer rechecked and closed R1 (`cargo test --locked tui::render::tests`:
|
||||||
|
39 passed). The integrated Clippy gate then found M17-R2 (P2), two redundant
|
||||||
|
closures. The repairer replaced them with function items; the reviewer checked
|
||||||
|
semantics, reran exact Clippy, and closed R2. No actionable review finding
|
||||||
|
remains.
|
||||||
|
|
||||||
|
Final integrated checks on the input hashes above:
|
||||||
|
|
||||||
|
| Command | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| `cargo fmt --all -- --check` | exit 0 |
|
||||||
|
| `cargo test --locked tui::` | 71 passed, 0 failed |
|
||||||
|
| `cargo test --locked --test tui_workflow` | 8 passed, 0 failed; includes synthetic PTY smoke |
|
||||||
|
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 after R2 repair |
|
||||||
|
| `cargo test --locked` | 384 passed, 0 failed, 3 explicitly ignored; 2 doctests passed |
|
||||||
|
| `cargo build --locked --release` | exit 0; binary hash above |
|
||||||
|
| `git diff --check` | exit 0 |
|
||||||
|
|
||||||
|
All tests used synthetic configuration and keys. The PTY smoke checks emitted
|
||||||
|
terminal control behavior through an isolated `script` session; it is not a
|
||||||
|
manual terminal-emulator usability test. Alpine Testcontainers, disposable-VM
|
||||||
|
systemd paths, live WireGuard, production configuration, and phone QR scanning
|
||||||
|
were not run for this presentation milestone. Those checks are not claimed by
|
||||||
|
the fixture results. No host service was restarted and no Git commit was made.
|
||||||
+1159
-163
File diff suppressed because it is too large
Load Diff
+297
-32
@@ -54,6 +54,9 @@ pub struct SessionView {
|
|||||||
pub generation: u64,
|
pub generation: u64,
|
||||||
pub clients: Vec<ClientDisplay>,
|
pub clients: Vec<ClientDisplay>,
|
||||||
pub client_states: std::collections::BTreeMap<PublicKey, ClientStateRow>,
|
pub client_states: std::collections::BTreeMap<PublicKey, ClientStateRow>,
|
||||||
|
/// Number of peer sections in the installed server document, when its
|
||||||
|
/// saved bytes can be projected. This includes nonmanaged peers.
|
||||||
|
pub saved_peer_count: Option<usize>,
|
||||||
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
|
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
|
||||||
pub applied: AppliedState,
|
pub applied: AppliedState,
|
||||||
pub reserved_addresses: BTreeSet<IpAddr>,
|
pub reserved_addresses: BTreeSet<IpAddr>,
|
||||||
@@ -86,10 +89,15 @@ impl SessionView {
|
|||||||
RuntimeObservation::Verified => AppliedState::Verified,
|
RuntimeObservation::Verified => AppliedState::Verified,
|
||||||
};
|
};
|
||||||
let projection = app.display_projection();
|
let projection = app.display_projection();
|
||||||
|
let saved_peer_count =
|
||||||
|
crate::wireguard::ConfigDocument::parse(&app.saved_bytes().server_config)
|
||||||
|
.ok()
|
||||||
|
.map(|document| document.peers().len());
|
||||||
Self {
|
Self {
|
||||||
generation,
|
generation,
|
||||||
clients: projection.clients,
|
clients: projection.clients,
|
||||||
client_states,
|
client_states,
|
||||||
|
saved_peer_count,
|
||||||
document_issues: projection.document_issues,
|
document_issues: projection.document_issues,
|
||||||
applied,
|
applied,
|
||||||
reserved_addresses: states.reserved_addresses().clone(),
|
reserved_addresses: states.reserved_addresses().clone(),
|
||||||
@@ -104,6 +112,57 @@ impl SessionView {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|client| client.identity == Some(*identity))
|
.any(|client| client.identity == Some(*identity))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rows shown in the client table. Document issues are deliberately
|
||||||
|
/// excluded because they are summaries, not client action targets.
|
||||||
|
pub fn visible_records(&self) -> Vec<VisibleRecord<'_>> {
|
||||||
|
let mut records = self
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.map(VisibleRecord::Peer)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
records.extend(self.client_states.iter().filter_map(|(identity, row)| {
|
||||||
|
let client = row.saved.as_ref()?;
|
||||||
|
if row.draft.is_some()
|
||||||
|
|| self
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.any(|peer| peer.identity == Some(*identity))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(VisibleRecord::DraftRemoved {
|
||||||
|
identity: *identity,
|
||||||
|
client,
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
records
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub enum VisibleRecord<'a> {
|
||||||
|
Peer(&'a ClientDisplay),
|
||||||
|
DraftRemoved {
|
||||||
|
identity: PublicKey,
|
||||||
|
client: &'a Client,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VisibleRecord<'_> {
|
||||||
|
pub fn identity(self) -> Option<PublicKey> {
|
||||||
|
match self {
|
||||||
|
Self::Peer(client) => client.identity,
|
||||||
|
Self::DraftRemoved { identity, .. } => Some(identity),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn peer_index(self) -> Option<usize> {
|
||||||
|
match self {
|
||||||
|
Self::Peer(client) => Some(client.peer_index),
|
||||||
|
Self::DraftRemoved { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -171,28 +230,92 @@ impl UiState {
|
|||||||
if view.generation != self.generation {
|
if view.generation != self.generation {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if self
|
self.reconcile_selection(&view);
|
||||||
.selected_identity
|
|
||||||
.is_some_and(|identity| !view.contains_identity(&identity))
|
|
||||||
{
|
|
||||||
self.selected_identity = None;
|
|
||||||
}
|
|
||||||
if let Some(identity) = self.selected_identity {
|
|
||||||
self.selected_peer_index = view
|
|
||||||
.clients
|
|
||||||
.iter()
|
|
||||||
.find(|client| client.identity == Some(identity))
|
|
||||||
.map(|client| client.peer_index);
|
|
||||||
} else if self
|
|
||||||
.selected_peer_index
|
|
||||||
.is_some_and(|index| !view.clients.iter().any(|client| client.peer_index == index))
|
|
||||||
{
|
|
||||||
self.selected_peer_index = None;
|
|
||||||
}
|
|
||||||
self.view = Some(view);
|
self.view = Some(view);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reconcile_selection(&mut self, view: &SessionView) {
|
||||||
|
if let Some(identity) = self.selected_identity {
|
||||||
|
let matching = view
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.filter(|client| client.identity == Some(identity))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if matching.len() == 1 {
|
||||||
|
self.selected_peer_index = Some(matching[0].peer_index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if matching.len() > 1 {
|
||||||
|
if self
|
||||||
|
.selected_peer_index
|
||||||
|
.is_some_and(|index| matching.iter().any(|client| client.peer_index == index))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The old row disappeared and the key is now ambiguous. Do
|
||||||
|
// not guess which peer the administrator meant.
|
||||||
|
self.selected_identity = None;
|
||||||
|
self.selected_peer_index = None;
|
||||||
|
self.select_first_record(view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let removed = view.client_states.get(&identity).is_some_and(|row| {
|
||||||
|
row.saved.is_some()
|
||||||
|
&& row.draft.is_none()
|
||||||
|
&& !view
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.any(|client| client.identity == Some(identity))
|
||||||
|
});
|
||||||
|
if removed {
|
||||||
|
self.selected_peer_index = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.selected_identity = None;
|
||||||
|
self.selected_peer_index = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(peer_index) = self.selected_peer_index {
|
||||||
|
if let Some(client) = view
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.find(|client| client.peer_index == peer_index)
|
||||||
|
{
|
||||||
|
self.selected_identity = client.identity;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.selected_peer_index = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.selected_identity.is_some_and(|identity| {
|
||||||
|
view.client_states
|
||||||
|
.get(&identity)
|
||||||
|
.is_some_and(|row| row.saved.is_some() && row.draft.is_none())
|
||||||
|
}) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.select_first_record(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_first_record(&mut self, view: &SessionView) {
|
||||||
|
match view.visible_records().first().copied() {
|
||||||
|
Some(VisibleRecord::Peer(client)) => {
|
||||||
|
self.selected_peer_index = Some(client.peer_index);
|
||||||
|
self.selected_identity = client.identity;
|
||||||
|
}
|
||||||
|
Some(VisibleRecord::DraftRemoved { identity, .. }) => {
|
||||||
|
self.selected_peer_index = None;
|
||||||
|
self.selected_identity = Some(identity);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.selected_peer_index = None;
|
||||||
|
self.selected_identity = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply only statistics sampled from the still-current saved/draft view.
|
/// Apply only statistics sampled from the still-current saved/draft view.
|
||||||
/// This never changes the applied-state marker.
|
/// This never changes the applied-state marker.
|
||||||
pub fn apply_statistics(&mut self, generation: u64, statistics: Statistics) -> bool {
|
pub fn apply_statistics(&mut self, generation: u64, statistics: Statistics) -> bool {
|
||||||
@@ -250,20 +373,19 @@ impl UiState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn select(&mut self, identity: PublicKey) -> bool {
|
pub fn select(&mut self, identity: PublicKey) -> bool {
|
||||||
if !self
|
let Some(view) = &self.view else { return false };
|
||||||
.view
|
let mut matching = view
|
||||||
.as_ref()
|
.clients
|
||||||
.is_some_and(|view| view.contains_identity(&identity))
|
.iter()
|
||||||
{
|
.filter(|row| row.identity == Some(identity));
|
||||||
|
let Some(client) = matching.next() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if matching.next().is_some() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.selected_identity = Some(identity);
|
self.selected_identity = Some(identity);
|
||||||
self.selected_peer_index = self.view.as_ref().and_then(|view| {
|
self.selected_peer_index = Some(client.peer_index);
|
||||||
view.clients
|
|
||||||
.iter()
|
|
||||||
.find(|client| client.identity == Some(identity))
|
|
||||||
.map(|client| client.peer_index)
|
|
||||||
});
|
|
||||||
self.screen = Screen::ClientDetails;
|
self.screen = Screen::ClientDetails;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -290,14 +412,81 @@ impl UiState {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn selected_record(&self) -> Option<VisibleRecord<'_>> {
|
||||||
|
let view = self.view.as_ref()?;
|
||||||
|
let records = view.visible_records();
|
||||||
|
self.selected_visible_index(&records)
|
||||||
|
.and_then(|index| records.get(index).copied())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_visible_index(&self, records: &[VisibleRecord<'_>]) -> Option<usize> {
|
||||||
|
self.selected_peer_index
|
||||||
|
.and_then(|peer_index| {
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.position(|record| record.peer_index() == Some(peer_index))
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
self.selected_identity.and_then(|identity| {
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.position(|record| record.identity() == Some(identity))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.or_else(|| (!records.is_empty()).then_some(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_visible_index(&mut self, selected: usize) -> bool {
|
||||||
|
let Some(record) = self
|
||||||
|
.view
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|view| view.visible_records().get(selected).copied())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match record {
|
||||||
|
VisibleRecord::Peer(client) => {
|
||||||
|
self.selected_peer_index = Some(client.peer_index);
|
||||||
|
self.selected_identity = client.identity;
|
||||||
|
}
|
||||||
|
VisibleRecord::DraftRemoved { identity, .. } => {
|
||||||
|
self.selected_peer_index = None;
|
||||||
|
self.selected_identity = Some(identity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_selected_record(&mut self) -> bool {
|
||||||
|
let Some(view) = &self.view else { return false };
|
||||||
|
let records = view.visible_records();
|
||||||
|
let Some(index) = self.selected_visible_index(&records) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.select_visible_index(index);
|
||||||
|
self.screen = Screen::ClientDetails;
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub fn selected_client_mutable(&self) -> bool {
|
pub fn selected_client_mutable(&self) -> bool {
|
||||||
let Some(identity) = self.selected_identity else {
|
let Some(identity) = self.selected_identity else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
self.view.as_ref().is_some_and(|view| {
|
self.view.as_ref().is_some_and(|view| {
|
||||||
view.clients.iter().any(|client| {
|
let mut matching = view
|
||||||
client.identity == Some(identity) && client.mutable_identity == Some(identity)
|
.clients
|
||||||
})
|
.iter()
|
||||||
|
.filter(|client| client.identity == Some(identity));
|
||||||
|
let Some(client) = matching.next() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
matching.next().is_none()
|
||||||
|
&& self
|
||||||
|
.selected_peer_index
|
||||||
|
.is_none_or(|peer_index| peer_index == client.peer_index)
|
||||||
|
&& client.mutable_identity == Some(identity)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,6 +519,7 @@ mod tests {
|
|||||||
generation,
|
generation,
|
||||||
clients,
|
clients,
|
||||||
client_states: Default::default(),
|
client_states: Default::default(),
|
||||||
|
saved_peer_count: None,
|
||||||
document_issues: Vec::new(),
|
document_issues: Vec::new(),
|
||||||
applied: AppliedState::Unknown,
|
applied: AppliedState::Unknown,
|
||||||
reserved_addresses: BTreeSet::new(),
|
reserved_addresses: BTreeSet::new(),
|
||||||
@@ -382,6 +572,81 @@ mod tests {
|
|||||||
assert!(!state.selected_client_mutable());
|
assert!(!state.selected_client_mutable());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_uses_peer_position_for_duplicate_keys_and_disables_mutation() {
|
||||||
|
let key = PublicKey::from_bytes([12; 32]);
|
||||||
|
let mut first = row(key, "first duplicate", true);
|
||||||
|
first.peer_index = 3;
|
||||||
|
let mut second = row(key, "second duplicate", true);
|
||||||
|
second.peer_index = 8;
|
||||||
|
let mut state = UiState::default();
|
||||||
|
assert!(state.apply_view(view(0, vec![first, second])));
|
||||||
|
assert_eq!(state.selected_peer_index, Some(3));
|
||||||
|
assert!(!state.selected_client_mutable());
|
||||||
|
assert!(state.highlight_peer_index(8));
|
||||||
|
assert_eq!(state.selected_record().unwrap().peer_index(), Some(8));
|
||||||
|
assert_eq!(
|
||||||
|
match state.selected_record().unwrap() {
|
||||||
|
VisibleRecord::Peer(client) => client.label.as_str(),
|
||||||
|
VisibleRecord::DraftRemoved { .. } => panic!("duplicate resolved to removal"),
|
||||||
|
},
|
||||||
|
"second duplicate"
|
||||||
|
);
|
||||||
|
assert!(!state.selected_client_mutable());
|
||||||
|
|
||||||
|
let mut refreshed_first = row(key, "refreshed first", true);
|
||||||
|
refreshed_first.peer_index = 3;
|
||||||
|
let mut refreshed_second = row(key, "refreshed second", true);
|
||||||
|
refreshed_second.peer_index = 8;
|
||||||
|
assert!(state.apply_view(view(0, vec![refreshed_second, refreshed_first])));
|
||||||
|
assert_eq!(state.selected_peer_index, Some(8));
|
||||||
|
assert_eq!(
|
||||||
|
match state.selected_record().unwrap() {
|
||||||
|
VisibleRecord::Peer(client) => client.label.as_str(),
|
||||||
|
VisibleRecord::DraftRemoved { .. } => panic!("duplicate resolved to removal"),
|
||||||
|
},
|
||||||
|
"refreshed second"
|
||||||
|
);
|
||||||
|
assert!(!state.selected_client_mutable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removed_saved_client_is_visible_and_never_mutable() {
|
||||||
|
let key = PublicKey::from_bytes([13; 32]);
|
||||||
|
let removed = Client::new(
|
||||||
|
key,
|
||||||
|
"removed alias",
|
||||||
|
crate::domain::ClientAddresses {
|
||||||
|
ipv4: "10.0.0.9".parse().unwrap(),
|
||||||
|
ipv6: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut view = view(0, Vec::new());
|
||||||
|
view.client_states.insert(
|
||||||
|
key,
|
||||||
|
ClientStateRow {
|
||||||
|
saved: Some(removed),
|
||||||
|
draft: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
view.document_issues
|
||||||
|
.push(crate::wireguard::DocumentIssue::MalformedSectionHeader { line: 5 });
|
||||||
|
let mut state = UiState::default();
|
||||||
|
assert!(state.apply_view(view));
|
||||||
|
assert_eq!(state.selected_identity, Some(key));
|
||||||
|
assert_eq!(
|
||||||
|
state.selected_visible_index(&state.view.as_ref().unwrap().visible_records()),
|
||||||
|
Some(0)
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
state.selected_record(),
|
||||||
|
Some(VisibleRecord::DraftRemoved { identity, .. }) if identity == key
|
||||||
|
));
|
||||||
|
assert!(!state.selected_client_mutable());
|
||||||
|
assert_eq!(state.view.as_ref().unwrap().visible_records().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() {
|
fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() {
|
||||||
let mut state = UiState {
|
let mut state = UiState {
|
||||||
|
|||||||
+112
-29
@@ -869,15 +869,7 @@ fn handle_navigation_key(code: KeyCode, state: &mut UiState) -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
KeyCode::Enter if state.screen == Screen::ClientList => {
|
KeyCode::Enter if state.screen == Screen::ClientList => {
|
||||||
let peer_index = state.selected_peer_index.or_else(|| {
|
let _ = state.open_selected_record();
|
||||||
state
|
|
||||||
.view
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|view| view.clients.first().map(|client| client.peer_index))
|
|
||||||
});
|
|
||||||
if let Some(peer_index) = peer_index {
|
|
||||||
let _ = state.select_peer_index(peer_index);
|
|
||||||
}
|
|
||||||
state.scroll_offset = 0;
|
state.scroll_offset = 0;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -891,31 +883,17 @@ fn handle_navigation_key(code: KeyCode, state: &mut UiState) -> bool {
|
|||||||
|
|
||||||
fn move_selection(state: &mut UiState, down: bool) {
|
fn move_selection(state: &mut UiState, down: bool) {
|
||||||
let Some(view) = &state.view else { return };
|
let Some(view) = &state.view else { return };
|
||||||
if view.clients.is_empty() {
|
let records = view.visible_records();
|
||||||
|
if records.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let current = state
|
let current = state.selected_visible_index(&records).unwrap_or(0);
|
||||||
.selected_peer_index
|
|
||||||
.and_then(|selected| {
|
|
||||||
view.clients
|
|
||||||
.iter()
|
|
||||||
.position(|row| row.peer_index == selected)
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
state.selected_identity.and_then(|identity| {
|
|
||||||
view.clients
|
|
||||||
.iter()
|
|
||||||
.position(|row| row.identity == Some(identity))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
|
||||||
let next = if down {
|
let next = if down {
|
||||||
(current + 1) % view.clients.len()
|
(current + 1) % records.len()
|
||||||
} else {
|
} else {
|
||||||
current.checked_sub(1).unwrap_or(view.clients.len() - 1)
|
current.checked_sub(1).unwrap_or(records.len() - 1)
|
||||||
};
|
};
|
||||||
let peer_index = view.clients[next].peer_index;
|
let _ = state.select_visible_index(next);
|
||||||
let _ = state.highlight_peer_index(peer_index);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -996,6 +974,7 @@ mod tests {
|
|||||||
unsupported,
|
unsupported,
|
||||||
],
|
],
|
||||||
client_states: Default::default(),
|
client_states: Default::default(),
|
||||||
|
saved_peer_count: Some(0),
|
||||||
document_issues: Vec::new(),
|
document_issues: Vec::new(),
|
||||||
applied: AppliedState::Unknown,
|
applied: AppliedState::Unknown,
|
||||||
reserved_addresses: Default::default(),
|
reserved_addresses: Default::default(),
|
||||||
@@ -1064,6 +1043,109 @@ mod tests {
|
|||||||
assert!(!state.selected_client_mutable());
|
assert!(!state.selected_client_mutable());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keyboard_navigation_reaches_draft_removed_rows_without_authorizing_them() {
|
||||||
|
let removed_key = crate::domain::PublicKey::from_bytes([19; 32]);
|
||||||
|
let mut view = navigation_view();
|
||||||
|
view.client_states.insert(
|
||||||
|
removed_key,
|
||||||
|
crate::tui::state::ClientStateRow {
|
||||||
|
saved: Some(
|
||||||
|
crate::domain::Client::new(
|
||||||
|
removed_key,
|
||||||
|
"removed",
|
||||||
|
crate::domain::ClientAddresses {
|
||||||
|
ipv4: "10.0.0.9".parse().unwrap(),
|
||||||
|
ipv6: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
draft: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut state = UiState {
|
||||||
|
view: Some(view),
|
||||||
|
..UiState::default()
|
||||||
|
};
|
||||||
|
assert!(handle_navigation_key(KeyCode::Down, &mut state));
|
||||||
|
assert_eq!(state.selected_peer_index, Some(9));
|
||||||
|
assert!(handle_navigation_key(KeyCode::Down, &mut state));
|
||||||
|
assert_eq!(state.selected_peer_index, None);
|
||||||
|
assert_eq!(state.selected_identity, Some(removed_key));
|
||||||
|
assert!(!state.selected_client_mutable());
|
||||||
|
assert!(handle_navigation_key(KeyCode::Enter, &mut state));
|
||||||
|
assert_eq!(state.screen, Screen::ClientDetails);
|
||||||
|
assert!(matches!(
|
||||||
|
state.selected_record(),
|
||||||
|
Some(crate::tui::state::VisibleRecord::DraftRemoved { identity, .. })
|
||||||
|
if identity == removed_key
|
||||||
|
));
|
||||||
|
assert!(handle_navigation_key(KeyCode::Esc, &mut state));
|
||||||
|
assert_eq!(state.screen, Screen::ClientList);
|
||||||
|
assert_eq!(state.selected_identity, Some(removed_key));
|
||||||
|
|
||||||
|
let mut empty = UiState {
|
||||||
|
view: Some(SessionView {
|
||||||
|
generation: 0,
|
||||||
|
clients: Vec::new(),
|
||||||
|
client_states: Default::default(),
|
||||||
|
saved_peer_count: Some(0),
|
||||||
|
document_issues: vec![crate::wireguard::DocumentIssue::MalformedSectionHeader {
|
||||||
|
line: 1,
|
||||||
|
}],
|
||||||
|
applied: AppliedState::Unknown,
|
||||||
|
reserved_addresses: Default::default(),
|
||||||
|
statistics: RuntimeObservation::unknown(),
|
||||||
|
has_draft: false,
|
||||||
|
vault_initialized: true,
|
||||||
|
}),
|
||||||
|
..UiState::default()
|
||||||
|
};
|
||||||
|
assert!(handle_navigation_key(KeyCode::Enter, &mut empty));
|
||||||
|
assert_eq!(empty.screen, Screen::ClientList);
|
||||||
|
assert!(empty.selected_record().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_public_keys_remain_inspectable_by_peer_position_but_not_mutable() {
|
||||||
|
let key = crate::domain::PublicKey::from_bytes([20; 32]);
|
||||||
|
let mut first = navigation_view().clients[0].clone();
|
||||||
|
first.peer_index = 3;
|
||||||
|
first.identity = Some(key);
|
||||||
|
first.mutable_identity = Some(key);
|
||||||
|
first.label = "first duplicate".to_owned();
|
||||||
|
let mut second = first.clone();
|
||||||
|
second.peer_index = 7;
|
||||||
|
second.label = "second duplicate".to_owned();
|
||||||
|
let mut state = UiState {
|
||||||
|
view: Some(SessionView {
|
||||||
|
generation: 0,
|
||||||
|
clients: vec![first, second],
|
||||||
|
client_states: Default::default(),
|
||||||
|
saved_peer_count: Some(2),
|
||||||
|
document_issues: Vec::new(),
|
||||||
|
applied: AppliedState::Unknown,
|
||||||
|
reserved_addresses: Default::default(),
|
||||||
|
statistics: RuntimeObservation::unknown(),
|
||||||
|
has_draft: false,
|
||||||
|
vault_initialized: true,
|
||||||
|
}),
|
||||||
|
..UiState::default()
|
||||||
|
};
|
||||||
|
assert!(handle_navigation_key(KeyCode::Down, &mut state));
|
||||||
|
assert_eq!(state.selected_peer_index, Some(7));
|
||||||
|
assert_eq!(state.selected_identity, Some(key));
|
||||||
|
assert!(!state.selected_client_mutable());
|
||||||
|
assert!(handle_navigation_key(KeyCode::Enter, &mut state));
|
||||||
|
assert!(matches!(
|
||||||
|
state.selected_record(),
|
||||||
|
Some(crate::tui::state::VisibleRecord::Peer(client))
|
||||||
|
if client.peer_index == 7 && client.label == "second duplicate"
|
||||||
|
));
|
||||||
|
assert!(!state.selected_client_mutable());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn issues_screen_hides_valid_address_shape_limits_but_keeps_genuine_errors() {
|
fn issues_screen_hides_valid_address_shape_limits_but_keeps_genuine_errors() {
|
||||||
let mut view = navigation_view();
|
let mut view = navigation_view();
|
||||||
@@ -1275,6 +1357,7 @@ mod tests {
|
|||||||
generation: 1,
|
generation: 1,
|
||||||
clients: Vec::new(),
|
clients: Vec::new(),
|
||||||
client_states: Default::default(),
|
client_states: Default::default(),
|
||||||
|
saved_peer_count: Some(0),
|
||||||
document_issues: Vec::new(),
|
document_issues: Vec::new(),
|
||||||
applied: AppliedState::Unknown,
|
applied: AppliedState::Unknown,
|
||||||
reserved_addresses: Default::default(),
|
reserved_addresses: Default::default(),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use wireguard_admin::tui::events::{
|
|||||||
ShutdownProgress, StatisticsReader, UiCommand, WorkerConfig, WorkerEvent,
|
ShutdownProgress, StatisticsReader, UiCommand, WorkerConfig, WorkerEvent,
|
||||||
};
|
};
|
||||||
use wireguard_admin::tui::forms::{FormAction, FormController};
|
use wireguard_admin::tui::forms::{FormAction, FormController};
|
||||||
|
use wireguard_admin::tui::state::SessionView;
|
||||||
use wireguard_admin::vault::WireGuardPrivateKey;
|
use wireguard_admin::vault::WireGuardPrivateKey;
|
||||||
|
|
||||||
const SETTINGS: &str = r#"
|
const SETTINGS: &str = r#"
|
||||||
@@ -75,6 +76,32 @@ fn fixture() -> Fixture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saved_peer_projection_counts_legacy_peers_without_inventing_managed_state() {
|
||||||
|
let fixture = fixture();
|
||||||
|
let legacy_key = WireGuardPrivateKey::generate()
|
||||||
|
.expect("synthetic legacy peer key")
|
||||||
|
.public_key();
|
||||||
|
let mut server_config =
|
||||||
|
fs::read_to_string(&fixture.settings.server_config_path).expect("synthetic server config");
|
||||||
|
server_config.push_str(&format!(
|
||||||
|
"\n[Peer]\nPublicKey = {legacy_key}\nAllowedIPs = 10.20.0.2/32\n"
|
||||||
|
));
|
||||||
|
fs::write(&fixture.settings.server_config_path, server_config)
|
||||||
|
.expect("write synthetic legacy peer");
|
||||||
|
|
||||||
|
let app = wireguard_admin::app::Application::open_from_saved_store(fixture.store.clone())
|
||||||
|
.expect("application opens with an inspect-only legacy peer");
|
||||||
|
let view = SessionView::from_application(&app, 0);
|
||||||
|
|
||||||
|
assert_eq!(view.saved_peer_count, Some(1));
|
||||||
|
assert_eq!(view.clients.len(), 1);
|
||||||
|
assert_eq!(view.clients[0].identity, Some(legacy_key));
|
||||||
|
assert_eq!(view.clients[0].mutable_identity, None);
|
||||||
|
assert!(view.client_states.is_empty());
|
||||||
|
assert!(!view.has_draft);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
struct CountingStatistics(Arc<AtomicUsize>);
|
struct CountingStatistics(Arc<AtomicUsize>);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user