From 77ac8c822ec5df8b6e53992712a2aa365a80a1a6 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Sun, 1 Mar 2026 16:33:33 +0000 Subject: [PATCH] Add fipstop TUI monitoring tool with smoothed metrics and quality indices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fipstop: ratatui-based TUI for real-time monitoring of a running FIPS daemon. Tabs and navigation: - 8 navigable tabs: Node, Peers, Transports, Sessions, Tree, Filters, Performance, Routing - Tab/BackTab navigation with group separators in tab bar - Table views with selectable rows, detail drill-down panels, and scrollbars Node tab: - Runtime info: pid, exe path, uptime, control socket path, TUN adapter name - Identity: npub, node_addr, ipv6 address - State summary with peer/session/link/transport/connection counts - TUN IPv6 traffic and forwarded transit traffic counters Peers tab: - Table with Name, Address, Conn, Depth, SRTT, Loss, LQI, Pkts Tx/Rx - Detail panel: identity, connection info, transport cross-reference, tree/bloom state, link stats, MMP metrics with LQI Sessions tab: - Table with Name, Remote Addr, State, Role, SRTT, Loss, SQI, Path MTU, Last Activity - Detail panel: identity, session info, traffic stats, MMP metrics with SQI Transports tab: - Hierarchical tree view: expandable transport parents with nested links (▼/▶ indicators, ├─/└─ tree chars, Space/Arrow to expand/collapse) - Transport detail: type-specific stats (UDP/TCP/Ethernet) - Link detail: peer cross-reference with MMP metrics and LQI Performance tab: - Link-layer MMP: SRTT, loss, ETX, LQI, goodput per peer - Session-layer MMP: SRTT, loss, ETX, SQI, path MTU per session - Trend indicators (rising/falling/stable) with context-aware coloring Routing tab: - Routing state: cache sizes, pending lookups, recent requests - Coordinate cache: entries, fill ratio, TTL, expiry, avg age - Statistics: forwarding, discovery request/response, error signal counters Tree tab: - Spanning tree position with 16 announce stats (inbound/outbound/cumulative) Filters tab: - Bloom filter announce stats, per-peer fill ratio and estimated node count MMP metrics enhancements: - Add etx_trend DualEwma for smoothed ETX tracking - Add smoothed_loss() and smoothed_etx() accessors (long-term EWMA) - LQI (Link Quality Index) = smoothed_etx * (1 + srtt_ms / 100) - SQI (Session Quality Index) = same formula for session layer - All loss/ETX displays prefer smoothed values with raw fallback Control socket: - Add smoothed_loss, smoothed_etx, lqi/sqi to show_peers, show_sessions, and show_mmp JSON responses - Rename fips_address to ipv6_addr in show_status and show_peers - Add tun_name and control_socket to show_status - FHS-compliant 3-tier default path: $XDG_RUNTIME_DIR, /run/fips, /tmp Node extensions: - Add started_at/uptime() to Node - Add tun_name() accessor Docker sidecar updates: - TCP transport support via FIPS_PEER_TRANSPORT env var - Build scripts include fipstop binary --- .github/workflows/ci.yml | 4 +- .gitignore | 3 +- Cargo.lock | 994 ++++++++++++++++++++++++++++- Cargo.toml | 10 + docs/design/fips-configuration.md | 4 +- src/bin/fipsctl.rs | 6 +- src/bin/fipstop/app.rs | 216 +++++++ src/bin/fipstop/client.rs | 65 ++ src/bin/fipstop/event.rs | 51 ++ src/bin/fipstop/main.rs | 226 +++++++ src/bin/fipstop/ui/bloom.rs | 150 +++++ src/bin/fipstop/ui/dashboard.rs | 209 ++++++ src/bin/fipstop/ui/helpers.rs | 171 +++++ src/bin/fipstop/ui/mmp.rs | 237 +++++++ src/bin/fipstop/ui/mod.rs | 99 +++ src/bin/fipstop/ui/peers.rs | 273 ++++++++ src/bin/fipstop/ui/routing.rs | 174 +++++ src/bin/fipstop/ui/sessions.rs | 274 ++++++++ src/bin/fipstop/ui/transports.rs | 605 ++++++++++++++++++ src/bin/fipstop/ui/tree.rs | 213 +++++++ src/config/node.rs | 4 +- src/control/queries.rs | 78 ++- src/mmp/metrics.rs | 22 + src/node/handlers/session.rs | 6 +- src/node/mod.rs | 15 + src/node/session.rs | 33 + testing/chaos/sim/control.py | 6 +- testing/sidecar/.gitignore | 1 + testing/sidecar/Dockerfile | 6 +- testing/sidecar/Dockerfile.app | 2 +- testing/sidecar/docker-compose.yml | 1 + testing/sidecar/entrypoint.sh | 6 +- testing/sidecar/scripts/build.sh | 4 +- 33 files changed, 4122 insertions(+), 46 deletions(-) create mode 100644 src/bin/fipstop/app.rs create mode 100644 src/bin/fipstop/client.rs create mode 100644 src/bin/fipstop/event.rs create mode 100644 src/bin/fipstop/main.rs create mode 100644 src/bin/fipstop/ui/bloom.rs create mode 100644 src/bin/fipstop/ui/dashboard.rs create mode 100644 src/bin/fipstop/ui/helpers.rs create mode 100644 src/bin/fipstop/ui/mmp.rs create mode 100644 src/bin/fipstop/ui/mod.rs create mode 100644 src/bin/fipstop/ui/peers.rs create mode 100644 src/bin/fipstop/ui/routing.rs create mode 100644 src/bin/fipstop/ui/sessions.rs create mode 100644 src/bin/fipstop/ui/transports.rs create mode 100644 src/bin/fipstop/ui/tree.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 324515f..81cf0ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,7 @@ jobs: path: | target/release/fips target/release/fipsctl + target/release/fipstop retention-days: 1 # ───────────────────────────────────────────────────────────────────────────── @@ -264,9 +265,10 @@ jobs: - name: Install binary (sidecar) if: matrix.type == 'sidecar' run: | - chmod +x _bin/fips _bin/fipsctl + chmod +x _bin/fips _bin/fipsctl _bin/fipstop cp _bin/fips testing/sidecar/fips cp _bin/fipsctl testing/sidecar/fipsctl + cp _bin/fipstop testing/sidecar/fipstop - name: Run sidecar integration test if: matrix.type == 'sidecar' diff --git a/.gitignore b/.gitignore index 03c7977..1db6196 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ # Claude Code .claude/ -deploy/ \ No newline at end of file +deploy/ +vps.env \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ac69ca2..b41d380 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,6 +30,12 @@ dependencies = [ "cc", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anes" version = "0.1.6" @@ -116,6 +122,15 @@ version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -128,12 +143,33 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bech32" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitcoin-io" version = "0.1.4" @@ -150,6 +186,12 @@ dependencies = [ "hex-conservative", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" @@ -184,6 +226,12 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "bytes" version = "1.11.0" @@ -207,7 +255,7 @@ checksum = "3b457277798202ccd365b9c112ebee08ddd57f1033916c8b8ea52f222e5b715d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -216,6 +264,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.54" @@ -342,7 +399,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -357,6 +414,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -366,6 +437,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -396,7 +476,7 @@ dependencies = [ "ciborium", "clap", "criterion-plot", - "itertools", + "itertools 0.13.0", "num-traits", "oorandom", "page_size", @@ -416,7 +496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", - "itertools", + "itertools 0.13.0", ] [[package]] @@ -444,6 +524,33 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -461,6 +568,87 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", +] + [[package]] name = "digest" version = "0.10.7" @@ -493,6 +681,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "either" version = "1.15.0" @@ -515,6 +712,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -536,18 +742,45 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + [[package]] name = "fips" version = "0.1.0" @@ -562,6 +795,7 @@ dependencies = [ "hkdf", "libc", "rand 0.10.0", + "ratatui", "rtnetlink", "secp256k1", "serde", @@ -578,12 +812,30 @@ dependencies = [ "tun", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "futures" version = "0.3.31" @@ -650,7 +902,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -747,7 +999,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -755,6 +1007,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -801,6 +1058,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.13.0" @@ -813,6 +1076,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -822,6 +1094,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -843,6 +1128,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -859,6 +1153,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.0" @@ -893,22 +1204,65 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libc", ] +[[package]] +name = "line-clipping" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "matchers" version = "0.2.0" @@ -924,6 +1278,27 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.1.1" @@ -931,6 +1306,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -950,7 +1326,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libc", "log", "netlink-packet-core", @@ -983,18 +1359,41 @@ dependencies = [ "tokio", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cfg-if", "cfg_aliases", "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1004,6 +1403,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1013,6 +1429,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1043,6 +1468,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "page_size" version = "0.6.0" @@ -1059,12 +1493,130 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1127,6 +1679,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1143,7 +1707,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.114", ] [[package]] @@ -1217,6 +1781,91 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.10.0", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools 0.14.0", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rayon" version = "1.11.0" @@ -1237,6 +1886,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -1290,18 +1948,27 @@ dependencies = [ "netlink-packet-route", "netlink-proto", "netlink-sys", - "nix", + "nix 0.30.1", "thiserror 1.0.69", "tokio", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", @@ -1329,6 +1996,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "secp256k1" version = "0.30.0" @@ -1382,7 +2055,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -1437,6 +2110,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1453,9 +2147,15 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df350943049174c4ae8ced56c604e28270258faec12a6a48637a7655287c9ce0" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.11" @@ -1478,18 +2178,56 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.114" @@ -1514,6 +2252,69 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.10.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1540,7 +2341,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -1551,7 +2352,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -1563,6 +2364,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + [[package]] name = "tinytemplate" version = "1.2.1" @@ -1597,7 +2419,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -1632,7 +2454,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -1687,7 +2509,7 @@ dependencies = [ "ipnet", "libc", "log", - "nix", + "nix 0.30.1", "thiserror 2.0.18", "tokio", "tokio-util", @@ -1701,12 +2523,41 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1735,6 +2586,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "atomic", + "getrandom 0.4.1", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1747,6 +2610,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1813,7 +2685,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.114", "wasm-bindgen-shared", ] @@ -1854,7 +2726,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.10.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -1870,6 +2742,78 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2119,7 +3063,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.114", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2135,7 +3079,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.114", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2147,7 +3091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.10.0", "indexmap", "log", "serde", @@ -2194,7 +3138,7 @@ checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 442b600..c1333ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,12 @@ authors = ["Johnathan Corgan "] repository = "https://github.com/jmcorgan/fips" readme = "README.md" +[features] +default = ["tui"] +tui = ["dep:ratatui"] + [dependencies] +ratatui = { version = "0.30", optional = true } secp256k1 = { version = "0.30", features = ["rand", "global-context"] } sha2 = "0.10" hkdf = "0.12" @@ -39,3 +44,8 @@ criterion = { version = "0.8.2", features = ["html_reports"] } [[bin]] name = "fipsctl" path = "src/bin/fipsctl.rs" + +[[bin]] +name = "fipstop" +path = "src/bin/fipstop/main.rs" +required-features = ["tui"] diff --git a/docs/design/fips-configuration.md b/docs/design/fips-configuration.md index 1ae1c7d..d31eea1 100644 --- a/docs/design/fips-configuration.md +++ b/docs/design/fips-configuration.md @@ -54,7 +54,7 @@ peers: # Static peer list | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `node.control.enabled` | bool | `true` | Enable the Unix domain control socket | -| `node.control.socket_path` | string | *(auto)* | Socket file path. Default: `$XDG_RUNTIME_DIR/fips/control.sock` if `XDG_RUNTIME_DIR` is set, otherwise `/tmp/fips-control.sock` | +| `node.control.socket_path` | string | *(auto)* | Socket file path. Default: `$XDG_RUNTIME_DIR/fips/control.sock`, then `/run/fips/control.sock` (if root), then `/tmp/fips-control.sock` | The control socket provides read-only access to node state via the `fipsctl` command-line tool. See the project @@ -480,7 +480,7 @@ node: owd_window_size: 32 control: enabled: true - socket_path: null # null = auto ($XDG_RUNTIME_DIR/fips/control.sock or /tmp/fips-control.sock) + socket_path: null # null = auto ($XDG_RUNTIME_DIR → /run/fips → /tmp fallback) buffers: packet_channel: 1024 tun_channel: 1024 diff --git a/src/bin/fipsctl.rs b/src/bin/fipsctl.rs index afadf63..674b902 100644 --- a/src/bin/fipsctl.rs +++ b/src/bin/fipsctl.rs @@ -6,7 +6,7 @@ use clap::{Parser, Subcommand}; use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; /// FIPS control client @@ -77,7 +77,9 @@ impl ShowCommands { /// Determine the default socket path. fn default_socket_path() -> PathBuf { if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { - PathBuf::from(format!("{}/fips/control.sock", runtime_dir)) + PathBuf::from(format!("{runtime_dir}/fips/control.sock")) + } else if Path::new("/run/fips/control.sock").exists() { + PathBuf::from("/run/fips/control.sock") } else { PathBuf::from("/tmp/fips-control.sock") } diff --git a/src/bin/fipstop/app.rs b/src/bin/fipstop/app.rs new file mode 100644 index 0000000..308e5c8 --- /dev/null +++ b/src/bin/fipstop/app.rs @@ -0,0 +1,216 @@ +use ratatui::widgets::TableState; +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub enum Tab { + Node, + Peers, + Links, + Sessions, + Tree, + Bloom, + Mmp, + Cache, + Transports, + Routing, +} + +impl Tab { + pub const ALL: [Tab; 8] = [ + Tab::Node, + Tab::Peers, + Tab::Transports, + Tab::Sessions, + Tab::Tree, + Tab::Bloom, + Tab::Mmp, + Tab::Routing, + ]; + + /// Tab group index: 0 = Node, 1 = Connectivity, 2 = Internals. + pub fn group(&self) -> usize { + match self { + Tab::Node => 0, + Tab::Peers | Tab::Transports => 1, + _ => 2, + } + } + + pub fn label(&self) -> &'static str { + match self { + Tab::Node => "Node", + Tab::Peers => "Peers", + Tab::Links => "Links", + Tab::Sessions => "Sessions", + Tab::Tree => "Tree", + Tab::Bloom => "Filters", + Tab::Mmp => "Performance", + Tab::Cache => "Cache", + Tab::Transports => "Transports", + Tab::Routing => "Routing", + } + } + + pub fn command(&self) -> &'static str { + match self { + Tab::Node => "show_status", + Tab::Peers => "show_peers", + Tab::Links => "show_links", + Tab::Sessions => "show_sessions", + Tab::Tree => "show_tree", + Tab::Bloom => "show_bloom", + Tab::Mmp => "show_mmp", + Tab::Cache => "show_cache", + Tab::Transports => "show_transports", + Tab::Routing => "show_routing", + } + } + + pub fn index(&self) -> usize { + Tab::ALL.iter().position(|t| t == self).unwrap() + } + + pub fn next(&self) -> Tab { + let i = self.index(); + Tab::ALL[(i + 1) % Tab::ALL.len()] + } + + pub fn prev(&self) -> Tab { + let i = self.index(); + Tab::ALL[(i + Tab::ALL.len() - 1) % Tab::ALL.len()] + } + + /// The JSON key containing the data array for this tab's response. + pub fn command_data_key(&self) -> &'static str { + match self { + Tab::Peers => "peers", + Tab::Links => "links", + Tab::Sessions => "sessions", + Tab::Transports => "transports", + _ => "", + } + } + + /// Whether this tab has a table view with row selection. + pub fn has_table(&self) -> bool { + matches!( + self, + Tab::Peers | Tab::Sessions | Tab::Transports + ) + } +} + +#[derive(Clone)] +pub enum ConnectionState { + Connected, + Disconnected(String), +} + +pub struct DetailView { + pub scroll: u16, +} + +#[derive(Clone, Copy)] +pub enum SelectedTreeItem { + None, + Transport(u64), + Link, +} + +pub struct App { + pub active_tab: Tab, + pub should_quit: bool, + pub connection_state: ConnectionState, + pub refresh_interval: Duration, + pub data: HashMap, + pub table_states: HashMap, + pub detail_view: Option, + pub last_fetch: Instant, + pub last_error: Option<(Instant, String)>, + pub expanded_transports: HashSet, + pub tree_row_count: usize, + pub selected_tree_item: SelectedTreeItem, +} + +impl App { + pub fn new(refresh_interval: Duration) -> Self { + Self { + active_tab: Tab::Node, + should_quit: false, + connection_state: ConnectionState::Disconnected("Not yet connected".into()), + refresh_interval, + data: HashMap::new(), + table_states: HashMap::new(), + detail_view: None, + last_fetch: Instant::now(), + last_error: None, + expanded_transports: HashSet::new(), + tree_row_count: 0, + selected_tree_item: SelectedTreeItem::None, + } + } + + /// Number of rows in the active tab's data array. + pub fn row_count(&self) -> usize { + if self.active_tab == Tab::Transports { + return self.tree_row_count; + } + let key = self.active_tab.command_data_key(); + self.data + .get(&self.active_tab) + .and_then(|v| v.get(key)) + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0) + } + + /// Move table selection down by one row. + pub fn select_next(&mut self) { + let count = self.row_count(); + if count == 0 { + return; + } + let state = self.table_states.entry(self.active_tab).or_default(); + let i = state.selected().map(|s| (s + 1).min(count - 1)).unwrap_or(0); + state.select(Some(i)); + } + + /// Move table selection up by one row. + pub fn select_prev(&mut self) { + let count = self.row_count(); + if count == 0 { + return; + } + let state = self.table_states.entry(self.active_tab).or_default(); + let i = state.selected().map(|s| s.saturating_sub(1)).unwrap_or(0); + state.select(Some(i)); + } + + /// Open detail view for the currently selected row. + pub fn open_detail(&mut self) { + let state = self.table_states.get(&self.active_tab); + if state.and_then(|s| s.selected()).is_some() { + self.detail_view = Some(DetailView { scroll: 0 }); + } + } + + /// Close detail view. + pub fn close_detail(&mut self) { + self.detail_view = None; + } + + /// Scroll detail view down. + pub fn scroll_detail_down(&mut self) { + if let Some(ref mut dv) = self.detail_view { + dv.scroll = dv.scroll.saturating_add(1); + } + } + + /// Scroll detail view up. + pub fn scroll_detail_up(&mut self) { + if let Some(ref mut dv) = self.detail_view { + dv.scroll = dv.scroll.saturating_sub(1); + } + } +} diff --git a/src/bin/fipstop/client.rs b/src/bin/fipstop/client.rs new file mode 100644 index 0000000..7732e8f --- /dev/null +++ b/src/bin/fipstop/client.rs @@ -0,0 +1,65 @@ +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tokio::time::timeout; + +const IO_TIMEOUT: Duration = Duration::from_secs(5); + +pub struct ControlClient { + socket_path: PathBuf, +} + +impl ControlClient { + pub fn new(socket_path: &Path) -> Self { + Self { + socket_path: socket_path.to_path_buf(), + } + } + + pub async fn query(&self, command: &str) -> Result { + let stream = timeout(IO_TIMEOUT, UnixStream::connect(&self.socket_path)) + .await + .map_err(|_| "connection timed out".to_string())? + .map_err(|e| format!("connect: {e}"))?; + + let (reader, mut writer) = stream.into_split(); + + let request = format!("{{\"command\":\"{command}\"}}\n"); + timeout(IO_TIMEOUT, writer.write_all(request.as_bytes())) + .await + .map_err(|_| "write timed out".to_string())? + .map_err(|e| format!("write: {e}"))?; + + writer + .shutdown() + .await + .map_err(|e| format!("shutdown: {e}"))?; + + let mut buf_reader = BufReader::new(reader); + let mut line = String::new(); + timeout(IO_TIMEOUT, buf_reader.read_line(&mut line)) + .await + .map_err(|_| "read timed out".to_string())? + .map_err(|e| format!("read: {e}"))?; + + let response: Value = + serde_json::from_str(line.trim()).map_err(|e| format!("parse: {e}"))?; + + let status = response + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + if status == "error" { + let msg = response + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + return Err(msg.to_string()); + } + + Ok(response.get("data").cloned().unwrap_or(Value::Null)) + } +} diff --git a/src/bin/fipstop/event.rs b/src/bin/fipstop/event.rs new file mode 100644 index 0000000..b2991bf --- /dev/null +++ b/src/bin/fipstop/event.rs @@ -0,0 +1,51 @@ +use ratatui::crossterm::event::{self, Event as CrosstermEvent, KeyEvent}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +pub enum Event { + Key(KeyEvent), + Resize, + Tick, +} + +pub struct EventHandler { + rx: mpsc::Receiver, +} + +impl EventHandler { + pub fn new(tick_rate: Duration) -> Self { + let (tx, rx) = mpsc::channel(); + + thread::spawn(move || loop { + if event::poll(tick_rate).unwrap_or(false) { + if let Ok(evt) = event::read() { + match evt { + CrosstermEvent::Key(key) => { + if tx.send(Event::Key(key)).is_err() { + return; + } + } + CrosstermEvent::Resize(..) => { + if tx.send(Event::Resize).is_err() { + return; + } + } + _ => {} + } + } + } else { + // Poll timed out — send a tick + if tx.send(Event::Tick).is_err() { + return; + } + } + }); + + Self { rx } + } + + pub fn next(&self) -> Result { + self.rx.recv() + } +} diff --git a/src/bin/fipstop/main.rs b/src/bin/fipstop/main.rs new file mode 100644 index 0000000..d107471 --- /dev/null +++ b/src/bin/fipstop/main.rs @@ -0,0 +1,226 @@ +mod app; +mod client; +mod event; +mod ui; + +use app::{App, ConnectionState, SelectedTreeItem, Tab}; +use clap::Parser; +use client::ControlClient; +use event::{Event, EventHandler}; +use ratatui::crossterm::event::{KeyCode, KeyModifiers}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// FIPS mesh monitoring TUI +#[derive(Parser, Debug)] +#[command(name = "fipstop", version, about = "Monitor a running FIPS daemon")] +struct Cli { + /// Control socket path override + #[arg(short = 's', long)] + socket: Option, + + /// Refresh interval in seconds + #[arg(short = 'r', long, default_value = "2")] + refresh: u64, +} + +fn default_socket_path() -> PathBuf { + if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + PathBuf::from(format!("{runtime_dir}/fips/control.sock")) + } else if Path::new("/run/fips/control.sock").exists() { + PathBuf::from("/run/fips/control.sock") + } else { + PathBuf::from("/tmp/fips-control.sock") + } +} + +fn restore_terminal() { + ratatui::restore(); +} + +fn fetch_data(rt: &tokio::runtime::Runtime, client: &ControlClient, app: &mut App) { + // Always fetch status for the status bar + match rt.block_on(client.query("show_status")) { + Ok(data) => { + app.data.insert(Tab::Node, data); + app.connection_state = ConnectionState::Connected; + } + Err(e) => { + app.connection_state = ConnectionState::Disconnected(e.clone()); + app.last_error = Some((std::time::Instant::now(), e)); + return; + } + } + + // Fetch active tab data (if not Dashboard, which we already fetched) + if app.active_tab != Tab::Node { + match rt.block_on(client.query(app.active_tab.command())) { + Ok(data) => { + app.data.insert(app.active_tab, data); + } + Err(e) => { + app.last_error = Some((std::time::Instant::now(), e)); + } + } + } + + // Cross-reference fetches for detail views + if app.active_tab == Tab::Peers { + if let Ok(data) = rt.block_on(client.query("show_links")) { + app.data.insert(Tab::Links, data); + } + if let Ok(data) = rt.block_on(client.query("show_transports")) { + app.data.insert(Tab::Transports, data); + } + } + if app.active_tab == Tab::Transports { + if let Ok(data) = rt.block_on(client.query("show_links")) { + app.data.insert(Tab::Links, data); + } + if let Ok(data) = rt.block_on(client.query("show_peers")) { + app.data.insert(Tab::Peers, data); + } + } + if app.active_tab == Tab::Routing + && let Ok(data) = rt.block_on(client.query("show_cache")) + { + app.data.insert(Tab::Cache, data); + } + + app.last_fetch = std::time::Instant::now(); +} + +fn main() { + let cli = Cli::parse(); + + let socket_path = cli.socket.unwrap_or_else(default_socket_path); + let refresh = Duration::from_secs(cli.refresh); + + // Install panic hook that restores terminal before printing panic + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + restore_terminal(); + original_hook(info); + })); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to create tokio runtime"); + + let client = ControlClient::new(&socket_path); + let mut terminal = ratatui::init(); + let mut app = App::new(refresh); + let events = EventHandler::new(refresh); + + // Initial fetch + fetch_data(&rt, &client, &mut app); + + // Main loop + loop { + terminal + .draw(|frame| ui::draw(frame, &mut app)) + .expect("failed to draw frame"); + + match events.next() { + Ok(Event::Key(key)) => { + // Ignore key release events + if key.kind != ratatui::crossterm::event::KeyEventKind::Press { + continue; + } + match (key.code, key.modifiers) { + (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + app.should_quit = true; + } + (KeyCode::Tab, KeyModifiers::NONE) => { + app.close_detail(); + app.active_tab = app.active_tab.next(); + fetch_data(&rt, &client, &mut app); + } + (KeyCode::BackTab, _) => { + app.close_detail(); + app.active_tab = app.active_tab.prev(); + fetch_data(&rt, &client, &mut app); + } + (KeyCode::Down, _) => { + if app.detail_view.is_some() { + app.scroll_detail_down(); + } else if app.active_tab.has_table() { + app.select_next(); + } + } + (KeyCode::Up, _) => { + if app.detail_view.is_some() { + app.scroll_detail_up(); + } else if app.active_tab.has_table() { + app.select_prev(); + } + } + (KeyCode::Enter, _) => { + if app.active_tab.has_table() && app.detail_view.is_none() { + app.open_detail(); + } + } + (KeyCode::Char(' '), _) | (KeyCode::Right, _) => { + if app.active_tab == Tab::Transports + && app.detail_view.is_none() + && let SelectedTreeItem::Transport(tid) = app.selected_tree_item + { + if app.expanded_transports.contains(&tid) { + app.expanded_transports.remove(&tid); + } else { + app.expanded_transports.insert(tid); + } + } + } + (KeyCode::Left, _) => { + if app.active_tab == Tab::Transports + && app.detail_view.is_none() + && let SelectedTreeItem::Transport(tid) = app.selected_tree_item + { + app.expanded_transports.remove(&tid); + } + } + (KeyCode::Esc, _) => { + if app.detail_view.is_some() { + app.close_detail(); + } + } + (KeyCode::Char('e'), KeyModifiers::NONE) => { + if app.active_tab == Tab::Transports + && let Some(data) = app.data.get(&Tab::Transports) + && let Some(arr) = data.get("transports").and_then(|v| v.as_array()) + { + for t in arr { + if let Some(tid) = t.get("transport_id").and_then(|v| v.as_u64()) { + app.expanded_transports.insert(tid); + } + } + } + } + (KeyCode::Char('c'), KeyModifiers::NONE) => { + if app.active_tab == Tab::Transports { + app.expanded_transports.clear(); + } + } + _ => {} + } + } + Ok(Event::Resize) => { + // Redraw happens at top of loop + } + Ok(Event::Tick) => { + fetch_data(&rt, &client, &mut app); + } + Err(_) => { + app.should_quit = true; + } + } + + if app.should_quit { + break; + } + } + + restore_terminal(); +} diff --git a/src/bin/fipstop/ui/bloom.rs b/src/bin/fipstop/ui/bloom.rs new file mode 100644 index 0000000..eb1f699 --- /dev/null +++ b/src/bin/fipstop/ui/bloom.rs @@ -0,0 +1,150 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::{App, Tab}; + +use super::helpers; + +pub fn draw(frame: &mut Frame, app: &App, area: Rect) { + let data = match app.data.get(&Tab::Bloom) { + Some(d) => d, + None => { + let msg = Paragraph::new(" Waiting for data...") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, area); + return; + } + }; + + let chunks = Layout::vertical([ + Constraint::Length(7), // Bloom Filter State + Constraint::Length(15), // Bloom Announce Stats + Constraint::Min(3), // Peer Filters + ]) + .split(area); + + draw_state(frame, data, chunks[0]); + draw_stats(frame, data, chunks[1]); + draw_peer_filters(frame, data, chunks[2]); +} + +fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) { + let lines = vec![ + helpers::kv_line( + "Node Addr", + &helpers::truncate_hex(helpers::str_field(data, "own_node_addr"), 16), + ), + helpers::kv_line("Leaf Only", helpers::bool_field(data, "is_leaf_only")), + helpers::kv_line("Sequence", &helpers::u64_field(data, "sequence")), + helpers::kv_line( + "Leaf Deps", + &helpers::u64_field(data, "leaf_dependent_count"), + ), + ]; + + let block = Block::default() + .borders(Borders::ALL) + .title(" Bloom Filter State "); + let inner = block.inner(area); + frame.render_widget(block, area); + frame.render_widget(Paragraph::new(lines), inner); +} + +fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Bloom Announce Stats "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let mut lines = vec![ + helpers::section_header("Inbound"), + helpers::kv_line("Received", &helpers::nested_u64(data, "stats", "received")), + helpers::kv_line("Accepted", &helpers::nested_u64(data, "stats", "accepted")), + helpers::kv_line("Decode Error", &helpers::nested_u64(data, "stats", "decode_error")), + helpers::kv_line("Invalid", &helpers::nested_u64(data, "stats", "invalid")), + helpers::kv_line("Non-V1", &helpers::nested_u64(data, "stats", "non_v1")), + helpers::kv_line("Unknown Peer", &helpers::nested_u64(data, "stats", "unknown_peer")), + helpers::kv_line("Stale", &helpers::nested_u64(data, "stats", "stale")), + Line::from(""), + helpers::section_header("Outbound"), + helpers::kv_line("Sent", &helpers::nested_u64(data, "stats", "sent")), + helpers::kv_line("Debounce Suppressed", &helpers::nested_u64(data, "stats", "debounce_suppressed")), + helpers::kv_line("Send Failed", &helpers::nested_u64(data, "stats", "send_failed")), + ]; + + let max_lines = inner.height as usize; + lines.truncate(max_lines); + + frame.render_widget(Paragraph::new(lines), inner); +} + +fn draw_peer_filters(frame: &mut Frame, data: &serde_json::Value, area: Rect) { + let filters = data + .get("peer_filters") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let count = filters.len(); + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" Peer Filters ({count}) ")); + let inner = block.inner(area); + frame.render_widget(block, area); + + if filters.is_empty() { + let msg = + Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, inner); + return; + } + + let lines: Vec = filters + .iter() + .map(|f| { + let name = helpers::str_field(f, "display_name"); + let seq = helpers::u64_field(f, "filter_sequence"); + let has = f + .get("has_filter") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let mut spans = vec![ + Span::styled( + format!(" {name:<16}"), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled("seq: ", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{seq:<6}")), + ]; + + if has { + let fill = f + .get("fill_ratio") + .and_then(|v| v.as_f64()) + .map(|r| format!("{:.1}%", r * 100.0)) + .unwrap_or_else(|| "-".into()); + let est = f + .get("estimated_count") + .and_then(|v| v.as_f64()) + .map(|n| format!("{:.0}", n)) + .unwrap_or_else(|| "-".into()); + spans.push(Span::styled("fill: ", Style::default().fg(Color::DarkGray))); + spans.push(Span::raw(format!("{fill:<8}"))); + spans.push(Span::styled("est: ", Style::default().fg(Color::DarkGray))); + spans.push(Span::raw(format!("{est:<6}"))); + spans.push(Span::styled("ok", Style::default().fg(Color::Green))); + } else { + spans.push(Span::styled("none", Style::default().fg(Color::Red))); + } + + Line::from(spans) + }) + .collect(); + + frame.render_widget(Paragraph::new(lines), inner); +} diff --git a/src/bin/fipstop/ui/dashboard.rs b/src/bin/fipstop/ui/dashboard.rs new file mode 100644 index 0000000..acf2875 --- /dev/null +++ b/src/bin/fipstop/ui/dashboard.rs @@ -0,0 +1,209 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::App; + +use super::helpers; + +pub fn draw(frame: &mut Frame, app: &App, area: Rect) { + let data = match app.data.get(&crate::app::Tab::Node) { + Some(d) => d, + None => { + let msg = Paragraph::new(" Waiting for data...") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, area); + return; + } + }; + + let chunks = Layout::vertical([ + Constraint::Length(7), // Runtime + Constraint::Length(7), // Identity + Constraint::Length(5), // State + Constraint::Length(9), // Traffic + Constraint::Min(0), // remaining + ]) + .split(area); + + draw_runtime(frame, data, chunks[0]); + draw_identity(frame, data, chunks[1]); + draw_state(frame, data, chunks[2]); + draw_node_stats(frame, data, chunks[3]); +} + +fn draw_runtime(frame: &mut Frame, data: &serde_json::Value, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Runtime "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let pid = helpers::u64_field(data, "pid"); + let exe = helpers::str_field(data, "exe_path"); + let uptime_secs = data.get("uptime_secs").and_then(|v| v.as_u64()).unwrap_or(0); + let uptime = format_uptime(uptime_secs); + let socket = helpers::str_field(data, "control_socket"); + let tun_name = helpers::str_field(data, "tun_name"); + + let label = Style::default().fg(Color::DarkGray); + + let lines = vec![ + Line::from(vec![ + Span::styled(" pid: ", label), + Span::raw(pid), + Span::styled(" uptime: ", label), + Span::raw(uptime), + ]), + Line::from(vec![ + Span::styled(" exe: ", label), + Span::raw(exe.to_string()), + ]), + Line::from(vec![ + Span::styled(" ctl: ", label), + Span::raw(socket.to_string()), + ]), + Line::from(vec![ + Span::styled(" tun: ", label), + Span::raw(tun_name.to_string()), + ]), + ]; + + frame.render_widget(Paragraph::new(lines), inner); +} + +fn draw_identity(frame: &mut Frame, data: &serde_json::Value, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Identity "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let npub = helpers::str_field(data, "npub"); + let node_addr = helpers::str_field(data, "node_addr"); + let ipv6_addr = helpers::str_field(data, "ipv6_addr"); + + let label = Style::default().fg(Color::DarkGray); + + let lines = vec![ + Line::from(vec![ + Span::styled(" npub: ", label), + Span::raw(npub.to_string()), + ]), + Line::from(vec![ + Span::styled(" node_addr: ", label), + Span::raw(node_addr.to_string()), + ]), + Line::from(vec![ + Span::styled(" ipv6: ", label), + Span::raw(ipv6_addr.to_string()), + ]), + ]; + + frame.render_widget(Paragraph::new(lines), inner); +} + +fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" State "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let state = helpers::str_field(data, "state"); + let tun = helpers::str_field(data, "tun_state"); + let mtu = helpers::u64_field(data, "effective_ipv6_mtu"); + let leaf = helpers::bool_field(data, "is_leaf_only"); + + let label = Style::default().fg(Color::DarkGray); + let count = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); + + let peers = helpers::u64_field(data, "peer_count"); + let sessions = helpers::u64_field(data, "session_count"); + let links = helpers::u64_field(data, "link_count"); + let transports = helpers::u64_field(data, "transport_count"); + let connections = helpers::u64_field(data, "connection_count"); + + let lines = vec![ + Line::from(vec![ + Span::styled(" state: ", label), + Span::raw(state.to_string()), + Span::styled(" tun: ", label), + Span::raw(tun.to_string()), + Span::styled(" mtu: ", label), + Span::raw(mtu), + Span::styled(" leaf: ", label), + Span::raw(leaf.to_string()), + ]), + Line::from(vec![ + Span::styled(" peers: ", label), + Span::styled(peers, count), + Span::styled(" sessions: ", label), + Span::styled(sessions, count), + Span::styled(" links: ", label), + Span::styled(links, count), + Span::styled(" transports: ", label), + Span::styled(transports, count), + Span::styled(" connections: ", label), + Span::styled(connections, count), + ]), + ]; + + frame.render_widget(Paragraph::new(lines), inner); +} + +fn draw_node_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Traffic "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let lines = vec![ + helpers::section_header("TUN (IPv6)"), + fwd_line(data, "Tx", "delivered_packets", "delivered_bytes"), + fwd_line(data, "Rx", "originated_packets", "originated_bytes"), + Line::from(""), + helpers::section_header("Forwarded (transit)"), + fwd_line(data, "Packets", "forwarded_packets", "forwarded_bytes"), + ]; + + frame.render_widget(Paragraph::new(lines), inner); +} + +/// Format a forwarding counter as "N pkts (formatted_bytes)". +fn fwd_line(data: &serde_json::Value, label: &str, pkt_key: &str, byte_key: &str) -> Line<'static> { + let pkts = data + .get("forwarding") + .and_then(|f| f.get(pkt_key)) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let bytes = data + .get("forwarding") + .and_then(|f| f.get(byte_key)) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + helpers::kv_line(label, &format!("{} pkts ({})", pkts, helpers::format_bytes(bytes))) +} + +/// Format seconds as human-readable uptime (e.g., "3d 2h 15m 4s"). +fn format_uptime(secs: u64) -> String { + let days = secs / 86400; + let hours = (secs % 86400) / 3600; + let mins = (secs % 3600) / 60; + let s = secs % 60; + + if days > 0 { + format!("{days}d {hours}h {mins}m {s}s") + } else if hours > 0 { + format!("{hours}h {mins}m {s}s") + } else if mins > 0 { + format!("{mins}m {s}s") + } else { + format!("{s}s") + } +} diff --git a/src/bin/fipstop/ui/helpers.rs b/src/bin/fipstop/ui/helpers.rs new file mode 100644 index 0000000..7ae543b --- /dev/null +++ b/src/bin/fipstop/ui/helpers.rs @@ -0,0 +1,171 @@ +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use serde_json::Value; + +/// Extract a string field from JSON, returning "-" if missing. +pub fn str_field<'a>(data: &'a Value, key: &str) -> &'a str { + data.get(key).and_then(|v| v.as_str()).unwrap_or("-") +} + +/// Extract a u64 field from JSON, returning "-" if missing. +pub fn u64_field(data: &Value, key: &str) -> String { + data.get(key) + .and_then(|v| v.as_u64()) + .map(|n| n.to_string()) + .unwrap_or_else(|| "-".into()) +} + +/// Truncate a hex string to the given length, adding "..." if truncated. +pub fn truncate_hex(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}...", &s[..max_len.saturating_sub(3)]) + } +} + +/// Format bytes-per-second with engineering units (B/s, KB/s, MB/s, GB/s) and 3 significant digits. +pub fn format_throughput(bytes_per_sec: f64) -> String { + if bytes_per_sec < 0.0 { + return "0 B/s".into(); + } + let (scaled, unit) = if bytes_per_sec < 1_000.0 { + (bytes_per_sec, "B/s") + } else if bytes_per_sec < 1_000_000.0 { + (bytes_per_sec / 1_000.0, "KB/s") + } else if bytes_per_sec < 1_000_000_000.0 { + (bytes_per_sec / 1_000_000.0, "MB/s") + } else { + (bytes_per_sec / 1_000_000_000.0, "GB/s") + }; + let decimals = if scaled >= 100.0 { + 0 + } else if scaled >= 10.0 { + 1 + } else { + 2 + }; + format!("{:.prec$} {unit}", scaled, prec = decimals) +} + +/// Extract a nested f64 field and format as engineering-unit throughput. +pub fn nested_throughput(data: &Value, outer: &str, inner: &str) -> String { + data.get(outer) + .and_then(|o| o.get(inner)) + .and_then(|v| v.as_f64()) + .map(format_throughput) + .unwrap_or_else(|| "-".into()) +} + +/// Format a byte count as human-readable (B, KB, MB, GB). +pub fn format_bytes(bytes: u64) -> String { + if bytes < 1024 { + format!("{bytes}B") + } else if bytes < 1024 * 1024 { + format!("{:.1}KB", bytes as f64 / 1024.0) + } else if bytes < 1024 * 1024 * 1024 { + format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0)) + } else { + format!("{:.2}GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) + } +} + +/// Format millisecond timestamp as relative duration from now (e.g., "3.2s ago"). +pub fn format_elapsed_ms(ms: u64) -> String { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + if ms == 0 || ms > now_ms { + return "-".into(); + } + let elapsed = now_ms - ms; + if elapsed < 1000 { + format!("{elapsed}ms") + } else if elapsed < 60_000 { + format!("{:.1}s", elapsed as f64 / 1000.0) + } else if elapsed < 3_600_000 { + format!("{:.1}m", elapsed as f64 / 60_000.0) + } else { + format!("{:.1}h", elapsed as f64 / 3_600_000.0) + } +} + +/// Get a nested string field. +pub fn nested_str(data: &Value, outer: &str, inner: &str) -> String { + data.get(outer) + .and_then(|o| o.get(inner)) + .and_then(|v| v.as_str()) + .unwrap_or("-") + .to_string() +} + +/// Get a nested field value (e.g., "stats.packets_sent"). +pub fn nested_u64(data: &Value, outer: &str, inner: &str) -> String { + data.get(outer) + .and_then(|o| o.get(inner)) + .and_then(|v| v.as_u64()) + .map(|n| n.to_string()) + .unwrap_or_else(|| "-".into()) +} + +/// Get a nested f64 field formatted to given decimal places. +pub fn nested_f64(data: &Value, outer: &str, inner: &str, decimals: usize) -> String { + data.get(outer) + .and_then(|o| o.get(inner)) + .and_then(|v| v.as_f64()) + .map(|n| format!("{:.prec$}", n, prec = decimals)) + .unwrap_or_else(|| "-".into()) +} + +/// Get a nested f64 field, preferring `preferred` key with fallback to `fallback` key. +pub fn nested_f64_prefer(data: &Value, outer: &str, preferred: &str, fallback: &str, decimals: usize) -> String { + data.get(outer) + .and_then(|o| o.get(preferred).or_else(|| o.get(fallback))) + .and_then(|v| v.as_f64()) + .map(|n| format!("{:.prec$}", n, prec = decimals)) + .unwrap_or_else(|| "-".into()) +} + +/// Extract a bool field from JSON, returning "yes"/"no" or "-" if missing. +pub fn bool_field(data: &Value, key: &str) -> &'static str { + data.get(key) + .and_then(|v| v.as_bool()) + .map(|b| if b { "yes" } else { "no" }) + .unwrap_or("-") +} + +/// Format a duration in milliseconds as compact string (e.g., "42ms", "3.2s", "5.0m"). +pub fn format_duration_ms(ms: u64) -> String { + if ms < 1000 { + format!("{ms}ms") + } else if ms < 60_000 { + format!("{:.1}s", ms as f64 / 1000.0) + } else if ms < 3_600_000 { + format!("{:.1}m", ms as f64 / 60_000.0) + } else { + format!("{:.1}h", ms as f64 / 3_600_000.0) + } +} + +/// Section header line for detail views. +pub fn section_header(title: &str) -> Line<'static> { + Line::from(Span::styled( + format!(" {title}"), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )) +} + +/// Key-value line for detail views. +pub fn kv_line(key: &str, value: &str) -> Line<'static> { + Line::from(vec![ + Span::styled( + format!(" {key}: "), + Style::default().fg(Color::DarkGray), + ), + Span::raw(value.to_string()), + ]) +} + diff --git a/src/bin/fipstop/ui/mmp.rs b/src/bin/fipstop/ui/mmp.rs new file mode 100644 index 0000000..152658a --- /dev/null +++ b/src/bin/fipstop/ui/mmp.rs @@ -0,0 +1,237 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::{App, Tab}; + +use super::helpers; + +pub fn draw(frame: &mut Frame, app: &App, area: Rect) { + let data = match app.data.get(&Tab::Mmp) { + Some(d) => d, + None => { + let msg = Paragraph::new(" Waiting for data...") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, area); + return; + } + }; + + let chunks = Layout::vertical([ + Constraint::Percentage(60), + Constraint::Percentage(40), + ]) + .split(area); + + draw_link_mmp(frame, data, chunks[0]); + draw_session_mmp(frame, data, chunks[1]); +} + +fn draw_link_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) { + let peers = data + .get("peers") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let count = peers.len(); + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" Link MMP ({count} peers) ")); + let inner = block.inner(area); + frame.render_widget(block, area); + + if peers.is_empty() { + let msg = + Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, inner); + return; + } + + let mut lines: Vec = Vec::new(); + for peer in &peers { + let name = helpers::str_field(peer, "display_name"); + let ll = peer.get("link_layer"); + + let srtt = ll + .and_then(|l| l.get("srtt_ms")) + .and_then(|v| v.as_f64()) + .map(|v| format!("{:.1}ms", v)) + .unwrap_or_else(|| "-".into()); + let loss = ll + .and_then(|l| l.get("smoothed_loss").or_else(|| l.get("loss_rate"))) + .and_then(|v| v.as_f64()) + .map(|v| format!("{:.4}", v)) + .unwrap_or_else(|| "-".into()); + let etx = ll + .and_then(|l| l.get("smoothed_etx").or_else(|| l.get("etx"))) + .and_then(|v| v.as_f64()) + .map(|v| format!("{:.2}", v)) + .unwrap_or_else(|| "-".into()); + let lqi = ll + .and_then(|l| l.get("lqi")) + .and_then(|v| v.as_f64()) + .map(|v| format!("{:.2}", v)) + .unwrap_or_else(|| "-".into()); + let goodput = ll + .and_then(|l| l.get("goodput_bps")) + .and_then(|v| v.as_f64()) + .map(helpers::format_throughput) + .unwrap_or_else(|| "-".into()); + + // Line 1: primary metrics + lines.push(Line::from(vec![ + Span::styled( + format!(" {name:<16}"), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled("srtt: ", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{srtt:<10}")), + Span::styled("loss: ", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{loss:<8}")), + Span::styled("etx: ", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{etx:<6}")), + Span::styled("lqi: ", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{lqi:<8}")), + Span::styled("gp: ", Style::default().fg(Color::DarkGray)), + Span::raw(goodput), + ])); + + // Line 2: trends + if let Some(ll_val) = ll { + let mut trend_spans: Vec = vec![Span::raw(" ")]; + let mut has_trends = false; + + for (label, key, bad_rising) in [ + ("rtt", "rtt_trend", true), + ("loss", "loss_trend", true), + ("goodput", "goodput_trend", false), + ("jitter", "jitter_trend", true), + ] { + if let Some(trend) = ll_val.get(key).and_then(|v| v.as_str()) { + if has_trends { + trend_spans.push(Span::raw(" ")); + } + trend_spans.push(Span::styled( + format!("{label}: "), + Style::default().fg(Color::DarkGray), + )); + trend_spans.push(Span::styled( + trend.to_string(), + Style::default().fg(trend_color(trend, bad_rising)), + )); + has_trends = true; + } + } + + if has_trends { + lines.push(Line::from(trend_spans)); + } + } + } + + frame.render_widget(Paragraph::new(lines), inner); +} + +fn draw_session_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) { + let sessions = data + .get("sessions") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let count = sessions.len(); + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" Session MMP ({count} sessions) ")); + let inner = block.inner(area); + frame.render_widget(block, area); + + if sessions.is_empty() { + let msg = Paragraph::new(" No sessions") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, inner); + return; + } + + let lines: Vec = sessions + .iter() + .map(|s| { + let name = helpers::str_field(s, "display_name"); + let sl = s.get("session_layer"); + + let srtt = sl + .and_then(|l| l.get("srtt_ms")) + .and_then(|v| v.as_f64()) + .map(|v| format!("{:.1}ms", v)) + .unwrap_or_else(|| "-".into()); + let loss = sl + .and_then(|l| l.get("smoothed_loss").or_else(|| l.get("loss_rate"))) + .and_then(|v| v.as_f64()) + .map(|v| format!("{:.4}", v)) + .unwrap_or_else(|| "-".into()); + let etx = sl + .and_then(|l| l.get("smoothed_etx").or_else(|| l.get("etx"))) + .and_then(|v| v.as_f64()) + .map(|v| format!("{:.2}", v)) + .unwrap_or_else(|| "-".into()); + let sqi = sl + .and_then(|l| l.get("sqi")) + .and_then(|v| v.as_f64()) + .map(|v| format!("{:.2}", v)) + .unwrap_or_else(|| "-".into()); + let mtu = sl + .and_then(|l| l.get("path_mtu")) + .and_then(|v| v.as_u64()) + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".into()); + + Line::from(vec![ + Span::styled( + format!(" {name:<16}"), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled("srtt: ", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{srtt:<10}")), + Span::styled("loss: ", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{loss:<8}")), + Span::styled("etx: ", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{etx:<6}")), + Span::styled("sqi: ", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{sqi:<8}")), + Span::styled("mtu: ", Style::default().fg(Color::DarkGray)), + Span::raw(mtu), + ]) + }) + .collect(); + + frame.render_widget(Paragraph::new(lines), inner); +} + +/// Color a trend value based on whether "rising" is bad or good for this metric. +fn trend_color(trend: &str, bad_rising: bool) -> Color { + match trend { + "rising" => { + if bad_rising { + Color::Red + } else { + Color::Green + } + } + "falling" => { + if bad_rising { + Color::Green + } else { + Color::Red + } + } + _ => Color::DarkGray, // "stable" + } +} + diff --git a/src/bin/fipstop/ui/mod.rs b/src/bin/fipstop/ui/mod.rs new file mode 100644 index 0000000..35d6c8a --- /dev/null +++ b/src/bin/fipstop/ui/mod.rs @@ -0,0 +1,99 @@ +mod bloom; +mod dashboard; +mod helpers; +mod mmp; +mod peers; +mod routing; +mod sessions; +mod transports; +mod tree; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::{App, ConnectionState, Tab}; + +pub fn draw(frame: &mut Frame, app: &mut App) { + let chunks = Layout::vertical([ + Constraint::Length(3), // tab bar + Constraint::Min(1), // content + Constraint::Length(1), // status bar + ]) + .split(frame.area()); + + draw_tab_bar(frame, app, chunks[0]); + draw_content(frame, app, chunks[1]); + draw_status_bar(frame, app, chunks[2]); +} + +fn draw_tab_bar(frame: &mut Frame, app: &App, area: Rect) { + let block = Block::default().borders(Borders::ALL).title(" fipstop "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let highlight = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let normal = Style::default().fg(Color::White); + let divider = Style::default().fg(Color::DarkGray); + let group_sep = Style::default().fg(Color::DarkGray); + + let mut spans: Vec = Vec::new(); + for (i, tab) in Tab::ALL.iter().enumerate() { + if i > 0 { + if tab.group() != Tab::ALL[i - 1].group() { + spans.push(Span::styled(" \u{2502} ", group_sep)); + } else { + spans.push(Span::styled(" | ", divider)); + } + } + let style = if *tab == app.active_tab { highlight } else { normal }; + spans.push(Span::styled(tab.label(), style)); + } + + let line = Line::from(spans); + frame.render_widget(Paragraph::new(line), inner); +} + +fn draw_content(frame: &mut Frame, app: &mut App, area: Rect) { + match app.active_tab { + Tab::Node => dashboard::draw(frame, app, area), + Tab::Peers => peers::draw(frame, app, area), + Tab::Sessions => sessions::draw(frame, app, area), + Tab::Transports => transports::draw(frame, app, area), + Tab::Tree => tree::draw(frame, app, area), + Tab::Bloom => bloom::draw(frame, app, area), + Tab::Mmp => mmp::draw(frame, app, area), + Tab::Cache => {} // data stored for Routing tab cross-reference + Tab::Routing => routing::draw(frame, app, area), + Tab::Links => {} // not a navigable tab; data stored for cross-references + } +} + +fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) { + let conn = match &app.connection_state { + ConnectionState::Connected => { + Span::styled(" Connected ", Style::default().fg(Color::Green)) + } + ConnectionState::Disconnected(msg) => Span::styled( + format!(" Disconnected: {} ", msg), + Style::default().fg(Color::Red), + ), + }; + + let elapsed = app.last_fetch.elapsed(); + let timing = Span::raw(format!( + "| Refresh: {}s | Updated: {:.1}s ago ", + app.refresh_interval.as_secs(), + elapsed.as_secs_f64() + )); + + let help = Span::styled("[?] Help ", Style::default().fg(Color::DarkGray)); + + let line = Line::from(vec![conn, timing, help]); + let bar = Paragraph::new(line); + frame.render_widget(bar, area); +} diff --git a/src/bin/fipstop/ui/peers.rs b/src/bin/fipstop/ui/peers.rs new file mode 100644 index 0000000..5da3189 --- /dev/null +++ b/src/bin/fipstop/ui/peers.rs @@ -0,0 +1,273 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table}; + +use crate::app::{App, Tab}; + +use super::helpers; + +pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) { + let peers = get_peers(app); + let row_count = peers.len(); + + if app.detail_view.is_some() { + // Split: left 40% table, right 60% detail + let chunks = Layout::horizontal([ + Constraint::Percentage(40), + Constraint::Percentage(60), + ]) + .split(area); + + draw_table(frame, app, chunks[0], &peers, row_count); + draw_detail(frame, app, chunks[1], &peers); + } else { + draw_table(frame, app, area, &peers, row_count); + } +} + +fn get_peers(app: &App) -> Vec { + app.data + .get(&Tab::Peers) + .and_then(|v| v.get("peers")) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() +} + +fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, peers: &[serde_json::Value], row_count: usize) { + let header = Row::new(vec![ + Cell::from("Name"), + Cell::from("Address"), + Cell::from("Conn"), + Cell::from("Depth"), + Cell::from("SRTT"), + Cell::from("Loss"), + Cell::from("LQI"), + Cell::from("Pkts Tx"), + Cell::from("Pkts Rx"), + ]) + .style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = peers + .iter() + .map(|peer| { + let name = helpers::str_field(peer, "display_name"); + let addr = helpers::truncate_hex(helpers::str_field(peer, "ipv6_addr"), 10); + let conn = helpers::str_field(peer, "connectivity"); + let depth = peer + .get("tree_depth") + .and_then(|v| v.as_u64()) + .map(|n| n.to_string()) + .unwrap_or_else(|| "-".into()); + let srtt = helpers::nested_f64(peer, "mmp", "srtt_ms", 1); + let loss = helpers::nested_f64_prefer(peer, "mmp", "smoothed_loss", "loss_rate", 3); + let lqi = helpers::nested_f64(peer, "mmp", "lqi", 2); + let pkts_tx = helpers::nested_u64(peer, "stats", "packets_sent"); + let pkts_rx = helpers::nested_u64(peer, "stats", "packets_recv"); + + Row::new(vec![ + Cell::from(name.to_string()), + Cell::from(addr), + Cell::from(connectivity_styled(conn)), + Cell::from(depth), + Cell::from(srtt), + Cell::from(loss), + Cell::from(lqi), + Cell::from(pkts_tx), + Cell::from(pkts_rx), + ]) + }) + .collect(); + + let widths = [ + Constraint::Min(12), + Constraint::Length(13), + Constraint::Length(8), + Constraint::Length(5), + Constraint::Length(8), + Constraint::Length(7), + Constraint::Length(6), + Constraint::Length(9), + Constraint::Length(9), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" Peers ({}) ", row_count)), + ) + .row_highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + + let state = app.table_states.entry(Tab::Peers).or_default(); + frame.render_stateful_widget(table, area, state); + + // Scrollbar + if row_count > 0 { + let selected = state.selected().unwrap_or(0); + let mut scrollbar_state = ScrollbarState::new(row_count).position(selected); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None), + area, + &mut scrollbar_state, + ); + } +} + +fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Value]) { + let state = app.table_states.get(&Tab::Peers); + let selected = state.and_then(|s| s.selected()).unwrap_or(0); + + let block = Block::default() + .borders(Borders::ALL) + .title(" Peer Detail "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let Some(peer) = peers.get(selected) else { + let msg = Paragraph::new(" No peer selected").style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, inner); + return; + }; + + let has_tree = peer.get("has_tree_position").and_then(|v| v.as_bool()).unwrap_or(false); + let has_bloom = peer.get("has_bloom_filter").and_then(|v| v.as_bool()).unwrap_or(false); + + let mut lines: Vec = vec![ + // Identity + helpers::section_header("Identity"), + helpers::kv_line("Name", helpers::str_field(peer, "display_name")), + helpers::kv_line("Node Addr", helpers::str_field(peer, "node_addr")), + helpers::kv_line("Npub", helpers::str_field(peer, "npub")), + helpers::kv_line("IPv6 Addr", helpers::str_field(peer, "ipv6_addr")), + Line::from(""), + // Connection + helpers::section_header("Connection"), + helpers::kv_line("Connectivity", helpers::str_field(peer, "connectivity")), + helpers::kv_line("Link ID", &helpers::u64_field(peer, "link_id")), + ]; + if let Some(addr) = peer.get("transport_addr").and_then(|v| v.as_str()) { + lines.push(helpers::kv_line("Transport Addr", addr)); + } + // Link details (cross-referenced) + let link_id = peer.get("link_id").and_then(|v| v.as_u64()); + let link = lookup_link(app, link_id); + if let Some(ref link) = link { + lines.push(helpers::kv_line("Direction", helpers::str_field(link, "direction"))); + lines.push(helpers::kv_line("Link State", helpers::str_field(link, "state"))); + } + lines.extend([ + helpers::kv_line("Authenticated", &helpers::format_elapsed_ms( + peer.get("authenticated_at_ms").and_then(|v| v.as_u64()).unwrap_or(0), + )), + helpers::kv_line("Last Seen", &helpers::format_elapsed_ms( + peer.get("last_seen_ms").and_then(|v| v.as_u64()).unwrap_or(0), + )), + Line::from(""), + ]); + + // Transport info (cross-referenced from link → transport) + if let Some(transport) = link.as_ref().and_then(|l| lookup_transport(app, l)) { + lines.push(helpers::section_header("Transport")); + lines.push(helpers::kv_line("Type", helpers::str_field(&transport, "type"))); + if let Some(name) = transport.get("name").and_then(|v| v.as_str()) { + lines.push(helpers::kv_line("Name", name)); + } + lines.push(helpers::kv_line("MTU", &helpers::u64_field(&transport, "mtu"))); + if let Some(addr) = transport.get("local_addr").and_then(|v| v.as_str()) { + lines.push(helpers::kv_line("Local Addr", addr)); + } + lines.push(helpers::kv_line("State", helpers::str_field(&transport, "state"))); + lines.push(Line::from("")); + } + + lines.extend([ + // Tree & Bloom + helpers::section_header("Tree / Bloom"), + helpers::kv_line("Tree Position", if has_tree { "yes" } else { "no" }), + ]); + if let Some(depth) = peer.get("tree_depth").and_then(|v| v.as_u64()) { + lines.push(helpers::kv_line("Tree Depth", &depth.to_string())); + } + lines.extend([ + helpers::kv_line("Bloom Filter", if has_bloom { "yes" } else { "no" }), + helpers::kv_line("Filter Seq", &helpers::u64_field(peer, "filter_sequence")), + Line::from(""), + // Stats + helpers::section_header("Link Stats"), + helpers::kv_line("Pkts Sent", &helpers::nested_u64(peer, "stats", "packets_sent")), + helpers::kv_line("Pkts Recv", &helpers::nested_u64(peer, "stats", "packets_recv")), + helpers::kv_line("Bytes Sent", &helpers::format_bytes( + peer.get("stats").and_then(|s| s.get("bytes_sent")).and_then(|v| v.as_u64()).unwrap_or(0), + )), + helpers::kv_line("Bytes Recv", &helpers::format_bytes( + peer.get("stats").and_then(|s| s.get("bytes_recv")).and_then(|v| v.as_u64()).unwrap_or(0), + )), + Line::from(""), + ]); + + // MMP (if present) + if peer.get("mmp").is_some() { + lines.push(helpers::section_header("MMP Metrics")); + lines.push(helpers::kv_line("Mode", &helpers::nested_str(peer, "mmp", "mode"))); + lines.push(helpers::kv_line("SRTT", &format!("{}ms", helpers::nested_f64(peer, "mmp", "srtt_ms", 1)))); + lines.push(helpers::kv_line("Loss Rate", &helpers::nested_f64_prefer(peer, "mmp", "smoothed_loss", "loss_rate", 4))); + lines.push(helpers::kv_line("ETX", &helpers::nested_f64_prefer(peer, "mmp", "smoothed_etx", "etx", 2))); + lines.push(helpers::kv_line("LQI", &helpers::nested_f64(peer, "mmp", "lqi", 2))); + lines.push(helpers::kv_line("Goodput", &helpers::nested_throughput(peer, "mmp", "goodput_bps"))); + lines.push(helpers::kv_line("Delivery Fwd", &helpers::nested_f64(peer, "mmp", "delivery_ratio_forward", 3))); + lines.push(helpers::kv_line("Delivery Rev", &helpers::nested_f64(peer, "mmp", "delivery_ratio_reverse", 3))); + } + + let detail_scroll = app.detail_view.as_ref().map(|d| d.scroll).unwrap_or(0); + let paragraph = Paragraph::new(lines).scroll((detail_scroll, 0)); + frame.render_widget(paragraph, inner); +} + +/// Look up the link for a peer by link_id. +fn lookup_link(app: &App, link_id: Option) -> Option { + let link_id = link_id?; + let links = app.data.get(&Tab::Links)?; + links + .get("links")? + .as_array()? + .iter() + .find(|l| l.get("link_id").and_then(|v| v.as_u64()) == Some(link_id)) + .cloned() +} + +/// Look up transport info by chaining: link → transport_id → transport. +fn lookup_transport(app: &App, link: &serde_json::Value) -> Option { + let transport_id = link.get("transport_id").and_then(|v| v.as_u64())?; + let transports = app.data.get(&Tab::Transports)?; + transports + .get("transports")? + .as_array()? + .iter() + .find(|t| t.get("transport_id").and_then(|v| v.as_u64()) == Some(transport_id)) + .cloned() +} + +fn connectivity_styled(conn: &str) -> Span<'static> { + let color = match conn { + "Full" => Color::Green, + "Partial" => Color::Yellow, + _ => Color::Red, + }; + Span::styled(conn.to_string(), Style::default().fg(color)) +} + diff --git a/src/bin/fipstop/ui/routing.rs b/src/bin/fipstop/ui/routing.rs new file mode 100644 index 0000000..c09e95f --- /dev/null +++ b/src/bin/fipstop/ui/routing.rs @@ -0,0 +1,174 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::{App, Tab}; + +use super::helpers; + +pub fn draw(frame: &mut Frame, app: &App, area: Rect) { + let data = match app.data.get(&Tab::Routing) { + Some(d) => d, + None => { + let msg = Paragraph::new(" Waiting for data...") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, area); + return; + } + }; + + let chunks = Layout::vertical([ + Constraint::Length(7), // Routing State + Constraint::Length(8), // Coordinate Cache + Constraint::Min(3), // Routing Statistics + ]) + .split(area); + + draw_routing_state(frame, data, chunks[0]); + draw_coord_cache(frame, app, chunks[1]); + draw_routing_stats(frame, data, chunks[2]); +} + +fn draw_routing_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) { + let lines = vec![ + helpers::kv_line("Coord Cache", &helpers::u64_field(data, "coord_cache_entries")), + helpers::kv_line( + "Identity Cache", + &helpers::u64_field(data, "identity_cache_entries"), + ), + helpers::kv_line( + "Pending Lookups", + &helpers::u64_field(data, "pending_lookups"), + ), + helpers::kv_line( + "Recent Requests", + &helpers::u64_field(data, "recent_requests"), + ), + ]; + + let block = Block::default() + .borders(Borders::ALL) + .title(" Routing State "); + let inner = block.inner(area); + frame.render_widget(block, area); + frame.render_widget(Paragraph::new(lines), inner); +} + +/// Format a forwarding counter as "N pkts (formatted_bytes)". +fn fwd_line(data: &serde_json::Value, label: &str, pkt_key: &str, byte_key: &str) -> Line<'static> { + let pkts = data + .get("forwarding") + .and_then(|f| f.get(pkt_key)) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let bytes = data + .get("forwarding") + .and_then(|f| f.get(byte_key)) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + helpers::kv_line(label, &format!("{} pkts ({})", pkts, helpers::format_bytes(bytes))) +} + +fn draw_routing_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Routing Statistics "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let mut lines = vec![ + helpers::section_header("Forwarding"), + fwd_line(data, "Received", "received_packets", "received_bytes"), + fwd_line(data, "Delivered", "delivered_packets", "delivered_bytes"), + fwd_line(data, "Forwarded", "forwarded_packets", "forwarded_bytes"), + fwd_line(data, "Originated", "originated_packets", "originated_bytes"), + fwd_line(data, "Decode Error", "decode_error_packets", "decode_error_bytes"), + fwd_line(data, "TTL Exhausted", "ttl_exhausted_packets", "ttl_exhausted_bytes"), + fwd_line(data, "No Route", "drop_no_route_packets", "drop_no_route_bytes"), + fwd_line(data, "MTU Exceeded", "drop_mtu_exceeded_packets", "drop_mtu_exceeded_bytes"), + fwd_line(data, "Send Error", "drop_send_error_packets", "drop_send_error_bytes"), + Line::from(""), + helpers::section_header("Discovery Requests"), + helpers::kv_line("Received", &helpers::nested_u64(data, "discovery", "req_received")), + helpers::kv_line("Forwarded", &helpers::nested_u64(data, "discovery", "req_forwarded")), + helpers::kv_line("Initiated", &helpers::nested_u64(data, "discovery", "req_initiated")), + helpers::kv_line("Deduplicated", &helpers::nested_u64(data, "discovery", "req_deduplicated")), + helpers::kv_line("Target Is Us", &helpers::nested_u64(data, "discovery", "req_target_is_us")), + helpers::kv_line("Duplicate", &helpers::nested_u64(data, "discovery", "req_duplicate")), + helpers::kv_line("Already Visited", &helpers::nested_u64(data, "discovery", "req_already_visited")), + helpers::kv_line("TTL Exhausted", &helpers::nested_u64(data, "discovery", "req_ttl_exhausted")), + helpers::kv_line("Decode Error", &helpers::nested_u64(data, "discovery", "req_decode_error")), + Line::from(""), + helpers::section_header("Discovery Responses"), + helpers::kv_line("Received", &helpers::nested_u64(data, "discovery", "resp_received")), + helpers::kv_line("Accepted", &helpers::nested_u64(data, "discovery", "resp_accepted")), + helpers::kv_line("Forwarded", &helpers::nested_u64(data, "discovery", "resp_forwarded")), + helpers::kv_line("Timed Out", &helpers::nested_u64(data, "discovery", "resp_timed_out")), + helpers::kv_line("Identity Miss", &helpers::nested_u64(data, "discovery", "resp_identity_miss")), + helpers::kv_line("Proof Failed", &helpers::nested_u64(data, "discovery", "resp_proof_failed")), + helpers::kv_line("Decode Error", &helpers::nested_u64(data, "discovery", "resp_decode_error")), + Line::from(""), + helpers::section_header("Error Signals"), + helpers::kv_line("Coords Required", &helpers::nested_u64(data, "error_signals", "coords_required")), + helpers::kv_line("Path Broken", &helpers::nested_u64(data, "error_signals", "path_broken")), + helpers::kv_line("MTU Exceeded", &helpers::nested_u64(data, "error_signals", "mtu_exceeded")), + ]; + + let max_lines = inner.height as usize; + lines.truncate(max_lines); + + frame.render_widget(Paragraph::new(lines), inner); +} + +fn draw_coord_cache(frame: &mut Frame, app: &App, area: Rect) { + let data = match app.data.get(&Tab::Cache) { + Some(d) => d, + None => { + let block = Block::default() + .borders(Borders::ALL) + .title(" Coordinate Cache "); + let inner = block.inner(area); + frame.render_widget(block, area); + let msg = + Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, inner); + return; + } + }; + + let entries = helpers::u64_field(data, "entries"); + let max_entries = helpers::u64_field(data, "max_entries"); + let fill_pct = data + .get("fill_ratio") + .and_then(|v| v.as_f64()) + .map(|r| format!("{:.1}%", r * 100.0)) + .unwrap_or_else(|| "-".into()); + let ttl = data + .get("default_ttl_ms") + .and_then(|v| v.as_u64()) + .map(helpers::format_duration_ms) + .unwrap_or_else(|| "-".into()); + let expired = helpers::u64_field(data, "expired"); + let avg_age = data + .get("avg_age_ms") + .and_then(|v| v.as_u64()) + .map(helpers::format_duration_ms) + .unwrap_or_else(|| "-".into()); + + let lines = vec![ + helpers::kv_line("Entries", &format!("{entries} / {max_entries}")), + helpers::kv_line("Fill Ratio", &fill_pct), + helpers::kv_line("Default TTL", &ttl), + helpers::kv_line("Expired", &expired), + helpers::kv_line("Avg Age", &avg_age), + ]; + + let block = Block::default() + .borders(Borders::ALL) + .title(" Coordinate Cache "); + let inner = block.inner(area); + frame.render_widget(block, area); + frame.render_widget(Paragraph::new(lines), inner); +} diff --git a/src/bin/fipstop/ui/sessions.rs b/src/bin/fipstop/ui/sessions.rs new file mode 100644 index 0000000..15aa029 --- /dev/null +++ b/src/bin/fipstop/ui/sessions.rs @@ -0,0 +1,274 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, +}; + +use crate::app::{App, Tab}; + +use super::helpers; + +pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) { + let sessions = get_sessions(app); + let row_count = sessions.len(); + + if app.detail_view.is_some() { + let chunks = Layout::horizontal([ + Constraint::Percentage(40), + Constraint::Percentage(60), + ]) + .split(area); + + draw_table(frame, app, chunks[0], &sessions, row_count); + draw_detail(frame, app, chunks[1], &sessions); + } else { + draw_table(frame, app, area, &sessions, row_count); + } +} + +fn get_sessions(app: &App) -> Vec { + app.data + .get(&Tab::Sessions) + .and_then(|v| v.get("sessions")) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() +} + +fn draw_table( + frame: &mut Frame, + app: &mut App, + area: Rect, + sessions: &[serde_json::Value], + row_count: usize, +) { + let header = Row::new(vec![ + Cell::from("Name"), + Cell::from("Remote Addr"), + Cell::from("State"), + Cell::from("Role"), + Cell::from("SRTT"), + Cell::from("Loss"), + Cell::from("SQI"), + Cell::from("Path MTU"), + Cell::from("Last Activity"), + ]) + .style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = sessions + .iter() + .map(|session| { + let name = helpers::str_field(session, "display_name"); + let addr = helpers::truncate_hex(helpers::str_field(session, "remote_addr"), 10); + let state = helpers::str_field(session, "state"); + let role = session + .get("is_initiator") + .and_then(|v| v.as_bool()) + .map(|b| if b { "init" } else { "resp" }) + .unwrap_or("-"); + let srtt = helpers::nested_f64(session, "mmp", "srtt_ms", 1); + let loss = helpers::nested_f64_prefer(session, "mmp", "smoothed_loss", "loss_rate", 3); + let sqi = helpers::nested_f64(session, "mmp", "sqi", 2); + let path_mtu = helpers::nested_u64(session, "mmp", "path_mtu"); + let activity = helpers::format_elapsed_ms( + session + .get("last_activity_ms") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + ); + + Row::new(vec![ + Cell::from(name.to_string()), + Cell::from(addr), + Cell::from(state_styled(state)), + Cell::from(role.to_string()), + Cell::from(srtt), + Cell::from(loss), + Cell::from(sqi), + Cell::from(path_mtu), + Cell::from(activity), + ]) + }) + .collect(); + + let widths = [ + Constraint::Min(12), + Constraint::Length(13), + Constraint::Length(13), + Constraint::Length(6), + Constraint::Length(8), + Constraint::Length(7), + Constraint::Length(6), + Constraint::Length(9), + Constraint::Length(14), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" Sessions ({}) ", row_count)), + ) + .row_highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + + let state = app.table_states.entry(Tab::Sessions).or_default(); + frame.render_stateful_widget(table, area, state); + + if row_count > 0 { + let selected = state.selected().unwrap_or(0); + let mut scrollbar_state = ScrollbarState::new(row_count).position(selected); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None), + area, + &mut scrollbar_state, + ); + } +} + +fn draw_detail(frame: &mut Frame, app: &App, area: Rect, sessions: &[serde_json::Value]) { + let state = app.table_states.get(&Tab::Sessions); + let selected = state.and_then(|s| s.selected()).unwrap_or(0); + + let block = Block::default() + .borders(Borders::ALL) + .title(" Session Detail "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let Some(session) = sessions.get(selected) else { + let msg = + Paragraph::new(" No session selected").style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, inner); + return; + }; + + let role = session + .get("is_initiator") + .and_then(|v| v.as_bool()) + .map(|b| if b { "Initiator" } else { "Responder" }) + .unwrap_or("-"); + + let mut lines: Vec = vec![ + helpers::section_header("Identity"), + helpers::kv_line("Name", helpers::str_field(session, "display_name")), + helpers::kv_line("Npub", helpers::str_field(session, "npub")), + helpers::kv_line("Remote Addr", helpers::str_field(session, "remote_addr")), + Line::from(""), + helpers::section_header("Session"), + helpers::kv_line("State", helpers::str_field(session, "state")), + helpers::kv_line("Role", role), + helpers::kv_line( + "Last Activity", + &helpers::format_elapsed_ms( + session + .get("last_activity_ms") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + ), + ), + Line::from(""), + ]; + + // Traffic stats + lines.push(helpers::section_header("Traffic")); + lines.push(helpers::kv_line( + "Pkts Sent", + &helpers::nested_u64(session, "stats", "packets_sent"), + )); + lines.push(helpers::kv_line( + "Pkts Recv", + &helpers::nested_u64(session, "stats", "packets_recv"), + )); + lines.push(helpers::kv_line( + "Bytes Sent", + &helpers::format_bytes( + session + .get("stats") + .and_then(|s| s.get("bytes_sent")) + .and_then(|v| v.as_u64()) + .unwrap_or(0), + ), + )); + lines.push(helpers::kv_line( + "Bytes Recv", + &helpers::format_bytes( + session + .get("stats") + .and_then(|s| s.get("bytes_recv")) + .and_then(|v| v.as_u64()) + .unwrap_or(0), + ), + )); + lines.push(Line::from("")); + + if session.get("mmp").is_some() { + lines.push(helpers::section_header("MMP Metrics")); + lines.push(helpers::kv_line( + "Mode", + &helpers::nested_str(session, "mmp", "mode"), + )); + lines.push(helpers::kv_line( + "SRTT", + &format!( + "{}ms", + helpers::nested_f64(session, "mmp", "srtt_ms", 1) + ), + )); + lines.push(helpers::kv_line( + "Loss Rate", + &helpers::nested_f64_prefer(session, "mmp", "smoothed_loss", "loss_rate", 4), + )); + lines.push(helpers::kv_line( + "ETX", + &helpers::nested_f64_prefer(session, "mmp", "smoothed_etx", "etx", 2), + )); + lines.push(helpers::kv_line( + "SQI", + &helpers::nested_f64(session, "mmp", "sqi", 2), + )); + lines.push(helpers::kv_line( + "Goodput", + &helpers::nested_throughput(session, "mmp", "goodput_bps"), + )); + lines.push(helpers::kv_line( + "Delivery Fwd", + &helpers::nested_f64(session, "mmp", "delivery_ratio_forward", 3), + )); + lines.push(helpers::kv_line( + "Delivery Rev", + &helpers::nested_f64(session, "mmp", "delivery_ratio_reverse", 3), + )); + lines.push(helpers::kv_line( + "Path MTU", + &helpers::nested_u64(session, "mmp", "path_mtu"), + )); + } + + let detail_scroll = app.detail_view.as_ref().map(|d| d.scroll).unwrap_or(0); + let paragraph = Paragraph::new(lines).scroll((detail_scroll, 0)); + frame.render_widget(paragraph, inner); +} + +fn state_styled(state: &str) -> Span<'static> { + let color = match state { + "established" => Color::Green, + "initiating" | "awaiting_msg3" => Color::Yellow, + _ => Color::Red, + }; + Span::styled(state.to_string(), Style::default().fg(color)) +} + diff --git a/src/bin/fipstop/ui/transports.rs b/src/bin/fipstop/ui/transports.rs new file mode 100644 index 0000000..30e5125 --- /dev/null +++ b/src/bin/fipstop/ui/transports.rs @@ -0,0 +1,605 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, +}; + +use crate::app::{App, SelectedTreeItem, Tab}; + +use super::helpers; + +/// A single visible row in the tree view — either a transport or a nested link. +enum TreeRow { + Transport { + index: usize, + transport_id: u64, + link_count: usize, + }, + Link { + index: usize, + is_last: bool, + }, +} + +pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) { + let transports = get_transports(app); + let links = get_links(app); + let tree_rows = build_tree_rows(&transports, &links, app); + + // Update app state for navigation + app.tree_row_count = tree_rows.len(); + update_selected_tree_item(app, &tree_rows); + + if app.detail_view.is_some() { + let chunks = Layout::horizontal([ + Constraint::Percentage(40), + Constraint::Percentage(60), + ]) + .split(area); + + draw_table(frame, app, chunks[0], &transports, &links, &tree_rows); + draw_detail(frame, app, chunks[1], &transports, &links, &tree_rows); + } else { + draw_table(frame, app, area, &transports, &links, &tree_rows); + } +} + +fn get_transports(app: &App) -> Vec { + app.data + .get(&Tab::Transports) + .and_then(|v| v.get("transports")) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() +} + +fn get_links(app: &App) -> Vec { + app.data + .get(&Tab::Links) + .and_then(|v| v.get("links")) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() +} + +fn build_tree_rows( + transports: &[serde_json::Value], + links: &[serde_json::Value], + app: &App, +) -> Vec { + let mut rows = Vec::new(); + for (t_idx, transport) in transports.iter().enumerate() { + let tid = transport + .get("transport_id") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + // Gather links belonging to this transport + let transport_links: Vec<(usize, &serde_json::Value)> = links + .iter() + .enumerate() + .filter(|(_, l)| l.get("transport_id").and_then(|v| v.as_u64()) == Some(tid)) + .collect(); + + rows.push(TreeRow::Transport { + index: t_idx, + transport_id: tid, + link_count: transport_links.len(), + }); + + if app.expanded_transports.contains(&tid) { + let last_idx = transport_links.len().saturating_sub(1); + for (pos, (l_idx, _)) in transport_links.iter().enumerate() { + rows.push(TreeRow::Link { + index: *l_idx, + is_last: pos == last_idx, + }); + } + } + } + rows +} + +fn update_selected_tree_item(app: &mut App, tree_rows: &[TreeRow]) { + let selected = app + .table_states + .get(&Tab::Transports) + .and_then(|s| s.selected()) + .unwrap_or(0); + + app.selected_tree_item = match tree_rows.get(selected) { + Some(TreeRow::Transport { transport_id, .. }) => { + SelectedTreeItem::Transport(*transport_id) + } + Some(TreeRow::Link { .. }) => SelectedTreeItem::Link, + None => SelectedTreeItem::None, + }; +} + +fn draw_table( + frame: &mut Frame, + app: &mut App, + area: Rect, + transports: &[serde_json::Value], + links: &[serde_json::Value], + tree_rows: &[TreeRow], +) { + let header = Row::new(vec![ + Cell::from("Transport / Link"), + Cell::from("State"), + Cell::from("Peer"), + Cell::from("Tx"), + Cell::from("Rx"), + ]) + .style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = tree_rows + .iter() + .map(|tree_row| match tree_row { + TreeRow::Transport { + index, + transport_id, + link_count, + } => { + let t = &transports[*index]; + let indicator = if *link_count == 0 { + " " + } else if app.expanded_transports.contains(transport_id) { + "\u{25BC} " // ▼ + } else { + "\u{25B6} " // ▶ + }; + let typ = helpers::str_field(t, "type"); + let name = t.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let addr = t.get("local_addr").and_then(|v| v.as_str()).unwrap_or(""); + let label = if !name.is_empty() { + format!("{indicator}{typ} {name}") + } else if !addr.is_empty() { + format!("{indicator}{typ} {addr}") + } else { + format!("{indicator}{typ} #{transport_id}") + }; + + let state = helpers::str_field(t, "state"); + let tx = t + .get("stats") + .and_then(|s| s.get("packets_sent").or_else(|| s.get("frames_sent"))) + .and_then(|v| v.as_u64()) + .map(|n| n.to_string()) + .unwrap_or_else(|| "-".into()); + let rx = t + .get("stats") + .and_then(|s| s.get("packets_recv").or_else(|| s.get("frames_recv"))) + .and_then(|v| v.as_u64()) + .map(|n| n.to_string()) + .unwrap_or_else(|| "-".into()); + + Row::new(vec![ + Cell::from(label), + Cell::from(state.to_string()), + Cell::from(""), + Cell::from(tx), + Cell::from(rx), + ]) + .style(Style::default().fg(Color::White)) + } + TreeRow::Link { index, is_last } => { + let link = &links[*index]; + let tree_char = if *is_last { "\u{2514}\u{2500}" } else { "\u{251C}\u{2500}" }; // └─ or ├─ + let dir = helpers::str_field(link, "direction"); + let dir_short = match dir { + "Outbound" => "Out", + "Inbound" => "In", + other => other, + }; + let addr = helpers::truncate_hex(helpers::str_field(link, "remote_addr"), 16); + let label = format!(" {tree_char} {dir_short} {addr}"); + + let state = helpers::str_field(link, "state"); + let peer_name = lookup_peer_for_link(app, link) + .map(|p| helpers::str_field(&p, "display_name").to_string()) + .unwrap_or_default(); + + Row::new(vec![ + Cell::from(Span::styled( + label, + Style::default().fg(if dir == "Outbound" { + Color::Cyan + } else { + Color::Green + }), + )), + Cell::from(state.to_string()), + Cell::from(peer_name), + Cell::from(""), + Cell::from(""), + ]) + } + }) + .collect(); + + let transport_count = transports.len(); + let link_count: usize = tree_rows + .iter() + .filter(|r| matches!(r, TreeRow::Link { .. })) + .count(); + let title = if link_count > 0 { + format!(" Transports ({transport_count}) Links ({link_count}) ") + } else { + format!(" Transports ({transport_count}) ") + }; + + let widths = [ + Constraint::Min(28), + Constraint::Length(12), + Constraint::Length(14), + Constraint::Length(9), + Constraint::Length(9), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)) + .row_highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("\u{25B8} "); // ▸ + + let row_count = tree_rows.len(); + let state = app.table_states.entry(Tab::Transports).or_default(); + frame.render_stateful_widget(table, area, state); + + if row_count > 0 { + let selected = state.selected().unwrap_or(0); + let mut scrollbar_state = ScrollbarState::new(row_count).position(selected); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None), + area, + &mut scrollbar_state, + ); + } +} + +fn draw_detail( + frame: &mut Frame, + app: &App, + area: Rect, + transports: &[serde_json::Value], + links: &[serde_json::Value], + tree_rows: &[TreeRow], +) { + let selected = app + .table_states + .get(&Tab::Transports) + .and_then(|s| s.selected()) + .unwrap_or(0); + + let Some(tree_row) = tree_rows.get(selected) else { + let block = Block::default() + .borders(Borders::ALL) + .title(" Detail "); + let inner = block.inner(area); + frame.render_widget(block, area); + let msg = Paragraph::new(" No item selected") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, inner); + return; + }; + + match tree_row { + TreeRow::Transport { index, .. } => { + draw_transport_detail(frame, app, area, &transports[*index]); + } + TreeRow::Link { index, .. } => { + draw_link_detail(frame, app, area, &links[*index]); + } + } +} + +fn draw_transport_detail(frame: &mut Frame, app: &App, area: Rect, t: &serde_json::Value) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Transport Detail "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let mut lines: Vec = vec![ + helpers::section_header("Transport Info"), + helpers::kv_line("Transport ID", &helpers::u64_field(t, "transport_id")), + helpers::kv_line("Type", helpers::str_field(t, "type")), + helpers::kv_line("State", helpers::str_field(t, "state")), + helpers::kv_line("MTU", &helpers::u64_field(t, "mtu")), + ]; + + if let Some(name) = t.get("name").and_then(|v| v.as_str()) { + lines.push(helpers::kv_line("Name", name)); + } + if let Some(addr) = t.get("local_addr").and_then(|v| v.as_str()) { + lines.push(helpers::kv_line("Local Addr", addr)); + } + + // Transport stats + if let Some(stats) = t.get("stats") { + let typ = helpers::str_field(t, "type"); + lines.push(Line::from("")); + lines.push(helpers::section_header("Traffic")); + + match typ { + "ethernet" => { + lines.push(helpers::kv_line( + "Frames Sent", + &helpers::nested_u64(t, "stats", "frames_sent"), + )); + lines.push(helpers::kv_line( + "Frames Recv", + &helpers::nested_u64(t, "stats", "frames_recv"), + )); + } + _ => { + lines.push(helpers::kv_line( + "Pkts Sent", + &helpers::nested_u64(t, "stats", "packets_sent"), + )); + lines.push(helpers::kv_line( + "Pkts Recv", + &helpers::nested_u64(t, "stats", "packets_recv"), + )); + } + } + + let bytes_sent = stats + .get("bytes_sent") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let bytes_recv = stats + .get("bytes_recv") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + lines.push(helpers::kv_line( + "Bytes Sent", + &helpers::format_bytes(bytes_sent), + )); + lines.push(helpers::kv_line( + "Bytes Recv", + &helpers::format_bytes(bytes_recv), + )); + + lines.push(Line::from("")); + lines.push(helpers::section_header("Errors")); + lines.push(helpers::kv_line( + "Send Errors", + &helpers::nested_u64(t, "stats", "send_errors"), + )); + lines.push(helpers::kv_line( + "Recv Errors", + &helpers::nested_u64(t, "stats", "recv_errors"), + )); + + match typ { + "udp" => { + lines.push(helpers::kv_line( + "MTU Exceeded", + &helpers::nested_u64(t, "stats", "mtu_exceeded"), + )); + lines.push(helpers::kv_line( + "Kernel Drops", + &helpers::nested_u64(t, "stats", "kernel_drops"), + )); + } + "tcp" => { + lines.push(helpers::kv_line( + "MTU Exceeded", + &helpers::nested_u64(t, "stats", "mtu_exceeded"), + )); + lines.push(Line::from("")); + lines.push(helpers::section_header("Connections")); + lines.push(helpers::kv_line( + "Established", + &helpers::nested_u64(t, "stats", "connections_established"), + )); + lines.push(helpers::kv_line( + "Accepted", + &helpers::nested_u64(t, "stats", "connections_accepted"), + )); + lines.push(helpers::kv_line( + "Rejected", + &helpers::nested_u64(t, "stats", "connections_rejected"), + )); + lines.push(helpers::kv_line( + "Timeouts", + &helpers::nested_u64(t, "stats", "connect_timeouts"), + )); + lines.push(helpers::kv_line( + "Refused", + &helpers::nested_u64(t, "stats", "connect_refused"), + )); + } + "ethernet" => { + lines.push(Line::from("")); + lines.push(helpers::section_header("Beacons")); + lines.push(helpers::kv_line( + "Beacons Sent", + &helpers::nested_u64(t, "stats", "beacons_sent"), + )); + lines.push(helpers::kv_line( + "Beacons Recv", + &helpers::nested_u64(t, "stats", "beacons_recv"), + )); + lines.push(Line::from("")); + lines.push(helpers::section_header("Frame Errors")); + lines.push(helpers::kv_line( + "Too Short", + &helpers::nested_u64(t, "stats", "frames_too_short"), + )); + lines.push(helpers::kv_line( + "Too Long", + &helpers::nested_u64(t, "stats", "frames_too_long"), + )); + } + _ => {} + } + } + + let detail_scroll = app.detail_view.as_ref().map(|d| d.scroll).unwrap_or(0); + let paragraph = Paragraph::new(lines).scroll((detail_scroll, 0)); + frame.render_widget(paragraph, inner); +} + +fn draw_link_detail(frame: &mut Frame, app: &App, area: Rect, link: &serde_json::Value) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Link Detail "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let mut lines: Vec = vec![ + helpers::section_header("Link Info"), + helpers::kv_line("Link ID", &helpers::u64_field(link, "link_id")), + helpers::kv_line("Direction", helpers::str_field(link, "direction")), + helpers::kv_line("State", helpers::str_field(link, "state")), + helpers::kv_line("Remote Addr", helpers::str_field(link, "remote_addr")), + helpers::kv_line( + "Created", + &helpers::format_elapsed_ms( + link.get("created_at_ms") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + ), + ), + Line::from(""), + ]; + + // Transport cross-reference + if let Some(transport) = lookup_transport(app, link) { + lines.push(helpers::section_header("Transport")); + lines.push(helpers::kv_line( + "Transport ID", + &helpers::u64_field(link, "transport_id"), + )); + lines.push(helpers::kv_line( + "Type", + helpers::str_field(&transport, "type"), + )); + if let Some(name) = transport.get("name").and_then(|v| v.as_str()) { + lines.push(helpers::kv_line("Name", name)); + } + lines.push(helpers::kv_line( + "MTU", + &helpers::u64_field(&transport, "mtu"), + )); + if let Some(addr) = transport.get("local_addr").and_then(|v| v.as_str()) { + lines.push(helpers::kv_line("Local Addr", addr)); + } + lines.push(helpers::kv_line( + "State", + helpers::str_field(&transport, "state"), + )); + lines.push(Line::from("")); + } + + // Peer cross-reference + if let Some(peer) = lookup_peer_for_link(app, link) { + lines.push(helpers::section_header("Peer")); + lines.push(helpers::kv_line( + "Name", + helpers::str_field(&peer, "display_name"), + )); + lines.push(helpers::kv_line( + "Connectivity", + helpers::str_field(&peer, "connectivity"), + )); + lines.push(helpers::kv_line( + "Last Seen", + &helpers::format_elapsed_ms( + peer.get("last_seen_ms") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + ), + )); + lines.push(Line::from("")); + lines.push(helpers::section_header("Peer Stats")); + lines.push(helpers::kv_line( + "Pkts Sent", + &helpers::nested_u64(&peer, "stats", "packets_sent"), + )); + lines.push(helpers::kv_line( + "Pkts Recv", + &helpers::nested_u64(&peer, "stats", "packets_recv"), + )); + lines.push(helpers::kv_line( + "Bytes Sent", + &helpers::format_bytes( + peer.get("stats") + .and_then(|s| s.get("bytes_sent")) + .and_then(|v| v.as_u64()) + .unwrap_or(0), + ), + )); + lines.push(helpers::kv_line( + "Bytes Recv", + &helpers::format_bytes( + peer.get("stats") + .and_then(|s| s.get("bytes_recv")) + .and_then(|v| v.as_u64()) + .unwrap_or(0), + ), + )); + if peer.get("mmp").is_some() { + lines.push(Line::from("")); + lines.push(helpers::section_header("MMP Metrics")); + lines.push(helpers::kv_line( + "SRTT", + &format!("{}ms", helpers::nested_f64(&peer, "mmp", "srtt_ms", 1)), + )); + lines.push(helpers::kv_line( + "Loss Rate", + &helpers::nested_f64_prefer(&peer, "mmp", "smoothed_loss", "loss_rate", 4), + )); + lines.push(helpers::kv_line( + "ETX", + &helpers::nested_f64_prefer(&peer, "mmp", "smoothed_etx", "etx", 2), + )); + lines.push(helpers::kv_line( + "LQI", + &helpers::nested_f64(&peer, "mmp", "lqi", 2), + )); + } + } + + let detail_scroll = app.detail_view.as_ref().map(|d| d.scroll).unwrap_or(0); + let paragraph = Paragraph::new(lines).scroll((detail_scroll, 0)); + frame.render_widget(paragraph, inner); +} + +/// Look up the peer associated with a link by matching link_id. +fn lookup_peer_for_link(app: &App, link: &serde_json::Value) -> Option { + let link_id = link.get("link_id").and_then(|v| v.as_u64())?; + let peers = app.data.get(&Tab::Peers)?; + peers + .get("peers")? + .as_array()? + .iter() + .find(|p| p.get("link_id").and_then(|v| v.as_u64()) == Some(link_id)) + .cloned() +} + +fn lookup_transport(app: &App, link: &serde_json::Value) -> Option { + let transport_id = link.get("transport_id").and_then(|v| v.as_u64())?; + let transports = app.data.get(&Tab::Transports)?; + transports + .get("transports")? + .as_array()? + .iter() + .find(|t| t.get("transport_id").and_then(|v| v.as_u64()) == Some(transport_id)) + .cloned() +} diff --git a/src/bin/fipstop/ui/tree.rs b/src/bin/fipstop/ui/tree.rs new file mode 100644 index 0000000..bbefc49 --- /dev/null +++ b/src/bin/fipstop/ui/tree.rs @@ -0,0 +1,213 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::{App, Tab}; + +use super::helpers; + +pub fn draw(frame: &mut Frame, app: &App, area: Rect) { + let data = match app.data.get(&Tab::Tree) { + Some(d) => d, + None => { + let msg = Paragraph::new(" Waiting for data...") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, area); + return; + } + }; + + let chunks = Layout::vertical([ + Constraint::Length(10), // Tree Position + Constraint::Length(22), // Tree Announce Stats + Constraint::Min(3), // Tree Peers + ]) + .split(area); + + draw_position(frame, data, chunks[0]); + draw_stats(frame, data, chunks[1]); + draw_peers(frame, data, chunks[2]); +} + +fn draw_position(frame: &mut Frame, data: &serde_json::Value, area: Rect) { + let root_hex = helpers::str_field(data, "root"); + let is_root = data + .get("is_root") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let depth = helpers::u64_field(data, "depth"); + let parent_name = helpers::str_field(data, "parent_display_name"); + let decl_seq = helpers::u64_field(data, "declaration_sequence"); + let decl_signed = helpers::bool_field(data, "declaration_signed"); + + let root_display = if is_root { + format!("{} (self)", helpers::truncate_hex(root_hex, 16)) + } else { + helpers::truncate_hex(root_hex, 16) + }; + + let parent_display = if is_root { + "self (root)".to_string() + } else { + parent_name.to_string() + }; + + let mut lines = vec![ + helpers::kv_line("Root", &root_display), + helpers::kv_line("Depth", &depth), + helpers::kv_line("Parent", &parent_display), + helpers::kv_line("Declaration", &format!("seq {decl_seq}, {decl_signed}")), + Line::from(""), + ]; + + // Coordinate path: my_coords array is self→root, render root→self + if let Some(coords) = data.get("my_coords").and_then(|v| v.as_array()) { + let mut path_parts: Vec = vec![Span::styled( + " Path: ", + Style::default().fg(Color::DarkGray), + )]; + + if coords.is_empty() { + path_parts.push(Span::styled( + "[root]", + Style::default().fg(Color::Yellow), + )); + } else { + // Reverse: root first, self last + for (i, entry) in coords.iter().rev().enumerate() { + if i > 0 { + path_parts + .push(Span::styled(" > ", Style::default().fg(Color::DarkGray))); + } + let hex = entry.as_str().unwrap_or("-"); + let color = if i == 0 { + Color::Yellow // root + } else { + Color::White + }; + path_parts + .push(Span::styled(helpers::truncate_hex(hex, 8), Style::default().fg(color))); + } + path_parts.push(Span::styled(" > ", Style::default().fg(Color::DarkGray))); + path_parts.push(Span::styled( + "[self]", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )); + } + + lines.push(Line::from(path_parts)); + } + + let block = Block::default() + .borders(Borders::ALL) + .title(" Tree Position "); + let inner = block.inner(area); + frame.render_widget(block, area); + frame.render_widget(Paragraph::new(lines), inner); +} + +fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Tree Announce Stats "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let mut lines = vec![ + helpers::section_header("Inbound"), + helpers::kv_line("Received", &helpers::nested_u64(data, "stats", "received")), + helpers::kv_line("Accepted", &helpers::nested_u64(data, "stats", "accepted")), + helpers::kv_line("Decode Error", &helpers::nested_u64(data, "stats", "decode_error")), + helpers::kv_line("Unknown Peer", &helpers::nested_u64(data, "stats", "unknown_peer")), + helpers::kv_line("Addr Mismatch", &helpers::nested_u64(data, "stats", "addr_mismatch")), + helpers::kv_line("Sig Failed", &helpers::nested_u64(data, "stats", "sig_failed")), + helpers::kv_line("Stale", &helpers::nested_u64(data, "stats", "stale")), + helpers::kv_line("Parent Switched", &helpers::nested_u64(data, "stats", "parent_switched")), + helpers::kv_line("Loop Detected", &helpers::nested_u64(data, "stats", "loop_detected")), + helpers::kv_line("Ancestry Changed", &helpers::nested_u64(data, "stats", "ancestry_changed")), + Line::from(""), + helpers::section_header("Outbound"), + helpers::kv_line("Sent", &helpers::nested_u64(data, "stats", "sent")), + helpers::kv_line("Rate Limited", &helpers::nested_u64(data, "stats", "rate_limited")), + helpers::kv_line("Send Failed", &helpers::nested_u64(data, "stats", "send_failed")), + Line::from(""), + helpers::section_header("Cumulative"), + helpers::kv_line("Parent Switches", &helpers::nested_u64(data, "stats", "parent_switches")), + helpers::kv_line("Parent Losses", &helpers::nested_u64(data, "stats", "parent_losses")), + helpers::kv_line("Flap Dampened", &helpers::nested_u64(data, "stats", "flap_dampened")), + ]; + + // Trim to fit available height + let max_lines = inner.height as usize; + lines.truncate(max_lines); + + frame.render_widget(Paragraph::new(lines), inner); +} + +fn draw_peers(frame: &mut Frame, data: &serde_json::Value, area: Rect) { + let peers = data + .get("peers") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let my_root = helpers::str_field(data, "root"); + + let count = peers.len(); + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" Tree Peers ({count}) ")); + let inner = block.inner(area); + frame.render_widget(block, area); + + if peers.is_empty() { + let msg = + Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, inner); + return; + } + + let lines: Vec = peers + .iter() + .map(|p| { + let name = helpers::str_field(p, "display_name"); + let has_depth = p.get("depth").is_some(); + + if !has_depth { + return Line::from(vec![ + Span::styled( + format!(" {name:<16}"), + Style::default().fg(Color::DarkGray), + ), + Span::styled("(no position)", Style::default().fg(Color::DarkGray)), + ]); + } + + let depth = helpers::u64_field(p, "depth"); + let dist = helpers::u64_field(p, "distance_to_us"); + let peer_root = helpers::str_field(p, "root"); + let (root_ind, root_color) = if peer_root == my_root { + ("same root", Color::Green) + } else { + ("diff root", Color::Red) + }; + + Line::from(vec![ + Span::styled( + format!(" {name:<16}"), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled("depth: ", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{depth:<4}")), + Span::styled("dist: ", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{dist:<4}")), + Span::styled(root_ind, Style::default().fg(root_color)), + ]) + }) + .collect(); + + frame.render_widget(Paragraph::new(lines), inner); +} diff --git a/src/config/node.rs b/src/config/node.rs index 2482978..f108d0f 100644 --- a/src/config/node.rs +++ b/src/config/node.rs @@ -375,7 +375,9 @@ impl ControlConfig { fn default_socket_path() -> String { if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { - format!("{}/fips/control.sock", runtime_dir) + format!("{runtime_dir}/fips/control.sock") + } else if std::fs::create_dir_all("/run/fips").is_ok() { + "/run/fips/control.sock".to_string() } else { "/tmp/fips-control.sock".to_string() } diff --git a/src/control/queries.rs b/src/control/queries.rs index b83583c..aefc177 100644 --- a/src/control/queries.rs +++ b/src/control/queries.rs @@ -3,6 +3,7 @@ //! Each function takes `&Node` and returns a `serde_json::Value`. //! Query logic is kept separate from socket handling. +use crate::identity::encode_npub; use crate::node::Node; use serde_json::{json, Value}; @@ -31,10 +32,17 @@ fn trend_label(short: f64, long: f64) -> &'static str { /// `show_status` — Node overview. pub fn show_status(node: &Node) -> Value { + let pid = std::process::id(); + let exe_path = std::env::current_exe() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "-".into()); + let uptime_secs = node.uptime().as_secs(); + let fwd = node.stats().snapshot().forwarding; + json!({ "npub": node.npub(), "node_addr": hex::encode(node.node_addr().as_bytes()), - "fips_address": format!("{}", node.identity().address()), + "ipv6_addr": format!("{}", node.identity().address()), "state": format!("{}", node.state()), "is_leaf_only": node.is_leaf_only(), "peer_count": node.peer_count(), @@ -43,7 +51,13 @@ pub fn show_status(node: &Node) -> Value { "transport_count": node.transport_count(), "connection_count": node.connection_count(), "tun_state": format!("{}", node.tun_state()), + "tun_name": node.tun_name().unwrap_or("-"), "effective_ipv6_mtu": node.effective_ipv6_mtu(), + "control_socket": &node.config().node.control.socket_path, + "pid": pid, + "exe_path": exe_path, + "uptime_secs": uptime_secs, + "forwarding": serde_json::to_value(&fwd).unwrap_or_default(), }) } @@ -57,7 +71,7 @@ pub fn show_peers(node: &Node) -> Value { "node_addr": addr_hex, "npub": peer.npub(), "display_name": node.peer_display_name(&node_addr), - "fips_address": format!("{}", peer.address()), + "ipv6_addr": format!("{}", peer.address()), "connectivity": format!("{}", peer.connectivity()), "link_id": peer.link_id().as_u64(), "authenticated_at_ms": peer.authenticated_at(), @@ -99,6 +113,17 @@ pub fn show_peers(node: &Node) -> Value { mmp_json["goodput_bps"] = json!(mmp.metrics.goodput_bps); mmp_json["delivery_ratio_forward"] = json!(mmp.metrics.delivery_ratio_forward); mmp_json["delivery_ratio_reverse"] = json!(mmp.metrics.delivery_ratio_reverse); + if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() { + mmp_json["smoothed_loss"] = json!(smoothed_loss); + } + if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() { + mmp_json["smoothed_etx"] = json!(smoothed_etx); + } + if let Some(srtt) = mmp.metrics.srtt_ms() { + if let Some(setx) = mmp.metrics.smoothed_etx() { + mmp_json["lqi"] = json!(setx * (1.0 + srtt / 100.0)); + } + } peer_json["mmp"] = mmp_json; } @@ -207,15 +232,44 @@ pub fn show_sessions(node: &Node) -> Value { "last_activity_ms": entry.last_activity(), }); + // Derive npub from session's remote public key + let (xonly, _parity) = entry.remote_pubkey().x_only_public_key(); + session_json["npub"] = json!(encode_npub(&xonly)); + + // Traffic counters + let (pkts_tx, pkts_rx, bytes_tx, bytes_rx) = entry.traffic_counters(); + session_json["stats"] = json!({ + "packets_sent": pkts_tx, + "packets_recv": pkts_rx, + "bytes_sent": bytes_tx, + "bytes_recv": bytes_rx, + }); + // Add session MMP if available if let Some(mmp) = entry.mmp() { let mut mmp_json = json!({ "mode": format!("{}", mmp.mode()), + "loss_rate": mmp.metrics.loss_rate(), + "etx": mmp.metrics.etx, + "goodput_bps": mmp.metrics.goodput_bps, + "delivery_ratio_forward": mmp.metrics.delivery_ratio_forward, + "delivery_ratio_reverse": mmp.metrics.delivery_ratio_reverse, + "path_mtu": mmp.path_mtu.current_mtu(), }); if let Some(srtt) = mmp.metrics.srtt_ms() { mmp_json["srtt_ms"] = json!(srtt); } - mmp_json["path_mtu"] = json!(mmp.path_mtu.current_mtu()); + if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() { + mmp_json["smoothed_loss"] = json!(smoothed_loss); + } + if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() { + mmp_json["smoothed_etx"] = json!(smoothed_etx); + } + if let Some(srtt) = mmp.metrics.srtt_ms() { + if let Some(setx) = mmp.metrics.smoothed_etx() { + mmp_json["sqi"] = json!(setx * (1.0 + srtt / 100.0)); + } + } session_json["mmp"] = mmp_json; } @@ -279,8 +333,17 @@ pub fn show_mmp(node: &Node) -> Value { "spin_bit_role": if mmp.spin_bit.is_initiator() { "initiator" } else { "responder" }, }); + if let Some(smoothed_loss) = metrics.smoothed_loss() { + link_layer["smoothed_loss"] = json!(smoothed_loss); + } + if let Some(smoothed_etx) = metrics.smoothed_etx() { + link_layer["smoothed_etx"] = json!(smoothed_etx); + } if let Some(srtt) = metrics.srtt_ms() { link_layer["srtt_ms"] = json!(srtt); + if let Some(setx) = metrics.smoothed_etx() { + link_layer["lqi"] = json!(setx * (1.0 + srtt / 100.0)); + } } // Trend indicators @@ -319,8 +382,17 @@ pub fn show_mmp(node: &Node) -> Value { "path_mtu": mmp.path_mtu.current_mtu(), }); + if let Some(smoothed_loss) = metrics.smoothed_loss() { + session_layer["smoothed_loss"] = json!(smoothed_loss); + } + if let Some(smoothed_etx) = metrics.smoothed_etx() { + session_layer["smoothed_etx"] = json!(smoothed_etx); + } if let Some(srtt) = metrics.srtt_ms() { session_layer["srtt_ms"] = json!(srtt); + if let Some(setx) = metrics.smoothed_etx() { + session_layer["sqi"] = json!(setx * (1.0 + srtt / 100.0)); + } } Some(json!({ diff --git a/src/mmp/metrics.rs b/src/mmp/metrics.rs index 7631b2a..7fa837f 100644 --- a/src/mmp/metrics.rs +++ b/src/mmp/metrics.rs @@ -24,6 +24,7 @@ pub struct MmpMetrics { pub loss_trend: DualEwma, pub goodput_trend: DualEwma, pub jitter_trend: DualEwma, + pub etx_trend: DualEwma, /// Forward delivery ratio (what fraction of our frames the peer received). pub delivery_ratio_forward: f64, @@ -54,6 +55,7 @@ impl MmpMetrics { loss_trend: DualEwma::new(), goodput_trend: DualEwma::new(), jitter_trend: DualEwma::new(), + etx_trend: DualEwma::new(), delivery_ratio_forward: 1.0, delivery_ratio_reverse: 1.0, etx: 1.0, @@ -106,6 +108,7 @@ impl MmpMetrics { let loss_rate = 1.0 - self.delivery_ratio_forward; self.loss_trend.update(loss_rate); self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse); + self.etx_trend.update(self.etx); } } @@ -146,6 +149,7 @@ impl MmpMetrics { pub fn set_delivery_ratio_reverse(&mut self, ratio: f64) { self.delivery_ratio_reverse = ratio.clamp(0.0, 1.0); self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse); + self.etx_trend.update(self.etx); } /// Current smoothed RTT in milliseconds, or `None` if not yet measured. @@ -162,6 +166,24 @@ impl MmpMetrics { 1.0 - self.delivery_ratio_forward } + /// Smoothed loss rate (long-term EWMA), or `None` if not yet initialized. + pub fn smoothed_loss(&self) -> Option { + if self.loss_trend.initialized() { + Some(self.loss_trend.long()) + } else { + None + } + } + + /// Smoothed ETX (long-term EWMA), or `None` if not yet initialized. + pub fn smoothed_etx(&self) -> Option { + if self.etx_trend.initialized() { + Some(self.etx_trend.long()) + } else { + None + } + } + /// Current smoothed goodput in bytes/sec, or 0 if not yet measured. pub fn goodput_bps(&self) -> f64 { self.goodput_bps diff --git a/src/node/handlers/session.rs b/src/node/handlers/session.rs index 00ba75d..890ecf5 100644 --- a/src/node/handlers/session.rs +++ b/src/node/handlers/session.rs @@ -263,11 +263,12 @@ impl Node { } } - // Only application data resets the idle timer — MMP reports - // (SenderReport, ReceiverReport, PathMtuNotification) do not. + // Only application data resets the idle timer and traffic counters — + // MMP reports (SenderReport, ReceiverReport, PathMtuNotification) do not. if msg_type == SessionMessageType::DataPacket.to_byte() && let Some(entry) = self.sessions.get_mut(src_addr) { + entry.record_recv(rest.len()); entry.touch(Self::now_ms()); } @@ -1026,6 +1027,7 @@ impl Node { // Re-borrow after send (which borrowed &mut self) if let Some(entry) = self.sessions.get_mut(dest_addr) { + entry.record_sent(plaintext.len()); if let Some(mmp) = entry.mmp_mut() { mmp.sender.record_sent(counter, timestamp, ciphertext.len()); } diff --git a/src/node/mod.rs b/src/node/mod.rs index 8c69011..d2db1bd 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -216,6 +216,9 @@ pub struct Node { /// Exchanged inside Noise handshake messages so peers can detect restarts. startup_epoch: [u8; 8], + /// Instant when the node was created, for uptime reporting. + started_at: std::time::Instant, + // === Configuration === /// Loaded configuration. config: Config, @@ -415,6 +418,7 @@ impl Node { Ok(Self { identity, startup_epoch, + started_at: std::time::Instant::now(), config, state: NodeState::Created, is_leaf_only, @@ -509,6 +513,7 @@ impl Node { Self { identity, startup_epoch, + started_at: std::time::Instant::now(), config, state: NodeState::Created, is_leaf_only: false, @@ -764,6 +769,11 @@ impl Node { self.state } + /// Get the node uptime. + pub fn uptime(&self) -> std::time::Duration { + self.started_at.elapsed() + } + /// Check if node is operational. pub fn is_running(&self) -> bool { self.state.is_operational() @@ -829,6 +839,11 @@ impl Node { self.tun_state } + /// Get the TUN interface name, if active. + pub fn tun_name(&self) -> Option<&str> { + self.tun_name.as_deref() + } + // === Resource Limits === diff --git a/src/node/session.rs b/src/node/session.rs index 264d78a..922a39d 100644 --- a/src/node/session.rs +++ b/src/node/session.rs @@ -73,6 +73,16 @@ pub(crate) struct SessionEntry { /// Session-layer MMP state. Initialized on Established transition. mmp: Option, + // === Traffic Counters === + /// Total data packets sent on this session. + packets_sent: u64, + /// Total data packets received on this session. + packets_recv: u64, + /// Total data bytes sent on this session (FSP payload). + bytes_sent: u64, + /// Total data bytes received on this session (FSP payload). + bytes_recv: u64, + // === Handshake Resend === /// Encoded session-layer payload for resend (SessionSetup or SessionAck). /// Cleared on Established transition. @@ -102,6 +112,10 @@ impl SessionEntry { coords_warmup_remaining: 0, is_initiator, mmp: None, + packets_sent: 0, + packets_recv: 0, + bytes_sent: 0, + bytes_recv: 0, handshake_payload: None, resend_count: 0, next_resend_at_ms: 0, @@ -219,6 +233,25 @@ impl SessionEntry { self.mmp = Some(MmpSessionState::new(config, self.is_initiator)); } + // === Traffic Counters === + + /// Record a sent data packet. + pub(crate) fn record_sent(&mut self, bytes: usize) { + self.packets_sent += 1; + self.bytes_sent += bytes as u64; + } + + /// Record a received data packet. + pub(crate) fn record_recv(&mut self, bytes: usize) { + self.packets_recv += 1; + self.bytes_recv += bytes as u64; + } + + /// Get traffic counters: (packets_sent, packets_recv, bytes_sent, bytes_recv). + pub(crate) fn traffic_counters(&self) -> (u64, u64, u64, u64) { + (self.packets_sent, self.packets_recv, self.bytes_sent, self.bytes_recv) + } + // === Handshake Resend === /// Store the encoded session-layer payload for potential resend. diff --git a/testing/chaos/sim/control.py b/testing/chaos/sim/control.py index c1628a8..41cf6ed 100644 --- a/testing/chaos/sim/control.py +++ b/testing/chaos/sim/control.py @@ -16,9 +16,9 @@ from .topology import SimTopology log = logging.getLogger(__name__) -# Default control socket path inside containers (XDG_RUNTIME_DIR is -# typically unset in Docker, so fips falls back to /tmp). -CONTROL_SOCKET = "/tmp/fips-control.sock" +# Default control socket path inside containers (running as root, +# so /run/fips/ is created by the daemon). +CONTROL_SOCKET = "/run/fips/control.sock" def query_node(container: str, command: str, timeout: int = 10) -> dict | None: diff --git a/testing/sidecar/.gitignore b/testing/sidecar/.gitignore index fd9c1ee..4d658a8 100644 --- a/testing/sidecar/.gitignore +++ b/testing/sidecar/.gitignore @@ -1,2 +1,3 @@ fips fipsctl +fipstop diff --git a/testing/sidecar/Dockerfile b/testing/sidecar/Dockerfile index dda07d6..9a131ba 100644 --- a/testing/sidecar/Dockerfile +++ b/testing/sidecar/Dockerfile @@ -1,4 +1,4 @@ -FROM debian:bookworm-slim +FROM debian:trixie-slim RUN apt-get update && \ apt-get install -y --no-install-recommends \ @@ -25,8 +25,8 @@ RUN printf '%s\n' \ 'no-resolv' \ >> /etc/dnsmasq.conf -COPY fips fipsctl /usr/local/bin/ -RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl +COPY fips fipsctl fipstop /usr/local/bin/ +RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl /usr/local/bin/fipstop COPY entrypoint.sh /entrypoint.sh diff --git a/testing/sidecar/Dockerfile.app b/testing/sidecar/Dockerfile.app index 302cdb0..9ec0940 100644 --- a/testing/sidecar/Dockerfile.app +++ b/testing/sidecar/Dockerfile.app @@ -1,4 +1,4 @@ -FROM debian:bookworm-slim +FROM debian:trixie-slim RUN apt-get update && \ apt-get install -y --no-install-recommends \ diff --git a/testing/sidecar/docker-compose.yml b/testing/sidecar/docker-compose.yml index 7a95790..a149491 100644 --- a/testing/sidecar/docker-compose.yml +++ b/testing/sidecar/docker-compose.yml @@ -26,6 +26,7 @@ services: - FIPS_PEER_ALIAS=${FIPS_PEER_ALIAS:-peer} - FIPS_UDP_BIND=${FIPS_UDP_BIND:-0.0.0.0:2121} - FIPS_TUN_MTU=${FIPS_TUN_MTU:-1280} + - FIPS_PEER_TRANSPORT=${FIPS_PEER_TRANSPORT:-udp} volumes: - ./resolv.conf:/etc/resolv.conf:ro networks: diff --git a/testing/sidecar/entrypoint.sh b/testing/sidecar/entrypoint.sh index 1cac02f..bcfd114 100755 --- a/testing/sidecar/entrypoint.sh +++ b/testing/sidecar/entrypoint.sh @@ -7,6 +7,7 @@ set -e FIPS_NSEC="${FIPS_NSEC:?FIPS_NSEC is required}" FIPS_UDP_BIND="${FIPS_UDP_BIND:-0.0.0.0:2121}" FIPS_TUN_MTU="${FIPS_TUN_MTU:-1280}" +FIPS_PEER_TRANSPORT="${FIPS_PEER_TRANSPORT:-udp}" mkdir -p /etc/fips @@ -17,7 +18,7 @@ if [ -n "$FIPS_PEER_NPUB" ] && [ -n "$FIPS_PEER_ADDR" ]; then PEERS_SECTION=" - npub: \"${FIPS_PEER_NPUB}\" alias: \"${FIPS_PEER_ALIAS}\" addresses: - - transport: udp + - transport: ${FIPS_PEER_TRANSPORT} addr: \"${FIPS_PEER_ADDR}\" connect_policy: auto_connect" fi @@ -40,6 +41,7 @@ transports: udp: bind_addr: "${FIPS_UDP_BIND}" mtu: 1472 + tcp: {} peers: ${PEERS_SECTION:- []} @@ -61,6 +63,8 @@ iptables -A OUTPUT -o eth0 -p udp --dport 2121 -j ACCEPT iptables -A OUTPUT -o eth0 -p udp --sport 2121 -j ACCEPT iptables -A INPUT -i eth0 -p udp --dport 2121 -j ACCEPT iptables -A INPUT -i eth0 -p udp --sport 2121 -j ACCEPT +iptables -A OUTPUT -o eth0 -p tcp --dport 443 -j ACCEPT +iptables -A INPUT -i eth0 -p tcp --sport 443 -j ACCEPT iptables -A OUTPUT -o eth0 -j DROP iptables -A INPUT -i eth0 -j DROP diff --git a/testing/sidecar/scripts/build.sh b/testing/sidecar/scripts/build.sh index f5487f9..35a5335 100755 --- a/testing/sidecar/scripts/build.sh +++ b/testing/sidecar/scripts/build.sh @@ -38,6 +38,7 @@ if [ "$UNAME_S" = "Darwin" ]; then echo "Copying binaries to docker context..." cp "$PROJECT_ROOT/target/$CARGO_TARGET/release/fips" "$DOCKER_DIR/fips" cp "$PROJECT_ROOT/target/$CARGO_TARGET/release/fipsctl" "$DOCKER_DIR/fipsctl" + cp "$PROJECT_ROOT/target/$CARGO_TARGET/release/fipstop" "$DOCKER_DIR/fipstop" else echo "Building FIPS (release)..." cargo build --release --manifest-path="$PROJECT_ROOT/Cargo.toml" @@ -45,9 +46,10 @@ else echo "Copying binaries to docker context..." cp "$PROJECT_ROOT/target/release/fips" "$DOCKER_DIR/fips" cp "$PROJECT_ROOT/target/release/fipsctl" "$DOCKER_DIR/fipsctl" + cp "$PROJECT_ROOT/target/release/fipstop" "$DOCKER_DIR/fipstop" fi -echo "Done. Binaries at $DOCKER_DIR/{fips,fipsctl}" +echo "Done. Binaries at $DOCKER_DIR/{fips,fipsctl,fipstop}" echo "" echo "Building Docker image..." docker compose -f "$DOCKER_DIR/docker-compose.yml" build