From 0322308896763ee579bb278646d6dbf1bd3a1695 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Fri, 21 Aug 2026 12:45:10 -0700 Subject: [PATCH] housekeeping-p2: security, correctness, and performance pass before 1.51 (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: dead-code sweep - Delete socktop_connector/src/connector.rs: orphaned since 08f248c removed 'pub mod connector;' during the modularization refactor. Never compiled (verified under default, wasm, and workspace feature combos) but shipped in the crates.io tarball and contained an outdated copy of the TLS verifier — a trap for anyone patching the pinning bug in the dead copy. - Delete empty socktop/src/ws.rs, tracked editor backup ui/.modal.rs.backup, and stray test_thiserror.rs at the repo root. - Delete the two LEGACY #[allow(dead_code)] process input handlers; the header-click render test now exercises the live _with_selection handler instead (better coverage of the real path). - Drop unused sysinfo dependency from the socktop client. - Replace stale 'temporarily increased for testing' comment on COMPRESSION_THRESHOLD (it already held the production value). Co-Authored-By: Claude Fable 5 * fix(connector): make certificate pinning real; disable Nagle Security: with --verify-hostname off (the default), the old NoVerify verifier accepted ANY server certificate — the CA loaded from --tls-ca was never consulted, so the documented pinning was a no-op and the connection was trivially MITM-able. Replace it with PinnedCertVerifier: the presented end-entity cert must be byte-identical to a cert in the --tls-ca file (any cert in a multi-cert PEM matches, supporting rotation). Signature validation now uses the ring provider's full algorithm set instead of a hardcoded 3-scheme list. Empty PEM files fail fast instead of failing closed per-handshake. The --verify-hostname path is unchanged (WebPki root-store validation). Also: the third argument of connect_async_tls_with_config is tungstenite's disable_nagle flag, not a verification toggle — we were passing verify_hostname there, leaving Nagle ON for default users. Pass true unconditionally, and disable Nagle on the plain ws:// path too; socktop exchanges small request/response frames where Nagle only adds latency. Client now consumes the connector via a dual path+version dep so these fixes are in local builds and CI before the crates.io publish (cargo strips the path on publish). Connector version -> 1.51.0. Verified E2E: agent A's cert connects to agent A; agent B's cert against agent A fails the handshake (the rpi-worker-1 wrong-PEM scenario); --verify-hostname against a 127.0.0.1 SAN still connects. Co-Authored-By: Claude Fable 5 * fix(agent): GPU worker thread, async journalctl, correctness + cache fixes Lightweight: - GPU collection moves to a dedicated worker thread that owns the gfxinfo handle for the process lifetime. gfxinfo's active_gpu() runs a full NVML init/teardown (~20ms, blocking) and we were paying it on the async runtime for every collect — measured at ~80% of the agent's entire active CPU on a GPU machine. The handle holds Rc (not Send), so a thread + mpsc/oneshot channel pair confines it; a zero-total-VRAM reply is treated as a dead session (driver reload) and re-probed. - journalctl now runs via tokio::process instead of blocking one of the two runtime workers for the duration of the subprocess. - TtlCell (state.rs) replaces the four hand-rolled static TTL caches; a cached negative result now counts as fresh, so hosts with no matching temp sensor or GPU stop rescanning every request. Single lock+clone on the GPU cache hit path (was two). Correctness: - Process/child CPU times are now microseconds as documented; they were milliseconds, rendering 1000x too small next to (correct) thread times. - Non-Linux per-process CPU%% clamps AFTER dividing by core count; a 4-cores-busy process on an 8-core box reported 12.5% instead of 50%. - Journal timestamps are real RFC 3339 UTC plus an additive timestamp_us field (sorting is now numeric); the old strings were Debug-formatted SystemTime mangled by string replace. - Partition detection uses /sys/block on Linux: whole-disk filesystems on names like nvme0n1 or zram1 are no longer misclassified as partitions. One shared parent_disk_name() replaces two inline copies. - New sampled_at_ms on the metrics payload (additive) records when the snapshot was actually collected, so clients can compute exact rates across the agent's TTL cache. Security/robustness: - key.pem is created 0600 (was umask default 0644, world-readable) and pre-1.51 keys are tightened on startup. - Per-PID detail/journal caches now evict (60s max age, 64 entries max); they previously grew without bound under PID-walking clients. - The two per-PID ws handlers collapse into one generic helper. - /proc//stat parsing unified in one comm-safe module. Co-Authored-By: Claude Fable 5 * fix(tui): responsive input, request timeouts, poisoned-stream reconnect R1 — input latency: the event loop drained input once per iteration, then slept the whole metrics interval; keys and wheel events queued for up to 500ms (or the full interval at slower rates) and applied in bursts. The input block is extracted to drain_input() and the tail sleep replaced by a deadline wait in <=33ms poll slices that handles and repaints input the moment it arrives. Verified: help modal opens <150ms into a 2000ms tick. R2 — freeze-proofing: metrics/processes/disks requests had no timeout; a half-dead connection left ws.next() pending forever and froze the TUI with no way to quit (raw mode eats Ctrl+C as an unread key event). All requests now carry a 5s budget. C3 — desync: replies are matched to requests by order alone, so a timed- out request's late reply would shift every subsequent reply off by one. Any timeout now treats the stream as poisoned and goes through the reconnect flow — a fresh stream is aligned by construction. The modal endpoints additionally mark process details unsupported (flag resets on modal close/selection change) so a detail-less agent doesn't cause a reconnect loop. While disconnected the fetch path idles: recovery belongs to the manual/auto retry paths instead of 5s-timeout hammering. C7 — fit::truncate_middle_cols replaces util::truncate_middle: display- width aware and char-boundary safe; the byte-slicing version panicked the draw loop on non-ASCII device names. Verified live: agent kill -9 mid-session -> error modal in <3s, q exits while disconnected, r reconnects and resumes. Co-Authored-By: Claude Fable 5 * chore: version 1.51.0, path-dep the wasm examples, README notes - socktop, socktop_agent, socktop_connector -> 1.51.0. - socktop_wasm_test and zellij_socktop_plugin consume the in-repo connector via path deps so wasm-feature API drift is caught at PR time instead of after publish. Immediately proved out: the wasm requests module needed the new sampled_at_ms field, invisible to native builds. - zellij plugin gains the standalone [workspace] marker (it could not be cargo-checked in-tree at all before). NOTE: its lib.rs has pre-existing compile errors unrelated to the connector (static mut STATE conflicts with register_plugin!, missing BTreeMap import) — needs its own rework, out of scope here. - README: sampled_at_ms in the example payload. Co-Authored-By: Claude Fable 5 * fix(review): restore Agent Update Required flow, command field, axis alignment Fixes from Jason's hands-on verification of the branch: 1. Old-agent messaging regression (this branch): a detail-request timeout went through the loud poison/reconnect flow, burying the ProcessDetails modal's 'Agent Update Required' message under a connection-error modal. Old agents IGNORE unknown messages (no late reply, no desync), so the optional per-PID endpoints now use quiet_reconnect(): swap the stream silently (still safe against merely-slow agents) and let the modal show its message. Only a failed reconnect surfaces loudly. Verified against a real v1.40.0 agent: message shows, session stays healthy. 2. Draw starvation (this branch): an agent that never answers get_metrics put the loop in fetch->timeout->poison->restart cycles that never reached the draw call — permanently blank TUI. The iteration now paints before fetching, and a second consecutive metrics timeout trips a circuit breaker: persistent 'Agent is not responding' error, recovery left to the manual/30s retry paths. Verified against a 0.9-era agent. 3. Command & Details pane blank (pre-existing on master): the minimal- refresh optimization dropped cmd/exe/cwd from the detail endpoint's ProcessRefreshKind, so process.cmd() had nothing to return. Restored with UpdateKind::OnlyIfNotSet — immutable values, read once per PID. Regression test added; journal E2E re-verified (100 entries render). 4. Scatter-plot axis misalignment: Y labels used a fixed 4-char field from the era when CPU times were 1000x too small; honest millisecond values (e.g. 136114) blew through it. Labels now right-align to the widest value per frame and X labels/titles share the dynamic padding. Co-Authored-By: Claude Fable 5 * feat: journal access notice, 1.60.0, install script, changelog, riscv protoc fallback - Journal pane now distinguishes 'no entries' from 'no journal access': journalctl exits 0 with empty output when the agent's user simply can't see the target's entries (demo mode / user-run agents), explaining itself only on stderr. The agent forwards that hint as an additive JournalResponse.notice and the client renders it with practical advice. Verified E2E via a stub journalctl emulating the unprivileged case. - Version 1.60.0 across all crates (1.51 would read fine, but the repo's scheme is 1.40/1.50/…, and a literal 1.6.0 would sort BELOW 1.50.0 in semver). All user-facing version strings already come from CARGO_PKG_VERSION — a stale binary was the only way to see an old one. - scripts/install.sh: build-from-source install/upgrade for the test fleet (Linux + macOS). Detects in-repo checkouts, installs rustup when missing, replaces a systemd socktop-agent service binary in place and restarts it, requires system protoc on riscv64. - build.rs (agent + connector): fall back to $PROTOC / PATH when protoc-bin-vendored has no binary for the host (riscv64) — native SBC builds previously panicked in the build script. - CHANGELOG.md covering v1.50.0 -> 1.60.0. Co-Authored-By: Claude Fable 5 * test: add notice field to cache test initializer Co-Authored-By: Claude Fable 5 * fix: untrack zellij plugin build dir; installer updates all PATH copies - Remove zellij_socktop_plugin/target from git (3,577 files committed by accident in bf6ac87): the root .gitignore anchors /target to the repo root, so the standalone plugin's own build dir wasn't covered. Ignore target/ at any depth (also fixes the pre-existing '/socktop-wasm-test/target' entry, which pointed at a hyphenated path that doesn't exist). - install.sh now updates EVERY copy of socktop/socktop_agent on PATH, not just $PREFIX: a stale 'cargo install' in ~/.cargo/bin shadows /usr/local/bin on most PATHs, so an install could 'succeed' while 'socktop --version' kept reporting the old release. Extra copies that can't be written are warned about, not fatal, and the script now prints which binary is actually active on PATH at the end. Co-Authored-By: Claude Fable 5 * feat(installer): manage the socktop-agent systemd service Upgrade path (unit already present): NEVER touch the unit file — it is the operator's config (SSL, tokens, ports live there as Environment= lines). Only the binary at the unit's own ExecStart path is replaced, then the service restarts. Flags/args preserved by construction. Fresh path (no unit): full first-time setup mirroring the deb postinst and the agent-service docs — create the socktop system user/group and /var/lib/socktop, install docs/socktop-agent.service (ExecStart rewritten to wherever this run installed the agent; embedded fallback for old refs), daemon-reload, enable --now, and print how to turn on TLS/token. Also: system-level operations get their own sudo decision (SYS_SUDO) — previously they inherited the PREFIX sudo flag, so a writable --prefix made the service section run groupadd/systemctl unprivileged and die. No sudo at all now skips service management with a warning instead of failing the install. Both branches dry-run verified with stubbed systemctl/sudo. Co-Authored-By: Claude Fable 5 * fix(installer): don't bind fresh agent services onto occupied ports The Orange Pi install put the new service straight into a crash-restart loop: the unit's default --port 3000 collided with a Docker service already publishing 3000 (Umami; Gitea and friends default there too). Fresh installs now scan 3000/3001/3010/3231/3232 via ss and configure the unit on the first free port, warning loudly when 3000 was taken and printing the resulting ws:// URL. Upgrades still never touch the unit. Co-Authored-By: Claude Fable 5 * fix(agent): detect NVIDIA GPUs on distros without the unversioned NVML soname On Debian and derivatives the NVIDIA driver ships only libnvidia-ml.so.1 (the unversioned symlink belongs to the dev package), and nvml-wrapper's default init dlopens the unversioned name — so gfxinfo reported 'No GPU found' on a fully functional RTX A2000 host while nvidia-smi worked fine. Arch-family distros ship the symlink, which is why the desktop never showed this. The GPU worker now falls back to initializing NVML directly with the versioned soname when gfxinfo's probe fails, collecting name/util/vram through the same handle-caching path. nvml-wrapper was already in the tree via gfxinfo — same version, no new build cost. Co-Authored-By: Claude Fable 5 * fix(agent): box the NVML handle variant (clippy large_enum_variant) CI clippy runs with -D warnings; Nvml is a large struct next to the 16-byte Box variant. Co-Authored-By: Claude Fable 5 * fix(installer): survive self-modification mid-run; sturdier unit detection Root cause of the mixed-up second install on the A2000 host: when run from the clone it manages, the script's own git checkout/merge REPLACES scripts/install.sh while bash is still executing it. Bash reads scripts lazily by byte offset, so it resumed parsing the NEW file at the OLD offset and executed an arbitrary tail of it — observed as the fresh- service path running on a host whose unit already existed: the port scan saw the still-running old service on 3000 and silently wrote a new unit on 3001, while enable --now on the already-active service changed nothing until a manual daemon-reload. Fix: the whole script now runs inside main(), invoked as 'main "$@"; exit $?' so bash parses everything up front and never reads the file again after main returns (the exit lives in the same parse unit — demonstrated necessary: with a bare 'main "$@"' ending, bash still executed the swapped file's trailing content after main returned). Also: unit existence is now checked with 'systemctl cat' instead of grepping the full list-unit-files output. Co-Authored-By: Claude Fable 5 * docs(installer): use a durable ref in the usage example Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .gitignore | 5 +- CHANGELOG.md | 57 + Cargo.lock | 31 +- README.md | 3 +- scripts/install.sh | 246 + socktop/Cargo.toml | 5 +- socktop/src/app.rs | 1132 +++-- socktop/src/ui/.modal.rs.backup | 1849 ------- socktop/src/ui/cpu.rs | 1 + socktop/src/ui/disks.rs | 5 +- socktop/src/ui/fit.rs | 57 + socktop/src/ui/gpu.rs | 1 + socktop/src/ui/modal_process.rs | 65 +- socktop/src/ui/processes.rs | 112 +- socktop/src/ui/util.rs | 13 - socktop/src/ws.rs | 0 socktop/tests/profiles.rs | 1 + socktop_agent/Cargo.toml | 17 +- socktop_agent/build.rs | 10 +- socktop_agent/src/gpu.rs | 127 +- socktop_agent/src/metrics.rs | 540 +- socktop_agent/src/state.rs | 51 +- socktop_agent/src/tls.rs | 22 +- socktop_agent/src/types.rs | 12 +- socktop_agent/src/ws.rs | 154 +- socktop_agent/tests/cache_tests.rs | 1 + socktop_agent/tests/process_details.rs | 20 +- socktop_connector/Cargo.toml | 2 +- socktop_connector/build.rs | 12 +- socktop_connector/src/connector.rs | 1152 ----- .../src/networking/connection.rs | 196 +- socktop_connector/src/types.rs | 8 + socktop_connector/src/wasm/requests.rs | 1 + socktop_wasm_test/Cargo.lock | 4 +- socktop_wasm_test/Cargo.toml | 4 +- test_thiserror.rs | 0 zellij_socktop_plugin/Cargo.lock | 4384 +++++++++++++++++ zellij_socktop_plugin/Cargo.toml | 5 +- 38 files changed, 6243 insertions(+), 4062 deletions(-) create mode 100644 CHANGELOG.md create mode 100755 scripts/install.sh delete mode 100644 socktop/src/ui/.modal.rs.backup delete mode 100644 socktop/src/ws.rs delete mode 100644 socktop_connector/src/connector.rs delete mode 100644 test_thiserror.rs create mode 100644 zellij_socktop_plugin/Cargo.lock diff --git a/.gitignore b/.gitignore index 15914e0..5334ce8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ -/target +# Any crate's build directory, including standalone sub-crates +# (zellij_socktop_plugin, socktop_wasm_test) that live outside the workspace. +target/ .vscode/ -/socktop-wasm-test/target /.cargo/ # Documentation files from development sessions (context-specific, not for public repo) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6b035eb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +## 1.60.0 — unreleased + +Everything since `v1.50.0`. Applies to all three crates (`socktop`, `socktop_agent`, `socktop_connector`), which move to 1.60.0 together. + +### Security + +- **Certificate pinning is now real.** With `--verify-hostname` off (the default), the client previously accepted *any* server certificate — the `--tls-ca` file was never consulted. The presented certificate must now be byte-identical to one in the pinned PEM (multi-cert files supported for rotation). If you use TLS, update the client: earlier versions are MITM-able despite the pinning documentation. (housekeeping-p2) +- `key.pem` is created with mode 0600 (was world-readable 0644); agents also tighten existing keys on startup. (housekeeping-p2) +- The agent's per-PID caches now evict (60s age / 64 entries); previously they grew without bound. (housekeeping-p2) + +### Performance + +- Agent CPU on GPU machines cut ~6× (measured 23.5 → 4.0 ms/s at default polling): GPU collection moved to a dedicated worker thread that keeps the NVML session open instead of re-initializing it every 1.5 s on the async runtime. (housekeeping-p2) +- `journalctl` no longer blocks the agent's async workers. (housekeeping-p2) +- Cached "no temp sensor / no GPU" results count as fresh — no more per-request rescans on hosts without them. (housekeeping-p2) +- Nagle disabled on all connection paths (small request/response frames). (housekeeping-p2) + +### TUI + +- **Compact layout for small windows**: when the window is too short for the Disks pane, Disks is dropped, Memory/Swap go side by side, GPU collapses to one line (omitted if absent), and the reclaimed rows keep the CPU graph and per-core bars visible. `--compact` pins it. (#37) +- **Width-aware text**: header, CPU title, and process table shed detail by priority as the terminal narrows instead of overwriting each other; process Name column is now the last to go, not the first. Fixed sort-header clicks landing up to 4 columns off. (#38) +- **Responsive input**: keys and mouse are handled within ~30 ms instead of queueing for a full metrics interval. (housekeeping-p2) +- **No more freezes**: all requests carry a 5 s timeout; a dead connection shows the reconnect modal (with working `q`) instead of hanging the UI. Consecutive timeouts surface a persistent "agent not responding" error. (housekeeping-p2) +- Old agents without the per-process endpoints once again show "Agent Update Required" instead of a reconnect loop. (housekeeping-p2) +- Journal pane distinguishes "no entries" from "no journal access" (e.g. user-run/demo agents) and shows journalctl's hint plus the fix. (housekeeping-p2) +- Scatter-plot axes align correctly for large CPU-time values. (housekeeping-p2) +- Demo mode explains how to install `socktop_agent` when the binary is missing. (#36) + +### Correctness + +- Process/child CPU times were sent as ms but displayed as µs — values rendered 1000× too small in the details modal. (housekeeping-p2) +- Non-Linux per-process CPU% no longer truncates multi-core usage (clamp after divide). (housekeeping-p2) +- Journal timestamps are real RFC 3339 UTC with numeric sorting (additive `timestamp_us`). (housekeeping-p2) +- Partition detection uses `/sys/block` on Linux — whole-disk filesystems (`nvme0n1`, `zram1`) are no longer misclassified as partitions. (housekeeping-p2) +- Network rates use agent-side sample timestamps (additive `sampled_at_ms`), eliminating rate sawtooth from TTL-cached snapshots; falls back to the client clock with older agents. (housekeeping-p2) +- The details modal's Command/exe/cwd fields are populated again (dropped by an earlier refresh optimization). (housekeeping-p2) +- Non-ASCII device names no longer panic the disk pane. (housekeeping-p2) + +### Wire format (additive only — old/new client-agent pairs keep working) + +- `Metrics.sampled_at_ms` (epoch ms of actual collection) +- `JournalEntry.timestamp_us` (epoch µs), `JournalEntry.timestamp` now RFC 3339 +- `JournalResponse.notice` (journal-access hint) + +### Internal / packaging + +- ratatui 0.28 → 0.30 (#33); aws-lc-rs advisories patched (#34); Debian packaging for the agent (#25); assorted dependabot bumps. +- ~3,100 lines of dead code removed, including an orphaned pre-refactor copy of the connector. +- `socktop` consumes `socktop_connector` via a path+version dep — connector changes are testable in-repo before publishing. +- wasm examples build against the in-repo connector; note `zellij_socktop_plugin` has pre-existing compile errors and needs its own rework. + +### Upgrade notes + +- **Release/publish order**: `socktop_connector` → `socktop` → agent packages. +- Clients older than 1.60 work against 1.60 agents and vice versa; the security fix is client-side, so prioritize client updates where TLS is used. diff --git a/Cargo.lock b/Cargo.lock index b48e4a5..7bfa0b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2412,7 +2412,7 @@ dependencies = [ [[package]] name = "socktop" -version = "1.50.0" +version = "1.60.0" dependencies = [ "anyhow", "assert_cmd", @@ -2422,8 +2422,7 @@ dependencies = [ "ratatui", "serde", "serde_json", - "socktop_connector 1.50.0 (registry+https://github.com/rust-lang/crates.io-index)", - "sysinfo", + "socktop_connector", "tempfile", "tokio", "unicode-width", @@ -2432,7 +2431,7 @@ dependencies = [ [[package]] name = "socktop_agent" -version = "1.50.2" +version = "1.60.0" dependencies = [ "anyhow", "assert_cmd", @@ -2442,6 +2441,7 @@ dependencies = [ "futures-util", "gfxinfo", "hostname", + "nvml-wrapper", "once_cell", "prost", "prost-build", @@ -2463,7 +2463,7 @@ dependencies = [ [[package]] name = "socktop_connector" -version = "1.50.0" +version = "1.60.0" dependencies = [ "flate2", "futures-util", @@ -2484,27 +2484,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "socktop_connector" -version = "1.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61ea6a5733e71da6d5c94d23265b85f7041305bca51e6c33e7104464444047bc" -dependencies = [ - "flate2", - "futures-util", - "prost", - "prost-build", - "protoc-bin-vendored", - "rustls", - "rustls-pemfile", - "serde", - "serde_json", - "thiserror 2.0.17", - "tokio", - "tokio-tungstenite 0.24.0", - "url", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/README.md b/README.md index 4a44831..4dd9361 100644 --- a/README.md +++ b/README.md @@ -416,6 +416,7 @@ Tip: If only the binary changed, restart is enough. If the unit file changed, ru ```json { + "sampled_at_ms": 1786752000123, "cpu_total": 12.4, "cpu_per_core": [11.2, 15.7], "mem_total": 33554432, @@ -475,7 +476,7 @@ socktop --tls-ca /path/to/agent/cert.pem wss://HOST:8443/ws Notes: - Do not copy the private key off the server; only the cert.pem is needed by clients. - When --tls-ca/-t is supplied, the client auto‑upgrades ws:// to wss:// to avoid protocol mismatch. -- Hostname (SAN) verification is DISABLED by default (the cert is still pinned). Use `--verify-hostname` to enable strict SAN checking. +- Hostname (SAN) verification is DISABLED by default; instead the client PINS the certificate: the agent must present a cert byte-identical to one in your `--tls-ca` file (expiry is ignored in this mode — you pinned that exact cert). Use `--verify-hostname` to switch to strict chain + SAN validation instead. - You can run multiple clients with different cert paths by passing --tls-ca per invocation. --- diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..db4862d --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +# Build socktop + socktop_agent from source and install them. +# +# Works on Linux (x86_64, arm64/armv7, riscv64) and macOS. Handles fresh +# installs and upgrades; if a systemd socktop-agent service is present, its +# binary is replaced in place and the service restarted. +# +# ./scripts/install.sh # build HEAD of the repo you're in +# ./scripts/install.sh --ref v1.60.0 # build a tag/branch (clones if needed) +# ./scripts/install.sh --ref master # or any branch +# ./scripts/install.sh --prefix ~/.local/bin --no-service +# +set -euo pipefail + +REPO_URL="https://github.com/jasonwitty/socktop.git" +REF="" +PREFIX="" +NO_SERVICE=0 +SRC_DIR="${SOCKTOP_SRC_DIR:-$HOME/.cache/socktop-src}" + +while [ $# -gt 0 ]; do + case "$1" in + --ref) REF="$2"; shift 2 ;; + --prefix) PREFIX="$2"; shift 2 ;; + --no-service) NO_SERVICE=1; shift ;; + -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +say() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +# The entire remainder runs inside main(), invoked on the LAST line. This +# makes the script safe against being MODIFIED WHILE RUNNING: when executed +# from the clone it manages, the git checkout below replaces this very file, +# and bash reads scripts lazily by byte offset — without this wrapper it +# resumes parsing the NEW file at the OLD offset and executes an arbitrary +# tail of it (observed: the fresh-service path ran on a host whose unit +# already existed). With main(), the whole script is parsed before any of +# it executes. +main() { + +OS="$(uname -s)" +ARCH="$(uname -m)" + +# ---------- toolchain ---------- +command -v git >/dev/null || die "git is required" +if ! command -v cargo >/dev/null; then + # rustup may be installed but not on PATH in this shell + [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" +fi +if ! command -v cargo >/dev/null; then + say "Rust toolchain not found — installing via rustup (stable, default profile)" + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + . "$HOME/.cargo/env" +fi +command -v cc >/dev/null || warn "no C compiler found (apt: build-essential / brew: xcode-select --install) — the build may fail" +case "$ARCH" in + riscv64*) + # protoc-bin-vendored ships no riscv64 binary; the build falls back to + # the system protoc (see build.rs). + command -v protoc >/dev/null || die "riscv64 needs a system protoc: sudo apt install protobuf-compiler" + ;; +esac + +# ---------- source ---------- +# If run from inside a socktop checkout and no --ref given, build that tree +# as-is (whatever is checked out, including local changes). +if [ -z "$REF" ] && git rev-parse --show-toplevel >/dev/null 2>&1 \ + && grep -qs '^name = "socktop"' "$(git rev-parse --show-toplevel)/socktop/Cargo.toml" 2>/dev/null; then + SRC_DIR="$(git rev-parse --show-toplevel)" + say "Building the current checkout: $SRC_DIR ($(git -C "$SRC_DIR" describe --always --dirty 2>/dev/null))" +else + REF="${REF:-master}" + if [ ! -d "$SRC_DIR/.git" ]; then + say "Cloning $REPO_URL -> $SRC_DIR" + git clone "$REPO_URL" "$SRC_DIR" + fi + say "Checking out $REF" + git -C "$SRC_DIR" fetch --tags origin + git -C "$SRC_DIR" checkout -q "$REF" + # fast-forward when REF is a branch + git -C "$SRC_DIR" merge --ff-only "origin/$REF" >/dev/null 2>&1 || true +fi + +# ---------- build ---------- +say "Building release binaries (this can take a while on SBCs)" +( cd "$SRC_DIR" && cargo build --release -p socktop -p socktop_agent ) +CLIENT="$SRC_DIR/target/release/socktop" +AGENT="$SRC_DIR/target/release/socktop_agent" + +# ---------- install ---------- +if [ -z "$PREFIX" ]; then + PREFIX="/usr/local/bin" +fi +SUDO="" +if [ ! -w "$PREFIX" ]; then + if command -v sudo >/dev/null; then SUDO="sudo"; else + PREFIX="$HOME/.local/bin"; mkdir -p "$PREFIX" + warn "no sudo — installing to $PREFIX (ensure it is on your PATH)" + fi +fi +say "Installing to $PREFIX" +$SUDO install -m 755 "$CLIENT" "$PREFIX/socktop" +$SUDO install -m 755 "$AGENT" "$PREFIX/socktop_agent" + +# Update every other copy on PATH as well. A stale `cargo install` in +# ~/.cargo/bin would otherwise SHADOW the fresh binary (~/.cargo/bin +# usually precedes /usr/local/bin on PATH), leaving `socktop --version` +# stuck on the old release after a "successful" install. +update_path_copies() { + local name="$1" src="$2" copy dir + # type -ap lists every match on PATH (bash builtin, symlinks not resolved) + for copy in $(type -ap "$name" | sort -u); do + [ "$copy" = "$PREFIX/$name" ] && continue + dir="$(dirname "$copy")" + say "Updating additional copy on PATH: $copy" + if [ -w "$copy" ] || [ -w "$dir" ]; then + install -m 755 "$src" "$copy" + else + # Non-fatal: an un-updatable extra copy shouldn't kill the install, + # but the user must know it may shadow the fresh binary. + $SUDO install -m 755 "$src" "$copy" || warn "could not update $copy — it may shadow $PREFIX/$name" + fi + done +} +update_path_copies socktop "$CLIENT" +update_path_copies socktop_agent "$AGENT" + +# ---------- systemd service (Linux only) ---------- +# System-level operations (unit files, users, service control) need root no +# matter where the binaries were installed — decide independently of PREFIX. +SYS_SUDO="" +if [ "$(id -u)" -ne 0 ]; then + if command -v sudo >/dev/null; then SYS_SUDO="sudo"; else SYS_SUDO="__none__"; fi +fi +if [ "$SYS_SUDO" = "__none__" ] && [ "$NO_SERVICE" -eq 0 ]; then + warn "no sudo available — skipping systemd service management" + NO_SERVICE=1 +fi +if [ "$OS" = "Linux" ] && [ "$NO_SERVICE" -eq 0 ] && command -v systemctl >/dev/null; then + if systemctl cat socktop-agent.service >/dev/null 2>&1; then + # UPGRADE: the unit file is the operator's (SSL, tokens, ports may be + # configured there) — never overwrite it. Only the binary it points at + # is replaced, then the service is restarted. + say "Existing socktop-agent.service found — preserving unit file, refreshing binary" + UNIT_BIN="$(systemctl show -p ExecStart socktop-agent.service 2>/dev/null \ + | sed -n 's/.*path=\([^ ;]*\).*/\1/p' | head -1)" + if [ -n "$UNIT_BIN" ] && [ "$UNIT_BIN" != "$PREFIX/socktop_agent" ]; then + $SYS_SUDO systemctl stop socktop-agent.service + $SYS_SUDO install -m 755 "$AGENT" "$UNIT_BIN" + $SYS_SUDO systemctl start socktop-agent.service + else + $SYS_SUDO systemctl restart socktop-agent.service + fi + else + # FRESH INSTALL: unit + the system user it runs as + its state dir, + # then enable and start. Mirrors the deb package's postinst and + # https://www.socktop.io/assets/docs/installation/agent-service.html + say "No socktop-agent.service found — installing and enabling it" + + if ! getent group socktop >/dev/null; then + $SYS_SUDO groupadd --system socktop + fi + if ! getent passwd socktop >/dev/null; then + NOLOGIN="$(command -v nologin || echo /usr/sbin/nologin)" + $SYS_SUDO useradd --system -g socktop -d /var/lib/socktop -M -s "$NOLOGIN" socktop + fi + $SYS_SUDO mkdir -p /var/lib/socktop + $SYS_SUDO chown socktop:socktop /var/lib/socktop + $SYS_SUDO chmod 755 /var/lib/socktop + + UNIT_TMP="$(mktemp)" + if [ -f "$SRC_DIR/docs/socktop-agent.service" ]; then + cp "$SRC_DIR/docs/socktop-agent.service" "$UNIT_TMP" + else + # Fallback for refs that predate docs/socktop-agent.service + cat > "$UNIT_TMP" <<'UNIT' +[Unit] +Description=Socktop agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/socktop_agent --port 3000 +Environment=RUST_LOG=info +# Optional auth: +# Environment=SOCKTOP_TOKEN=changeme +# TLS (self-signed cert on first run, default port 8443): +# Environment=SOCKTOP_ENABLE_SSL=1 +Restart=on-failure +User=socktop +Group=socktop +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target +UNIT + fi + # Pick the agent port: 3000 by default, but NEVER bind onto a port that + # something else already holds (e.g. Gitea/Umami and friends love 3000) + # — that puts the fresh service straight into a crash-restart loop. + AGENT_PORT="" + for p in 3000 3001 3010 3231 3232; do + if ! ss -tln 2>/dev/null | awk '{print $4}' | grep -q ":${p}\$"; then + AGENT_PORT="$p" + break + fi + done + if [ -z "$AGENT_PORT" ]; then + AGENT_PORT=3000 + warn "no free port among the defaults — using 3000; edit the unit if the service fails to start" + elif [ "$AGENT_PORT" != "3000" ]; then + warn "port 3000 is already in use by another service — configuring the agent on port $AGENT_PORT" + fi + + # Point ExecStart at wherever this run installed the agent, on the chosen port. + sed -i.bak -e "s|^ExecStart=[^ ]*socktop_agent|ExecStart=$PREFIX/socktop_agent|" \ + -e "s|--port [0-9]*|--port $AGENT_PORT|" "$UNIT_TMP" + rm -f "$UNIT_TMP.bak" + + $SYS_SUDO install -o root -g root -m 0644 "$UNIT_TMP" /etc/systemd/system/socktop-agent.service + rm -f "$UNIT_TMP" + $SYS_SUDO systemctl daemon-reload + $SYS_SUDO systemctl enable --now socktop-agent.service + say "Service installed — agent URL: ws://$(hostname):$AGENT_PORT/ws" + say "To enable TLS or a token, edit /etc/systemd/system/socktop-agent.service, then: sudo systemctl daemon-reload && sudo systemctl restart socktop-agent" + fi + sleep 1 + systemctl --no-pager -l status socktop-agent.service | head -5 || true +fi + +say "Installed:" +"$PREFIX/socktop" --version +"$PREFIX/socktop_agent" --version +say "Active on PATH: $(type -p socktop || true) / $(type -p socktop_agent || true)" +socktop --version + +} + +# exit in the same parse unit as the call: after main returns, bash must not +# read another byte from this (possibly replaced) file. +main "$@"; exit $? diff --git a/socktop/Cargo.toml b/socktop/Cargo.toml index 0bf3419..9f7ce20 100644 --- a/socktop/Cargo.toml +++ b/socktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "socktop" -version = "1.50.0" +version = "1.60.0" authors = ["Jason Witty "] description = "Remote system monitor over WebSocket, TUI like top" edition = "2024" @@ -11,7 +11,7 @@ repository = "https://github.com/jasonwitty/socktop" [dependencies] # socktop connector for agent communication -socktop_connector = "1.50.0" +socktop_connector = { version = "1.60.0", path = "../socktop_connector" } tokio = { workspace = true } futures-util = { workspace = true } @@ -23,7 +23,6 @@ crossterm = { workspace = true } unicode-width = { workspace = true } anyhow = { workspace = true } dirs-next = { workspace = true } -sysinfo = { workspace = true } [dev-dependencies] assert_cmd = "2.0" diff --git a/socktop/src/app.rs b/socktop/src/app.rs index 881cf8c..47e09b9 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -51,6 +51,11 @@ use socktop_connector::{ const MIN_METRICS_INTERVAL_MS: u64 = 100; const MIN_PROCESSES_INTERVAL_MS: u64 = 200; +/// Budget for one request/response round trip. Replies are matched to +/// requests by order, so a request that never answers would otherwise hang +/// `ws.next()` forever and freeze the TUI (raw mode even eats Ctrl+C). +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); + /// Drop duplicate-name entries from a disks payload (the agent occasionally /// reports a partition twice). Done once when fresh disk data arrives so the /// per-frame draw path doesn't have to rebuild a HashSet. @@ -60,6 +65,13 @@ fn dedup_disks(disks: &mut Vec) { disks.retain(|d| seen.insert(d.name.clone())); } +/// Outcome of draining input: keep going, or restart the event loop because a +/// reconnect installed a replacement connection. +enum InputFlow { + Continue, + RestartConnection, +} + #[derive(Debug, Clone, PartialEq)] pub enum ConnectionState { Connected, @@ -80,6 +92,14 @@ pub struct App { // Network totals snapshot + histories of KB/s last_net_totals: Option<(u64, u64, Instant)>, + // Agent-side sample timestamp of the previous snapshot (1.60+ agents). + last_net_sampled_at_ms: Option, + + // Consecutive metrics-request timeouts. One timeout gets a silent stream + // refresh; a second in a row means the agent accepts connections but + // never answers, and deserves a persistent error instead of an invisible + // reconnect loop that starves the UI. + consecutive_request_timeouts: u32, rx_hist: VecDeque, tx_hist: VecDeque, rx_peak: u64, @@ -180,6 +200,8 @@ impl App { cpu_hist_sum: 0, per_core_hist: PerCoreHistory::new(60), last_net_totals: None, + last_net_sampled_at_ms: None, + consecutive_request_timeouts: 0, rx_hist: VecDeque::with_capacity(600), tx_hist: VecDeque::with_capacity(600), rx_peak: 0, @@ -349,6 +371,40 @@ impl App { } } + /// A request produced no reply in time. Any late reply would desync every + /// subsequent request/response on this stream (replies are matched to + /// requests purely by order), so treat the connection as poisoned and go + /// through the standard reconnect flow — a fresh stream is realigned by + /// construction. + async fn poison_connection(&mut self, what: &str) { + self.show_connection_error(format!("{what}; reconnecting…")); + self.retry_connection().await; + } + + /// Replace the connection WITHOUT any modal or state churn. + /// + /// For timeouts on the optional per-process endpoints: an old agent + /// ignores those messages entirely (no late reply, so no desync), but a + /// merely-slow agent would desync the stream — indistinguishable at + /// timeout time, so we still swap to a fresh stream, silently. The + /// ProcessDetails modal keeps showing its "Agent Update Required" + /// message instead of being buried under a connection-error modal. + /// Only a failed reconnect (connection genuinely dead) surfaces loudly. + async fn quiet_reconnect(&mut self) { + let tls_ca_ref = self.tls_ca.as_deref(); + match self + .try_connect(&self.ws_url, tls_ca_ref, self.verify_hostname) + .await + { + Ok(ws) => { + self.replacement_connection = Some(ws); + } + Err(e) => { + self.show_connection_error(format!("Reconnect failed: {e}")); + } + } + } + /// Mark connection as successful and dismiss any error modals pub fn mark_connected(&mut self) { if self.connection_state != ConnectionState::Connected { @@ -666,6 +722,337 @@ impl App { } } + /// Drains and handles every queued terminal event (keys, mouse). Returns + /// whether the caller must restart the event loop on a replacement + /// connection. Extracted from the loop body so the tick wait can process + /// input at ~30ms latency instead of letting it queue for a whole + /// metrics interval. + async fn drain_input( + &mut self, + terminal: &mut Terminal, + ) -> Result> + where + ::Error: 'static, + { + // Drain everything already queued; the caller has verified (or will + // verify via poll) that input is or may be pending. + while event::poll(Duration::ZERO)? { + match event::read()? { + Event::Key(k) => { + // Handle modal input first - if a modal consumes the input, don't process normal keys + if self.modal_manager.is_active() { + let action = self.modal_manager.handle_key(k.code); + match action { + ModalAction::ExitApp => { + self.should_quit = true; + continue; // Skip normal key processing + } + ModalAction::RetryConnection => { + self.retry_connection().await; + // Check if retry succeeded and we have a replacement connection + if self.replacement_connection.is_some() { + // Restart the outer loop on the new connection + return Ok(InputFlow::RestartConnection); + } + continue; // Skip normal key processing + } + ModalAction::Cancel | ModalAction::Dismiss => { + // If ProcessDetails modal was dismissed, clear the data to save resources + if let Some(crate::ui::modal::ModalType::ProcessDetails { + .. + }) = self.modal_manager.current_modal() + { + self.clear_process_details(); + } + // Modal was dismissed, skip normal key processing + continue; + } + ModalAction::Confirm => { + // Handle confirmation action here if needed in the future + } + ModalAction::SwitchToParentProcess(_current_pid) => { + // Get parent PID from current process details + if let Some(details) = &self.process_details + && let Some(parent_pid) = details.process.parent_pid + { + // Clear current process details + self.clear_process_details(); + // Update selected process to parent + self.selected_process_pid = Some(parent_pid); + // Open modal for parent process + self.modal_manager.push_modal( + crate::ui::modal::ModalType::ProcessDetails { + pid: parent_pid, + }, + ); + } + continue; + } + ModalAction::Handled => { + // Modal consumed the key, don't pass to main window + continue; + } + ModalAction::None => { + // Modal didn't handle the key, pass through to normal handling + } + } + } + + // Handle search mode + if self.process_search_active { + match k.code { + KeyCode::Esc => { + // Exit search mode + self.process_search_active = false; + self.process_search_query.clear(); + self.invalidate_procs_filter(); + continue; + } + KeyCode::Enter => { + // Exit search mode, keep filter active, and auto-select first result + self.process_search_active = false; + + // Auto-select first filtered result + let first = self.procs_filter().first().copied(); + if let (Some(first_idx), Some(m)) = + (first, self.last_metrics.as_ref()) + { + self.selected_process_index = Some(first_idx); + self.selected_process_pid = + Some(m.top_processes[first_idx].pid); + } + continue; + } + KeyCode::Backspace => { + self.process_search_query.pop(); + self.invalidate_procs_filter(); + continue; + } + KeyCode::Char(c) => { + self.process_search_query.push(c); + self.invalidate_procs_filter(); + continue; + } + KeyCode::Up | KeyCode::Down => { + // Allow arrow keys to navigate even while in search mode + // Fall through to normal navigation handling + } + _ => { + continue; // Block other keys in search mode + } + } + } + + // Normal key handling (only if no modal is active or modal didn't consume the key) + if matches!( + k.code, + KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc + ) { + self.should_quit = true; + } + + // Activate search mode on '/' (clears query if starting new search, or edits existing) + if matches!(k.code, KeyCode::Char('/')) { + self.process_search_active = true; + // Don't clear query - allow editing existing search + continue; + } + + // Clear search filter on 'c' or 'C' (when not in search mode) + if matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C')) + && !self.process_search_query.is_empty() + && !self.process_search_active + { + self.process_search_query.clear(); + self.selected_process_pid = None; + self.selected_process_index = None; + self.invalidate_procs_filter(); + continue; + } + + // Show About modal on 'a' or 'A' + if matches!(k.code, KeyCode::Char('a') | KeyCode::Char('A')) { + self.modal_manager.push_modal(ModalType::About); + } + + // Show Help modal on 'h' or 'H' + if matches!(k.code, KeyCode::Char('h') | KeyCode::Char('H')) { + self.modal_manager.push_modal(ModalType::Help); + } + + // Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End) + let sz = terminal.size()?; + let area = Rect::new(0, 0, sz.width, sz.height); + let layout = self.layout(area); + let content = per_core_content_area(layout.per_core); + + // Refresh the filtered+sorted index cache once before we + // borrow individual fields of `self`. + let _ = self.procs_filter(); + + // First try process selection (only handles arrows if a process is selected) + let process_handled = if self.last_procs_area.is_some() { + processes_handle_key_with_selection(ProcessKeyParams { + selected_process_pid: &mut self.selected_process_pid, + selected_process_index: &mut self.selected_process_index, + key: k, + metrics: self.last_metrics.as_ref(), + filtered_indices: &self.procs_filtered, + }) + } else { + false + }; + + // If process selection didn't handle it, use CPU scrolling + if !process_handled { + per_core_handle_key(&mut self.per_core_scroll, k, content.height as usize); + } + + // Auto-scroll to keep selected process visible + if let (Some(selected_idx), Some(p_area)) = + (self.selected_process_index, self.last_procs_area) + && self.last_metrics.is_some() + { + let idxs = &self.procs_filtered; + + // Find the display position of the selected process in filtered list + if let Some(display_pos) = idxs.iter().position(|&idx| idx == selected_idx) + { + // Calculate viewport size + // Account for: borders (2) + header (1) + search box if active (3) + let extra_rows = if self.process_search_active + || !self.process_search_query.is_empty() + { + 3 // search box with border + } else { + 0 + }; + let viewport_rows = + p_area.height.saturating_sub(3 + extra_rows) as usize; + + // Adjust scroll offset to keep selection visible + if display_pos < self.procs_scroll_offset { + // Selection is above viewport, scroll up + self.procs_scroll_offset = display_pos; + } else if display_pos >= self.procs_scroll_offset + viewport_rows { + // Selection is below viewport, scroll down + self.procs_scroll_offset = + display_pos.saturating_sub(viewport_rows - 1); + } + } + } + + // Check if process selection changed and clear details if so + if self.selected_process_pid != self.prev_selected_process_pid { + self.clear_process_details(); + self.prev_selected_process_pid = self.selected_process_pid; + } + + // Check if Enter was pressed with a process selected + if process_handled + && k.code == KeyCode::Enter + && let Some(selected_pid) = self.selected_process_pid + { + self.modal_manager + .push_modal(ModalType::ProcessDetails { pid: selected_pid }); + } + + let total_rows = self + .last_metrics + .as_ref() + .map(|mm| mm.cpu_per_core.len()) + .unwrap_or(0); + per_core_clamp( + &mut self.per_core_scroll, + total_rows, + content.height as usize, + ); + } + Event::Mouse(m) => { + // If modal is active, don't handle mouse events on the main window + if self.modal_manager.is_active() { + continue; + } + + // Layout to get areas + let sz = terminal.size()?; + let area = Rect::new(0, 0, sz.width, sz.height); + let layout = self.layout(area); + + // Content wheel scrolling + let content = per_core_content_area(layout.per_core); + per_core_handle_mouse( + &mut self.per_core_scroll, + m, + content, + content.height as usize, + ); + + // Scrollbar clicks/drag + let total_rows = self + .last_metrics + .as_ref() + .map(|mm| mm.cpu_per_core.len()) + .unwrap_or(0); + per_core_handle_scrollbar_mouse( + &mut self.per_core_scroll, + &mut self.per_core_drag, + m, + layout.per_core, + total_rows, + ); + + // Clamp to bounds + per_core_clamp( + &mut self.per_core_scroll, + total_rows, + content.height as usize, + ); + + // Refresh filter cache before partial borrows of self. + let _ = self.procs_filter(); + let search_box_visible = + self.process_search_active || !self.process_search_query.is_empty(); + + // Processes table: sort by column on header click and handle row selection + if let (Some(_mm), Some(p_area)) = + (self.last_metrics.as_ref(), self.last_procs_area) + { + use crate::ui::processes::ProcessMouseParams; + let total_rows = self.procs_filtered.len(); + if let Some(new_sort) = + processes_handle_mouse_with_selection(ProcessMouseParams { + scroll_offset: &mut self.procs_scroll_offset, + selected_process_pid: &mut self.selected_process_pid, + selected_process_index: &mut self.selected_process_index, + drag: &mut self.procs_drag, + mouse: m, + area: p_area, + total_rows, + metrics: self.last_metrics.as_ref(), + search_box_visible, + filtered_indices: &self.procs_filtered, + }) + { + self.procs_sort_by = new_sort; + self.invalidate_procs_filter(); + } + } + + // Check if process selection changed via mouse and clear details if so + if self.selected_process_pid != self.prev_selected_process_pid { + self.clear_process_details(); + self.prev_selected_process_pid = self.selected_process_pid; + } + } + Event::Resize(_, _) => {} + _ => {} + } + } + + Ok(InputFlow::Continue) + } + async fn run_event_loop_iteration( &mut self, terminal: &mut Terminal, @@ -675,325 +1062,12 @@ impl App { ::Error: 'static, { loop { - // Input (non-blocking) - while event::poll(Duration::from_millis(10))? { - match event::read()? { - Event::Key(k) => { - // Handle modal input first - if a modal consumes the input, don't process normal keys - if self.modal_manager.is_active() { - let action = self.modal_manager.handle_key(k.code); - match action { - ModalAction::ExitApp => { - self.should_quit = true; - continue; // Skip normal key processing - } - ModalAction::RetryConnection => { - self.retry_connection().await; - // Check if retry succeeded and we have a replacement connection - if self.replacement_connection.is_some() { - // Signal that we want to restart with new connection - // Return from this iteration so the outer loop can restart - return Ok(()); - } - continue; // Skip normal key processing - } - ModalAction::Cancel | ModalAction::Dismiss => { - // If ProcessDetails modal was dismissed, clear the data to save resources - if let Some(crate::ui::modal::ModalType::ProcessDetails { - .. - }) = self.modal_manager.current_modal() - { - self.clear_process_details(); - } - // Modal was dismissed, skip normal key processing - continue; - } - ModalAction::Confirm => { - // Handle confirmation action here if needed in the future - } - ModalAction::SwitchToParentProcess(_current_pid) => { - // Get parent PID from current process details - if let Some(details) = &self.process_details - && let Some(parent_pid) = details.process.parent_pid - { - // Clear current process details - self.clear_process_details(); - // Update selected process to parent - self.selected_process_pid = Some(parent_pid); - // Open modal for parent process - self.modal_manager.push_modal( - crate::ui::modal::ModalType::ProcessDetails { - pid: parent_pid, - }, - ); - } - continue; - } - ModalAction::Handled => { - // Modal consumed the key, don't pass to main window - continue; - } - ModalAction::None => { - // Modal didn't handle the key, pass through to normal handling - } - } - } - - // Handle search mode - if self.process_search_active { - match k.code { - KeyCode::Esc => { - // Exit search mode - self.process_search_active = false; - self.process_search_query.clear(); - self.invalidate_procs_filter(); - continue; - } - KeyCode::Enter => { - // Exit search mode, keep filter active, and auto-select first result - self.process_search_active = false; - - // Auto-select first filtered result - let first = self.procs_filter().first().copied(); - if let (Some(first_idx), Some(m)) = - (first, self.last_metrics.as_ref()) - { - self.selected_process_index = Some(first_idx); - self.selected_process_pid = - Some(m.top_processes[first_idx].pid); - } - continue; - } - KeyCode::Backspace => { - self.process_search_query.pop(); - self.invalidate_procs_filter(); - continue; - } - KeyCode::Char(c) => { - self.process_search_query.push(c); - self.invalidate_procs_filter(); - continue; - } - KeyCode::Up | KeyCode::Down => { - // Allow arrow keys to navigate even while in search mode - // Fall through to normal navigation handling - } - _ => { - continue; // Block other keys in search mode - } - } - } - - // Normal key handling (only if no modal is active or modal didn't consume the key) - if matches!( - k.code, - KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc - ) { - self.should_quit = true; - } - - // Activate search mode on '/' (clears query if starting new search, or edits existing) - if matches!(k.code, KeyCode::Char('/')) { - self.process_search_active = true; - // Don't clear query - allow editing existing search - continue; - } - - // Clear search filter on 'c' or 'C' (when not in search mode) - if matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C')) - && !self.process_search_query.is_empty() - && !self.process_search_active - { - self.process_search_query.clear(); - self.selected_process_pid = None; - self.selected_process_index = None; - self.invalidate_procs_filter(); - continue; - } - - // Show About modal on 'a' or 'A' - if matches!(k.code, KeyCode::Char('a') | KeyCode::Char('A')) { - self.modal_manager.push_modal(ModalType::About); - } - - // Show Help modal on 'h' or 'H' - if matches!(k.code, KeyCode::Char('h') | KeyCode::Char('H')) { - self.modal_manager.push_modal(ModalType::Help); - } - - // Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End) - let sz = terminal.size()?; - let area = Rect::new(0, 0, sz.width, sz.height); - let layout = self.layout(area); - let content = per_core_content_area(layout.per_core); - - // Refresh the filtered+sorted index cache once before we - // borrow individual fields of `self`. - let _ = self.procs_filter(); - - // First try process selection (only handles arrows if a process is selected) - let process_handled = if self.last_procs_area.is_some() { - processes_handle_key_with_selection(ProcessKeyParams { - selected_process_pid: &mut self.selected_process_pid, - selected_process_index: &mut self.selected_process_index, - key: k, - metrics: self.last_metrics.as_ref(), - filtered_indices: &self.procs_filtered, - }) - } else { - false - }; - - // If process selection didn't handle it, use CPU scrolling - if !process_handled { - per_core_handle_key( - &mut self.per_core_scroll, - k, - content.height as usize, - ); - } - - // Auto-scroll to keep selected process visible - if let (Some(selected_idx), Some(p_area)) = - (self.selected_process_index, self.last_procs_area) - && self.last_metrics.is_some() - { - let idxs = &self.procs_filtered; - - // Find the display position of the selected process in filtered list - if let Some(display_pos) = - idxs.iter().position(|&idx| idx == selected_idx) - { - // Calculate viewport size - // Account for: borders (2) + header (1) + search box if active (3) - let extra_rows = if self.process_search_active - || !self.process_search_query.is_empty() - { - 3 // search box with border - } else { - 0 - }; - let viewport_rows = - p_area.height.saturating_sub(3 + extra_rows) as usize; - - // Adjust scroll offset to keep selection visible - if display_pos < self.procs_scroll_offset { - // Selection is above viewport, scroll up - self.procs_scroll_offset = display_pos; - } else if display_pos >= self.procs_scroll_offset + viewport_rows { - // Selection is below viewport, scroll down - self.procs_scroll_offset = - display_pos.saturating_sub(viewport_rows - 1); - } - } - } - - // Check if process selection changed and clear details if so - if self.selected_process_pid != self.prev_selected_process_pid { - self.clear_process_details(); - self.prev_selected_process_pid = self.selected_process_pid; - } - - // Check if Enter was pressed with a process selected - if process_handled - && k.code == KeyCode::Enter - && let Some(selected_pid) = self.selected_process_pid - { - self.modal_manager - .push_modal(ModalType::ProcessDetails { pid: selected_pid }); - } - - let total_rows = self - .last_metrics - .as_ref() - .map(|mm| mm.cpu_per_core.len()) - .unwrap_or(0); - per_core_clamp( - &mut self.per_core_scroll, - total_rows, - content.height as usize, - ); - } - Event::Mouse(m) => { - // If modal is active, don't handle mouse events on the main window - if self.modal_manager.is_active() { - continue; - } - - // Layout to get areas - let sz = terminal.size()?; - let area = Rect::new(0, 0, sz.width, sz.height); - let layout = self.layout(area); - - // Content wheel scrolling - let content = per_core_content_area(layout.per_core); - per_core_handle_mouse( - &mut self.per_core_scroll, - m, - content, - content.height as usize, - ); - - // Scrollbar clicks/drag - let total_rows = self - .last_metrics - .as_ref() - .map(|mm| mm.cpu_per_core.len()) - .unwrap_or(0); - per_core_handle_scrollbar_mouse( - &mut self.per_core_scroll, - &mut self.per_core_drag, - m, - layout.per_core, - total_rows, - ); - - // Clamp to bounds - per_core_clamp( - &mut self.per_core_scroll, - total_rows, - content.height as usize, - ); - - // Refresh filter cache before partial borrows of self. - let _ = self.procs_filter(); - let search_box_visible = - self.process_search_active || !self.process_search_query.is_empty(); - - // Processes table: sort by column on header click and handle row selection - if let (Some(_mm), Some(p_area)) = - (self.last_metrics.as_ref(), self.last_procs_area) - { - use crate::ui::processes::ProcessMouseParams; - let total_rows = self.procs_filtered.len(); - if let Some(new_sort) = - processes_handle_mouse_with_selection(ProcessMouseParams { - scroll_offset: &mut self.procs_scroll_offset, - selected_process_pid: &mut self.selected_process_pid, - selected_process_index: &mut self.selected_process_index, - drag: &mut self.procs_drag, - mouse: m, - area: p_area, - total_rows, - metrics: self.last_metrics.as_ref(), - search_box_visible, - filtered_indices: &self.procs_filtered, - }) - { - self.procs_sort_by = new_sort; - self.invalidate_procs_filter(); - } - } - - // Check if process selection changed via mouse and clear details if so - if self.selected_process_pid != self.prev_selected_process_pid { - self.clear_process_details(); - self.prev_selected_process_pid = self.selected_process_pid; - } - } - Event::Resize(_, _) => {} - _ => {} - } + // Input: drain anything already queued + if matches!( + self.drain_input(terminal).await?, + InputFlow::RestartConnection + ) { + return Ok(()); } // Check for automatic retry (every 30 seconds) @@ -1010,163 +1084,246 @@ impl App { break; } - // Fetch and update - match ws.request(AgentRequest::Metrics).await { - Ok(AgentResponse::Metrics(m)) => { - self.mark_connected(); // Mark as connected on successful request - self.update_with_metrics(m); + // Paint the current state BEFORE fetching: a request can stall for + // the full 5s timeout, and an iteration that ends in a poisoned- + // stream restart never reaches the draw at the bottom — without + // this, an agent that never answers left the screen permanently + // blank. (ratatui diffs make an unchanged repaint nearly free.) + terminal.draw(|f| self.draw(f))?; - // Only poll processes every 2s - if self.last_procs_poll.elapsed() >= self.procs_interval { - let mut updated = false; - if let Ok(AgentResponse::Processes(procs)) = - ws.request(AgentRequest::Processes).await - && let Some(mm) = self.last_metrics.as_mut() - { - mm.top_processes = procs.top_processes; - mm.process_count = Some(procs.process_count); - updated = true; + // Fetch and update. Skipped while disconnected — the retry paths + // (manual 'r' or the 30s auto-retry) own recovery, and hammering a + // dead socket with 5s-timeout requests would stall the loop. The + // shared draw + responsive wait below still run, so the error + // modal stays live and input stays snappy. + if self.connection_state == ConnectionState::Connected { + match timeout(REQUEST_TIMEOUT, ws.request(AgentRequest::Metrics)).await { + Err(_) => { + self.consecutive_request_timeouts += 1; + if self.consecutive_request_timeouts >= 2 { + // The agent accepts connections but never answers + // (wrong protocol era, or wedged): reconnecting + // can't help, so surface a persistent error and + // leave recovery to the manual/auto retry paths. + self.show_connection_error( + "Agent is not responding to requests".to_string(), + ); + } else { + self.poison_connection("Metrics request timed out").await; } - if updated { - self.invalidate_procs_filter(); - // Rebuild the pre-formatted row cache for the next - // ~N frames. Done once per poll, not per frame. - if let Some(mm) = self.last_metrics.as_ref() { - self.procs_row_peak_cpu = crate::ui::processes::rebuild_row_cache( - mm, - &mut self.procs_row_cache, - ); - } - } - self.last_procs_poll = Instant::now(); } + Ok(Ok(AgentResponse::Metrics(m))) => { + self.mark_connected(); // Mark as connected on successful request + self.consecutive_request_timeouts = 0; + self.update_with_metrics(m); - // Only poll disks every 5s - if self.last_disks_poll.elapsed() >= self.disks_interval { - if let Ok(AgentResponse::Disks(mut disks)) = - ws.request(AgentRequest::Disks).await - && let Some(mm) = self.last_metrics.as_mut() - { - dedup_disks(&mut disks); - mm.disks = disks; - } - self.last_disks_poll = Instant::now(); - } - - // Poll process details when modal is active and process is selected - if let Some(pid) = self.selected_process_pid { - // Check if ProcessDetails modal is currently active - if let Some(crate::ui::modal::ModalType::ProcessDetails { .. }) = - self.modal_manager.current_modal() - { - // Poll process details every 500ms when modal is active - if self.last_process_details_poll.elapsed() - >= self.process_details_interval + // Only poll processes every 2s + if self.last_procs_poll.elapsed() >= self.procs_interval { + let mut updated = false; + match timeout(REQUEST_TIMEOUT, ws.request(AgentRequest::Processes)) + .await { - // Use timeout to prevent blocking the event loop - match timeout( - Duration::from_millis(2000), - ws.request(AgentRequest::ProcessMetrics { pid }), - ) - .await - { - Ok(Ok(AgentResponse::ProcessMetrics(details))) => { - // Update history for sparklines - let cpu_usage = details.process.cpu_usage; - let evicted_cpu = push_capped( - &mut self.process_cpu_history, - cpu_usage, - 600, - ); - self.process_cpu_history_sum = self.process_cpu_history_sum - + cpu_usage - - evicted_cpu.unwrap_or(0.0); - - let mem_bytes = details.process.mem_bytes; - push_capped(&mut self.process_mem_history, mem_bytes, 600); - - // Track maximum memory usage - if mem_bytes > self.max_process_mem_bytes { - self.max_process_mem_bytes = mem_bytes; - } - - // I/O bytes from agent are cumulative, calculate deltas - if let Some(read) = details.process.read_bytes { - let delta = if let Some(last) = self.last_io_read_bytes - { - read.saturating_sub(last) - } else { - 0 // First sample, no delta available - }; - push_capped( - &mut self.process_io_read_history, - delta, - 600, - ); - self.last_io_read_bytes = Some(read); - } - if let Some(write) = details.process.write_bytes { - let delta = if let Some(last) = self.last_io_write_bytes - { - write.saturating_sub(last) - } else { - 0 // First sample, no delta available - }; - push_capped( - &mut self.process_io_write_history, - delta, - 600, - ); - self.last_io_write_bytes = Some(write); - } - - self.process_details = Some(details); - self.process_details_unsupported = false; - } - Ok(Err(_)) | Err(_) => { - // Agent doesn't support this feature or timeout occurred - // Mark as unsupported so we can show appropriate message - self.process_details_unsupported = true; - } - Ok(Ok(_)) => { - // Wrong response type - self.process_details_unsupported = true; + Err(_) => { + self.poison_connection("Processes request timed out").await; + } + Ok(Ok(AgentResponse::Processes(procs))) => { + if let Some(mm) = self.last_metrics.as_mut() { + mm.top_processes = procs.top_processes; + mm.process_count = Some(procs.process_count); + updated = true; } } - self.last_process_details_poll = Instant::now(); + // Request error or wrong type: keep stale rows; a + // broken socket surfaces on the next metrics tick. + Ok(_) => {} } + if updated { + self.invalidate_procs_filter(); + // Rebuild the pre-formatted row cache for the next + // ~N frames. Done once per poll, not per frame. + if let Some(mm) = self.last_metrics.as_ref() { + self.procs_row_peak_cpu = + crate::ui::processes::rebuild_row_cache( + mm, + &mut self.procs_row_cache, + ); + } + } + self.last_procs_poll = Instant::now(); + } - // Poll journal entries every 5s when modal is active - if self.last_journal_poll.elapsed() >= self.journal_interval { - // Use timeout to prevent blocking the event loop - match timeout( - Duration::from_millis(2000), - ws.request(AgentRequest::JournalEntries { pid }), - ) - .await - { - Ok(Ok(AgentResponse::JournalEntries(journal))) => { - self.journal_entries = Some(journal); - } - Ok(Err(_)) | Err(_) | Ok(Ok(_)) => { - // Agent doesn't support this feature, error occurred, or wrong response type - // Keep journal_entries as None + // Only poll disks every 5s + if self.connection_state == ConnectionState::Connected + && self.last_disks_poll.elapsed() >= self.disks_interval + { + match timeout(REQUEST_TIMEOUT, ws.request(AgentRequest::Disks)).await { + Err(_) => { + self.poison_connection("Disks request timed out").await; + } + Ok(Ok(AgentResponse::Disks(mut disks))) => { + if let Some(mm) = self.last_metrics.as_mut() { + dedup_disks(&mut disks); + mm.disks = disks; } } - self.last_journal_poll = Instant::now(); + Ok(_) => {} + } + self.last_disks_poll = Instant::now(); + } + + // Poll process details when modal is active and process is selected + if let Some(pid) = self.selected_process_pid + && self.connection_state == ConnectionState::Connected + { + // Check if ProcessDetails modal is currently active + if let Some(crate::ui::modal::ModalType::ProcessDetails { .. }) = + self.modal_manager.current_modal() + { + // Poll process details every 500ms when modal is + // active. Skipped once the agent is known not to + // support the endpoint (flag resets when the modal + // closes or the selection changes, so a one-off + // timeout doesn't disable details for the session). + if self.connection_state == ConnectionState::Connected + && !self.process_details_unsupported + && self.last_process_details_poll.elapsed() + >= self.process_details_interval + { + // Use timeout to prevent blocking the event loop + match timeout( + Duration::from_millis(2000), + ws.request(AgentRequest::ProcessMetrics { pid }), + ) + .await + { + Ok(Ok(AgentResponse::ProcessMetrics(details))) => { + // Update history for sparklines + let cpu_usage = details.process.cpu_usage; + let evicted_cpu = push_capped( + &mut self.process_cpu_history, + cpu_usage, + 600, + ); + self.process_cpu_history_sum = + self.process_cpu_history_sum + cpu_usage + - evicted_cpu.unwrap_or(0.0); + + let mem_bytes = details.process.mem_bytes; + push_capped( + &mut self.process_mem_history, + mem_bytes, + 600, + ); + + // Track maximum memory usage + if mem_bytes > self.max_process_mem_bytes { + self.max_process_mem_bytes = mem_bytes; + } + + // I/O bytes from agent are cumulative, calculate deltas + if let Some(read) = details.process.read_bytes { + let delta = + if let Some(last) = self.last_io_read_bytes { + read.saturating_sub(last) + } else { + 0 // First sample, no delta available + }; + push_capped( + &mut self.process_io_read_history, + delta, + 600, + ); + self.last_io_read_bytes = Some(read); + } + if let Some(write) = details.process.write_bytes { + let delta = + if let Some(last) = self.last_io_write_bytes { + write.saturating_sub(last) + } else { + 0 // First sample, no delta available + }; + push_capped( + &mut self.process_io_write_history, + delta, + 600, + ); + self.last_io_write_bytes = Some(write); + } + + self.process_details = Some(details); + self.process_details_unsupported = false; + } + Ok(Err(_)) => { + // Agent responded with an error: endpoint + // not supported. + self.process_details_unsupported = true; + } + Err(_) => { + // No reply at all: old agents IGNORE + // this message, so show the "Agent + // Update Required" state and refresh + // the stream quietly (a merely-slow + // agent's late reply would otherwise + // desync it). + self.process_details_unsupported = true; + self.quiet_reconnect().await; + } + Ok(Ok(_)) => { + // Wrong response type + self.process_details_unsupported = true; + } + } + self.last_process_details_poll = Instant::now(); + } + + // Poll journal entries every 5s when modal is active. + // Gated on the same unsupported flag: agents that lack + // process details lack the journal endpoint too. + if self.connection_state == ConnectionState::Connected + && !self.process_details_unsupported + && self.last_journal_poll.elapsed() >= self.journal_interval + { + // Use timeout to prevent blocking the event loop + match timeout( + Duration::from_millis(2000), + ws.request(AgentRequest::JournalEntries { pid }), + ) + .await + { + Ok(Ok(AgentResponse::JournalEntries(journal))) => { + self.journal_entries = Some(journal); + } + Err(_) => { + // No reply: same quiet stream refresh + // as the details endpoint above. + self.quiet_reconnect().await; + } + Ok(Err(_)) | Ok(Ok(_)) => { + // Endpoint unsupported or wrong type; + // keep journal_entries as None + } + } + self.last_journal_poll = Instant::now(); + } } } } + Ok(Err(e)) => { + // Connection error - show modal if not already shown + let error_message = format!("Failed to fetch metrics: {e}"); + self.show_connection_error(error_message); + } + Ok(_) => { + // Unexpected response type + self.show_connection_error("Unexpected response from agent".to_string()); + } } - Err(e) => { - // Connection error - show modal if not already shown - let error_message = format!("Failed to fetch metrics: {e}"); - self.show_connection_error(error_message); - } - _ => { - // Unexpected response type - self.show_connection_error("Unexpected response from agent".to_string()); - } + } + + // A poisoned connection may have been replaced mid-iteration: + // restart on the fresh stream before issuing any more requests. + if self.replacement_connection.is_some() { + return Ok(()); } // Update countdown for connection error modal if active @@ -1178,8 +1335,27 @@ impl App { // Draw terminal.draw(|f| self.draw(f))?; - // Tick rate - sleep(self.metrics_interval).await; + // Tick wait, kept responsive: instead of sleeping the whole + // metrics interval (which queued keys/wheel events for up to + // 500ms and applied them in bursts), wait in ≤33ms slices and + // handle + repaint input the moment it arrives. + let deadline = Instant::now() + self.metrics_interval; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() || self.should_quit { + break; + } + if !event::poll(remaining.min(Duration::from_millis(33)))? { + continue; + } + if matches!( + self.drain_input(terminal).await?, + InputFlow::RestartConnection + ) { + return Ok(()); + } + terminal.draw(|f| self.draw(f))?; + } } Ok(()) @@ -1249,19 +1425,39 @@ impl App { self.per_core_hist.ensure_cores(m.cpu_per_core.len()); self.per_core_hist.push_samples(&m.cpu_per_core); - // NET: sum across all ifaces, compute KB/s via elapsed time + // NET: sum across all ifaces, compute KB/s. Prefer the agent's sample + // timestamps (the agent serves TTL-cached snapshots, so client receive + // time overstates dt on a cache hit and produces a 0-then-2x sawtooth); + // fall back to the client clock against pre-1.60 agents. let now = Instant::now(); let rx_total = m.networks.iter().map(|n| n.received).sum::(); let tx_total = m.networks.iter().map(|n| n.transmitted).sum::(); let (rx_kb, tx_kb) = if let Some((prx, ptx, pts)) = self.last_net_totals { - let dt = now.duration_since(pts).as_secs_f64().max(1e-6); - let rx = ((rx_total.saturating_sub(prx)) as f64 / dt / 1024.0).round() as u64; - let tx = ((tx_total.saturating_sub(ptx)) as f64 / dt / 1024.0).round() as u64; - (rx, tx) + // None = identical agent snapshot (cache hit): repeat the previous + // rates so the timeline advances without a fake dip to zero. + let dt = match (m.sampled_at_ms, self.last_net_sampled_at_ms) { + (Some(a), Some(b)) if a == b => None, + (Some(a), Some(b)) if a > b => Some((a - b) as f64 / 1000.0), + // Agent restarted or clock stepped backwards: client clock. + _ => Some(now.duration_since(pts).as_secs_f64().max(1e-6)), + }; + match dt { + None => ( + self.rx_hist.back().copied().unwrap_or(0), + self.tx_hist.back().copied().unwrap_or(0), + ), + Some(dt) => { + let dt = dt.max(1e-6); + let rx = ((rx_total.saturating_sub(prx)) as f64 / dt / 1024.0).round() as u64; + let tx = ((tx_total.saturating_sub(ptx)) as f64 / dt / 1024.0).round() as u64; + (rx, tx) + } + } } else { (0, 0) }; self.last_net_totals = Some((rx_total, tx_total, now)); + self.last_net_sampled_at_ms = m.sampled_at_ms; push_capped(&mut self.rx_hist, rx_kb, 600); push_capped(&mut self.tx_hist, tx_kb, 600); self.rx_peak = self.rx_peak.max(rx_kb); diff --git a/socktop/src/ui/.modal.rs.backup b/socktop/src/ui/.modal.rs.backup deleted file mode 100644 index a64db1b..0000000 --- a/socktop/src/ui/.modal.rs.backup +++ /dev/null @@ -1,1849 +0,0 @@ -//! Modal window system for socktop TUI application - -use std::time::Instant; - -use super::modal_format::{calculate_dynamic_y_max, format_duration, format_uptime, normalize_cpu_usage}; -use super::theme::{ - BTN_EXIT_BG_ACTIVE, BTN_EXIT_FG_ACTIVE, BTN_EXIT_FG_INACTIVE, BTN_EXIT_TEXT, - BTN_RETRY_BG_ACTIVE, BTN_RETRY_FG_ACTIVE, BTN_RETRY_FG_INACTIVE, BTN_RETRY_TEXT, ICON_CLUSTER, - ICON_COUNTDOWN_LABEL, ICON_MESSAGE, ICON_OFFLINE_LABEL, ICON_RETRY_LABEL, ICON_WARNING_TITLE, - LARGE_ERROR_ICON, MODAL_AGENT_FG, MODAL_BG, MODAL_BORDER_FG, MODAL_COUNTDOWN_LABEL_FG, - MODAL_DIM_BG, MODAL_FG, MODAL_HINT_FG, MODAL_ICON_PINK, MODAL_OFFLINE_LABEL_FG, - MODAL_RETRY_LABEL_FG, MODAL_TITLE_FG, -}; -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span, Text}, - widgets::{ - Axis, Block, Borders, Chart, Clear, Dataset, GraphType, Padding, Paragraph, Row, - Scrollbar, ScrollbarOrientation, ScrollbarState, Table, Wrap, - }, -}; - -/// History data for process metrics rendering -pub struct ProcessHistoryData<'a> { - pub cpu: &'a std::collections::VecDeque, - pub mem: &'a std::collections::VecDeque, - pub io_read: &'a std::collections::VecDeque, - pub io_write: &'a std::collections::VecDeque, -} - -/// Process data for modal rendering -pub struct ProcessModalData<'a> { - pub details: Option<&'a socktop_connector::ProcessMetricsResponse>, - pub journal: Option<&'a socktop_connector::JournalResponse>, - pub history: ProcessHistoryData<'a>, - pub unsupported: bool, -} - -/// Parameters for rendering scatter plot -struct ScatterPlotParams<'a> { - process: &'a socktop_connector::DetailedProcessInfo, - main_user_ms: f64, - main_system_ms: f64, - max_user: f64, - max_system: f64, -} - -#[derive(Debug, Clone)] -pub enum ModalType { - ConnectionError { - message: String, - disconnected_at: Instant, - retry_count: u32, - auto_retry_countdown: Option, - }, - ProcessDetails { - pid: u32, - }, - #[allow(dead_code)] - Confirmation { - title: String, - message: String, - confirm_text: String, - cancel_text: String, - }, - #[allow(dead_code)] - Info { - title: String, - message: String, - }, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum ModalAction { - None, // Modal didn't handle the key, pass to main window - Handled, // Modal handled the key, don't pass to main window - RetryConnection, - ExitApp, - Confirm, - Cancel, - Dismiss, - SwitchToParentProcess(u32), // Switch to viewing parent process details -} - -#[derive(Debug, Clone, PartialEq)] -pub enum ModalButton { - Retry, - Exit, - Confirm, - Cancel, - Ok, -} - -#[derive(Debug)] -pub struct ModalManager { - stack: Vec, - active_button: ModalButton, - pub thread_scroll_offset: usize, - pub journal_scroll_offset: usize, - thread_scroll_max: usize, - journal_scroll_max: usize, -} - -impl ModalManager { - pub fn new() -> Self { - Self { - stack: Vec::new(), - active_button: ModalButton::Retry, - thread_scroll_offset: 0, - journal_scroll_offset: 0, - thread_scroll_max: 0, - journal_scroll_max: 0, - } - } - pub fn is_active(&self) -> bool { - !self.stack.is_empty() - } - - pub fn current_modal(&self) -> Option<&ModalType> { - self.stack.last() - } - - pub fn push_modal(&mut self, modal: ModalType) { - self.stack.push(modal); - self.active_button = match self.stack.last() { - Some(ModalType::ConnectionError { .. }) => ModalButton::Retry, - Some(ModalType::ProcessDetails { .. }) => { - // Reset scroll state for new process details - self.thread_scroll_offset = 0; - self.journal_scroll_offset = 0; - self.thread_scroll_max = 0; - self.journal_scroll_max = 0; - ModalButton::Ok - } - Some(ModalType::Confirmation { .. }) => ModalButton::Confirm, - Some(ModalType::Info { .. }) => ModalButton::Ok, - None => ModalButton::Ok, - }; - } - pub fn pop_modal(&mut self) -> Option { - let m = self.stack.pop(); - if let Some(next) = self.stack.last() { - self.active_button = match next { - ModalType::ConnectionError { .. } => ModalButton::Retry, - ModalType::ProcessDetails { .. } => ModalButton::Ok, - ModalType::Confirmation { .. } => ModalButton::Confirm, - ModalType::Info { .. } => ModalButton::Ok, - }; - } - m - } - pub fn update_connection_error_countdown(&mut self, new_countdown: Option) { - if let Some(ModalType::ConnectionError { - auto_retry_countdown, - .. - }) = self.stack.last_mut() - { - *auto_retry_countdown = new_countdown; - } - } - pub fn handle_key(&mut self, key: KeyCode) -> ModalAction { - if !self.is_active() { - return ModalAction::None; - } - match key { - KeyCode::Esc => { - self.pop_modal(); - ModalAction::Cancel - } - KeyCode::Enter => self.handle_enter(), - KeyCode::Tab | KeyCode::Right => { - self.next_button(); - ModalAction::None - } - KeyCode::BackTab | KeyCode::Left => { - self.prev_button(); - ModalAction::None - } - KeyCode::Char('r') | KeyCode::Char('R') => { - if matches!(self.stack.last(), Some(ModalType::ConnectionError { .. })) { - ModalAction::RetryConnection - } else { - ModalAction::None - } - } - KeyCode::Char('q') | KeyCode::Char('Q') => { - if matches!(self.stack.last(), Some(ModalType::ConnectionError { .. })) { - ModalAction::ExitApp - } else { - ModalAction::None - } - } - KeyCode::Char('x') | KeyCode::Char('X') => { - if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { - // Close all ProcessDetails modals at once (handles parent navigation chain) - while matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { - self.pop_modal(); - } - ModalAction::Dismiss - } else { - ModalAction::None - } - } - KeyCode::Char('j') | KeyCode::Char('J') => { - if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { - self.thread_scroll_offset = self - .thread_scroll_offset - .saturating_add(1) - .min(self.thread_scroll_max); - ModalAction::Handled - } else { - ModalAction::None - } - } - KeyCode::Char('k') | KeyCode::Char('K') => { - if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { - self.thread_scroll_offset = self.thread_scroll_offset.saturating_sub(1); - ModalAction::Handled - } else { - ModalAction::None - } - } - KeyCode::Char('d') | KeyCode::Char('D') => { - if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { - self.thread_scroll_offset = self - .thread_scroll_offset - .saturating_add(10) - .min(self.thread_scroll_max); - ModalAction::Handled - } else { - ModalAction::None - } - } - KeyCode::Char('u') | KeyCode::Char('U') => { - if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { - self.thread_scroll_offset = self.thread_scroll_offset.saturating_sub(10); - ModalAction::Handled - } else { - ModalAction::None - } - } - KeyCode::Char('[') => { - if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { - self.journal_scroll_offset = self.journal_scroll_offset.saturating_sub(1); - ModalAction::Handled - } else { - ModalAction::None - } - } - KeyCode::Char(']') => { - if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { - self.journal_scroll_offset = self - .journal_scroll_offset - .saturating_add(1) - .min(self.journal_scroll_max); - ModalAction::Handled - } else { - ModalAction::None - } - } - KeyCode::Char('p') | KeyCode::Char('P') => { - // Switch to parent process if it exists - if let Some(ModalType::ProcessDetails { pid }) = self.stack.last() { - // We need to get the parent PID from the process details - // For now, return a special action that the app can handle - // The app has access to the process details and can extract parent_pid - ModalAction::SwitchToParentProcess(*pid) - } else { - ModalAction::None - } - } - _ => ModalAction::None, - } - } - fn handle_enter(&mut self) -> ModalAction { - match (&self.stack.last(), &self.active_button) { - (Some(ModalType::ConnectionError { .. }), ModalButton::Retry) => { - ModalAction::RetryConnection - } - (Some(ModalType::ConnectionError { .. }), ModalButton::Exit) => ModalAction::ExitApp, - (Some(ModalType::ProcessDetails { .. }), ModalButton::Ok) => { - self.pop_modal(); - ModalAction::Dismiss - } - (Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm, - (Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel, - (Some(ModalType::Info { .. }), ModalButton::Ok) => { - self.pop_modal(); - ModalAction::Dismiss - } - _ => ModalAction::None, - } - } - fn next_button(&mut self) { - self.active_button = match (&self.stack.last(), &self.active_button) { - (Some(ModalType::ConnectionError { .. }), ModalButton::Retry) => ModalButton::Exit, - (Some(ModalType::ConnectionError { .. }), ModalButton::Exit) => ModalButton::Retry, - (Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalButton::Cancel, - (Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalButton::Confirm, - _ => self.active_button.clone(), - }; - } - fn prev_button(&mut self) { - self.next_button(); - } - - pub fn render(&mut self, f: &mut Frame, data: ProcessModalData) { - if let Some(m) = self.stack.last().cloned() { - self.render_background_dim(f); - self.render_modal_content(f, &m, data); - } - } - - fn render_background_dim(&self, f: &mut Frame) { - let area = f.area(); - f.render_widget(Clear, area); - f.render_widget( - Block::default() - .style(Style::default().bg(MODAL_DIM_BG).fg(MODAL_DIM_BG)) - .borders(Borders::NONE), - area, - ); - } - - fn render_modal_content(&mut self, f: &mut Frame, modal: &ModalType, data: ProcessModalData) { - let area = f.area(); - // Different sizes for different modal types - let modal_area = match modal { - ModalType::ProcessDetails { .. } => { - // Process details modal uses almost full screen (95% width, 90% height) - self.centered_rect(95, 90, area) - } - _ => { - // Other modals use smaller size - self.centered_rect(70, 50, area) - } - }; - f.render_widget(Clear, modal_area); - match modal { - ModalType::ConnectionError { - message, - disconnected_at, - retry_count, - auto_retry_countdown, - } => self.render_connection_error( - f, - modal_area, - message, - *disconnected_at, - *retry_count, - *auto_retry_countdown, - ), - ModalType::ProcessDetails { pid } => { - self.render_process_details(f, modal_area, *pid, data) - } - ModalType::Confirmation { - title, - message, - confirm_text, - cancel_text, - } => self.render_confirmation(f, modal_area, title, message, confirm_text, cancel_text), - ModalType::Info { title, message } => self.render_info(f, modal_area, title, message), - } - } - - fn render_connection_error( - &self, - f: &mut Frame, - area: Rect, - message: &str, - disconnected_at: Instant, - retry_count: u32, - auto_retry_countdown: Option, - ) { - let duration_text = format_duration(disconnected_at.elapsed()); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(4), - Constraint::Length(4), - ]) - .split(area); - let block = Block::default() - .title(ICON_WARNING_TITLE) - .title_style( - Style::default() - .fg(MODAL_TITLE_FG) - .add_modifier(Modifier::BOLD), - ) - .borders(Borders::ALL) - .border_style(Style::default().fg(MODAL_BORDER_FG)) - .style(Style::default().bg(MODAL_BG).fg(MODAL_FG)); - f.render_widget(block, area); - - let content_area = chunks[1]; - let max_w = content_area.width.saturating_sub(15) as usize; - let clean_message = if message.to_lowercase().contains("hostname verification") - || message.contains("socktop_connector") - { - "Connection failed - hostname verification disabled".to_string() - } else if message.contains("Failed to fetch metrics:") { - if let Some(p) = message.find(':') { - let ess = message[p + 1..].trim(); - if ess.len() > max_w { - format!("{}...", &ess[..max_w.saturating_sub(3)]) - } else { - ess.to_string() - } - } else { - "Connection error".to_string() - } - } else if message.starts_with("Retry failed:") { - if let Some(p) = message.find(':') { - let ess = message[p + 1..].trim(); - if ess.len() > max_w { - format!("{}...", &ess[..max_w.saturating_sub(3)]) - } else { - ess.to_string() - } - } else { - "Retry failed".to_string() - } - } else if message.len() > max_w { - format!("{}...", &message[..max_w.saturating_sub(3)]) - } else { - message.to_string() - }; - let truncate = |s: &str| { - if s.len() > max_w { - format!("{}...", &s[..max_w.saturating_sub(3)]) - } else { - s.to_string() - } - }; - let agent_text = truncate("📡 Cannot connect to socktop agent"); - let message_text = truncate(&clean_message); - let duration_display = truncate(&duration_text); - let retry_display = truncate(&retry_count.to_string()); - let countdown_text = auto_retry_countdown.map(|c| { - if c == 0 { - "Auto retry now...".to_string() - } else { - format!("{c}s") - } - }); - - // Determine if we have enough space (height + width) to show large centered icon - let icon_max_width = LARGE_ERROR_ICON - .iter() - .map(|l| l.trim().chars().count()) - .max() - .unwrap_or(0) as u16; - let large_allowed = content_area.height >= (LARGE_ERROR_ICON.len() as u16 + 8) - && content_area.width >= icon_max_width + 6; // small margin for borders/padding - let mut icon_lines: Vec = Vec::new(); - if large_allowed { - for &raw in LARGE_ERROR_ICON.iter() { - let trimmed = raw.trim(); - icon_lines.push(Line::from( - trimmed - .chars() - .map(|ch| { - if ch == '!' { - Span::styled( - ch.to_string(), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ) - } else if ch == '/' || ch == '\\' || ch == '_' { - // keep outline in pink - Span::styled( - ch.to_string(), - Style::default() - .fg(MODAL_ICON_PINK) - .add_modifier(Modifier::BOLD), - ) - } else if ch == ' ' { - Span::raw(" ") - } else { - Span::styled(ch.to_string(), Style::default().fg(MODAL_ICON_PINK)) - } - }) - .collect::>(), - )); - } - icon_lines.push(Line::from("")); // blank spacer line below icon - } - - let mut info_lines: Vec = Vec::new(); - if !large_allowed { - info_lines.push(Line::from(vec![Span::styled( - ICON_CLUSTER, - Style::default().fg(MODAL_ICON_PINK), - )])); - info_lines.push(Line::from("")); - } - info_lines.push(Line::from(vec![Span::styled( - &agent_text, - Style::default().fg(MODAL_AGENT_FG), - )])); - info_lines.push(Line::from("")); - info_lines.push(Line::from(vec![ - Span::styled(ICON_MESSAGE, Style::default().fg(MODAL_HINT_FG)), - Span::styled(&message_text, Style::default().fg(MODAL_AGENT_FG)), - ])); - info_lines.push(Line::from("")); - info_lines.push(Line::from(vec![ - Span::styled( - ICON_OFFLINE_LABEL, - Style::default().fg(MODAL_OFFLINE_LABEL_FG), - ), - Span::styled( - &duration_display, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - ])); - info_lines.push(Line::from(vec![ - Span::styled(ICON_RETRY_LABEL, Style::default().fg(MODAL_RETRY_LABEL_FG)), - Span::styled( - &retry_display, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - ])); - if let Some(cd) = &countdown_text { - info_lines.push(Line::from(vec![ - Span::styled( - ICON_COUNTDOWN_LABEL, - Style::default().fg(MODAL_COUNTDOWN_LABEL_FG), - ), - Span::styled( - cd, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - ])); - } - - let constrained = Rect { - x: content_area.x + 2, - y: content_area.y, - width: content_area.width.saturating_sub(4), - height: content_area.height, - }; - if large_allowed { - let split = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(icon_lines.len() as u16), - Constraint::Min(0), - ]) - .split(constrained); - // Center the icon block; each line already trimmed so per-line centering keeps shape - f.render_widget( - Paragraph::new(Text::from(icon_lines)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: false }), - split[0], - ); - f.render_widget( - Paragraph::new(Text::from(info_lines)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }), - split[1], - ); - } else { - f.render_widget( - Paragraph::new(Text::from(info_lines)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }), - constrained, - ); - } - - let button_area = Rect { - x: chunks[2].x, - y: chunks[2].y, - width: chunks[2].width, - height: chunks[2].height.saturating_sub(1), - }; - self.render_connection_error_buttons(f, button_area); - } - - fn render_connection_error_buttons(&self, f: &mut Frame, area: Rect) { - let button_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(30), - Constraint::Percentage(15), - Constraint::Percentage(10), - Constraint::Percentage(15), - Constraint::Percentage(30), - ]) - .split(area); - let retry_style = if self.active_button == ModalButton::Retry { - Style::default() - .bg(BTN_RETRY_BG_ACTIVE) - .fg(BTN_RETRY_FG_ACTIVE) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(BTN_RETRY_FG_INACTIVE) - .add_modifier(Modifier::DIM) - }; - let exit_style = if self.active_button == ModalButton::Exit { - Style::default() - .bg(BTN_EXIT_BG_ACTIVE) - .fg(BTN_EXIT_FG_ACTIVE) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(BTN_EXIT_FG_INACTIVE) - .add_modifier(Modifier::DIM) - }; - f.render_widget( - Paragraph::new(Text::from(Line::from(vec![Span::styled( - BTN_RETRY_TEXT, - retry_style, - )]))) - .alignment(Alignment::Center), - button_chunks[1], - ); - f.render_widget( - Paragraph::new(Text::from(Line::from(vec![Span::styled( - BTN_EXIT_TEXT, - exit_style, - )]))) - .alignment(Alignment::Center), - button_chunks[3], - ); - } - - fn render_confirmation( - &self, - f: &mut Frame, - area: Rect, - title: &str, - message: &str, - confirm_text: &str, - cancel_text: &str, - ) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(3)]) - .split(area); - let block = Block::default() - .title(format!(" {title} ")) - .borders(Borders::ALL) - .style(Style::default().bg(Color::Black)); - f.render_widget(block, area); - f.render_widget( - Paragraph::new(message) - .style(Style::default().fg(Color::White)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }), - chunks[0], - ); - let buttons = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(chunks[1]); - let confirm_style = if self.active_button == ModalButton::Confirm { - Style::default() - .bg(Color::Green) - .fg(Color::Black) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Green) - }; - let cancel_style = if self.active_button == ModalButton::Cancel { - Style::default() - .bg(Color::Red) - .fg(Color::Black) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Red) - }; - f.render_widget( - Paragraph::new(confirm_text) - .style(confirm_style) - .alignment(Alignment::Center), - buttons[0], - ); - f.render_widget( - Paragraph::new(cancel_text) - .style(cancel_style) - .alignment(Alignment::Center), - buttons[1], - ); - } - - fn render_info(&self, f: &mut Frame, area: Rect, title: &str, message: &str) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(3)]) - .split(area); - let block = Block::default() - .title(format!(" {title} ")) - .borders(Borders::ALL) - .style(Style::default().bg(Color::Black)); - f.render_widget(block, area); - f.render_widget( - Paragraph::new(message) - .style(Style::default().fg(Color::White)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }), - chunks[0], - ); - let ok_style = if self.active_button == ModalButton::Ok { - Style::default() - .bg(Color::Blue) - .fg(Color::White) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Blue) - }; - f.render_widget( - Paragraph::new("[ Enter ] OK") - .style(ok_style) - .alignment(Alignment::Center), - chunks[1], - ); - } - - fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect { - let vert = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(vert[1])[1] - } - - fn render_process_details( - &mut self, - f: &mut Frame, - area: Rect, - pid: u32, - data: ProcessModalData, - ) { - let title = format!("Process Details - PID {pid}"); - - // Use neutral colors to match main UI aesthetic - let block = Block::default().title(title).borders(Borders::ALL); - - // Split the modal into the 3-row layout as designed - let inner = block.inner(area); - let main_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(18), // Top row: CPU sparkline | Thread scatter plot - Constraint::Length(25), // Middle row: Memory/IO graphs | Thread table | Command details (fixed height for consistent scrolling) - Constraint::Min(6), // Bottom row: Journal events (gets remaining space) - Constraint::Length(1), // Help line - ]) - .split(inner); - - // Render the border - f.render_widget(block, area); - - if let Some(details) = data.details { - // Top Row: CPU sparkline (left) | Thread scatter plot (right) - self.render_top_row_with_sparkline( - f, - main_chunks[0], - &details.process, - data.history.cpu, - ); - - // Middle Row: Memory/IO + Thread Table + Command Details (with process metadata) - self.render_middle_row_with_metadata( - f, - main_chunks[1], - &details.process, - data.history.mem, - data.history.io_read, - data.history.io_write, - ); - - // Bottom Row: Journal Events - if let Some(journal) = data.journal { - self.render_journal_events(f, main_chunks[2], journal); - } else { - self.render_loading_journal_events(f, main_chunks[2]); - } - } else if data.unsupported { - // Agent doesn't support this feature - self.render_unsupported_message(f, main_chunks[0]); - self.render_loading_middle_row(f, main_chunks[1]); - self.render_loading_journal_events(f, main_chunks[2]); - } else { - // Loading states for all sections - self.render_loading_top_row(f, main_chunks[0]); - self.render_loading_middle_row(f, main_chunks[1]); - self.render_loading_journal_events(f, main_chunks[2]); - } - - // Help line - let help_text = vec![Line::from(vec![ - Span::styled( - "X ", - Style::default() - .fg(super::theme::PROCESS_DETAILS_ACCENT) - .add_modifier(Modifier::BOLD), - ), - Span::styled("close ", Style::default().add_modifier(Modifier::DIM)), - Span::styled( - "P ", - Style::default() - .fg(super::theme::PROCESS_DETAILS_ACCENT) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - "goto parent ", - Style::default().add_modifier(Modifier::DIM), - ), - Span::styled( - "j/k ", - Style::default() - .fg(super::theme::PROCESS_DETAILS_ACCENT) - .add_modifier(Modifier::BOLD), - ), - Span::styled("threads ", Style::default().add_modifier(Modifier::DIM)), - Span::styled( - "[ ] ", - Style::default() - .fg(super::theme::PROCESS_DETAILS_ACCENT) - .add_modifier(Modifier::BOLD), - ), - Span::styled("journal", Style::default().add_modifier(Modifier::DIM)), - ])]; - let help = Paragraph::new(help_text).alignment(Alignment::Center); - f.render_widget(help, main_chunks[3]); - } - - fn render_thread_scatter_plot( - &self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - ) { - let plot_block = Block::default() - .title("Thread & Process CPU Time") - .borders(Borders::ALL); - - let inner = plot_block.inner(area); - - // Convert CPU times from microseconds to milliseconds for better readability - let main_user_ms = process.cpu_time_user as f64 / 1000.0; - let main_system_ms = process.cpu_time_system as f64 / 1000.0; - - // Calculate max values for scaling - let mut max_user = main_user_ms; - let mut max_system = main_system_ms; - - for child in &process.child_processes { - let child_user_ms = child.cpu_time_user as f64 / 1000.0; - let child_system_ms = child.cpu_time_system as f64 / 1000.0; - max_user = max_user.max(child_user_ms); - max_system = max_system.max(child_system_ms); - } - - // Add some padding to the scale - max_user = (max_user * 1.1).max(1.0); - max_system = (max_system * 1.1).max(1.0); - - // Render the existing scatter plot but in the smaller space - self.render_scatter_plot_content( - f, - inner, - ScatterPlotParams { - process, - main_user_ms, - main_system_ms, - max_user, - max_system, - }, - ); - - // Render the border - f.render_widget(plot_block, area); - } - - fn render_memory_io_graphs( - &self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - mem_history: &std::collections::VecDeque, - io_read_history: &std::collections::VecDeque, - io_write_history: &std::collections::VecDeque, - ) { - let graphs_block = Block::default() - .title("Memory & I/O") - .borders(Borders::ALL) - .padding(Padding::horizontal(1)); - - let mem_mb = process.mem_bytes as f64 / 1_048_576.0; - let virtual_mb = process.virtual_mem_bytes as f64 / 1_048_576.0; - - let mut content_lines = vec![ - Line::from(vec![ - Span::styled("🧠 Memory", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(""), // Small padding - ]), - Line::from(vec![ - Span::styled(" RSS: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{mem_mb:.1} MB")), - ]), - ]; - - // Add memory sparkline if we have history - if mem_history.len() >= 2 { - let mem_data: Vec = mem_history.iter().map(|&bytes| bytes / 1_048_576).collect(); // Convert to MB - let max_mem = mem_data.iter().copied().max().unwrap_or(1).max(1); - - // Create mini sparkline using Unicode blocks - let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; - let sparkline_str: String = mem_data - .iter() - .map(|&val| { - let level = ((val as f64 / max_mem as f64) * 7.0).round() as usize; - blocks[level.min(7)] - }) - .collect(); - - content_lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(sparkline_str, Style::default().fg(Color::Blue)), - ])); - } else { - content_lines.push(Line::from(vec![Span::styled( - " Collecting...", - Style::default().add_modifier(Modifier::DIM), - )])); - } - - content_lines.push(Line::from(vec![ - Span::styled(" Virtual: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{virtual_mb:.1} MB")), - ])); - - // Add shared memory if available - if let Some(shared_bytes) = process.shared_mem_bytes { - let shared_mb = shared_bytes as f64 / 1_048_576.0; - content_lines.push(Line::from(vec![ - Span::styled(" Shared: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{shared_mb:.1} MB")), - ])); - } - - content_lines.push(Line::from("")); - content_lines.push(Line::from(vec![ - Span::styled("💾 Disk I/O", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(""), // Small padding - ])); - - // Add I/O stats if available - match (process.read_bytes, process.write_bytes) { - (Some(read), Some(write)) => { - let read_mb = read as f64 / 1_048_576.0; - let write_mb = write as f64 / 1_048_576.0; - content_lines.push(Line::from(vec![ - Span::styled(" Read: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{read_mb:.1} MB")), - ])); - - // Add read I/O sparkline if we have history - if io_read_history.len() >= 2 { - let read_data: Vec = io_read_history - .iter() - .map(|&bytes| bytes / 1_048_576) - .collect(); // Convert to MB - let max_read = read_data.iter().copied().max().unwrap_or(1).max(1); - - let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; - let sparkline_str: String = read_data - .iter() - .map(|&val| { - let level = ((val as f64 / max_read as f64) * 7.0).round() as usize; - blocks[level.min(7)] - }) - .collect(); - - content_lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(sparkline_str, Style::default().fg(Color::Green)), - ])); - } - - content_lines.push(Line::from(vec![ - Span::styled(" Write: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{write_mb:.1} MB")), - ])); - - // Add write I/O sparkline if we have history - if io_write_history.len() >= 2 { - let write_data: Vec = io_write_history - .iter() - .map(|&bytes| bytes / 1_048_576) - .collect(); // Convert to MB - let max_write = write_data.iter().copied().max().unwrap_or(1).max(1); - - let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; - let sparkline_str: String = write_data - .iter() - .map(|&val| { - let level = ((val as f64 / max_write as f64) * 7.0).round() as usize; - blocks[level.min(7)] - }) - .collect(); - - content_lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(sparkline_str, Style::default().fg(Color::Yellow)), - ])); - } - } - _ => { - content_lines.push(Line::from(vec![Span::styled( - " Not available", - Style::default().add_modifier(Modifier::DIM), - )])); - } - } - - let content = Paragraph::new(content_lines).block(graphs_block); - - f.render_widget(content, area); - } - - fn render_thread_table( - &mut self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - ) { - let total_items = process.threads.len() + process.child_processes.len(); - - // Manually calculate inner area (like processes.rs does) - let inner_area = Rect { - x: area.x + 1, - y: area.y + 1, - width: area.width.saturating_sub(2), - height: area.height.saturating_sub(2), - }; - - // Calculate visible rows: inner height minus header (1 line) and header bottom margin (1 line) - let visible_rows = inner_area.height.saturating_sub(2).max(1) as usize; - - // Calculate and store max scroll for key handler bounds checking - self.thread_scroll_max = if total_items > visible_rows { - total_items.saturating_sub(visible_rows) - } else { - 0 - }; - - // Clamp scroll offset to valid range - let scroll_offset = self.thread_scroll_offset.min(self.thread_scroll_max); - - // Combine threads and processes into rows - let mut rows = Vec::new(); - - // Add threads first - for thread in &process.threads { - rows.push(Row::new(vec![ - Line::from(Span::styled("[T]", Style::default().fg(Color::Cyan))), - Line::from(format!("{}", thread.tid)), - Line::from(thread.name.clone()), - Line::from(thread.status.clone()), - ])); - } - - // Add child processes - for child in &process.child_processes { - rows.push(Row::new(vec![ - Line::from(Span::styled("[P]", Style::default().fg(Color::Green))), - Line::from(format!("{}", child.pid)), - Line::from(child.name.clone()), - Line::from(format!("{:.1}%", child.cpu_usage)), - ])); - } - - // Create table header - let header = Row::new(vec!["Type", "TID/PID", "Name", "Status/CPU"]) - .style(Style::default().add_modifier(Modifier::BOLD)) - .bottom_margin(1); - - let block = Block::default() - .title(format!( - "Threads ({}) & Children ({}) - j/k to scroll, u/d for 10x", - process.threads.len(), - process.child_processes.len() - )) - .borders(Borders::ALL) - .padding(Padding::horizontal(1)); - - let table = Table::new( - rows.iter().skip(scroll_offset).take(visible_rows).cloned(), - [ - Constraint::Length(6), - Constraint::Length(10), - Constraint::Min(15), - Constraint::Length(12), - ], - ) - .header(header) - .block(block) - .highlight_style(Style::default()); - - f.render_widget(table, area); - - // Render scrollbar if there are more items than visible - if total_items > visible_rows { - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("↑")) - .end_symbol(Some("↓")); - - // Use the same max_scroll value we use for clamping - // This ensures the scrollbar position matches our actual scroll range - let mut scrollbar_state = - ScrollbarState::new(self.thread_scroll_max).position(scroll_offset); - - let scrollbar_area = Rect { - x: area.x + area.width.saturating_sub(1), - y: area.y + 1, - width: 1, - height: area.height.saturating_sub(2), - }; - - f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); - } - } - - fn render_journal_events( - &mut self, - f: &mut Frame, - area: Rect, - journal: &socktop_connector::JournalResponse, - ) { - let total_entries = journal.entries.len(); - let visible_lines = area.height.saturating_sub(2) as usize; // Account for borders - - // Calculate and store max scroll for key handler bounds checking - self.journal_scroll_max = if total_entries > visible_lines { - total_entries.saturating_sub(visible_lines) - } else { - 0 - }; - - // Clamp scroll offset to valid range - let scroll_offset = self.journal_scroll_offset.min(self.journal_scroll_max); - - let journal_block = Block::default() - .title(format!( - "Journal Events ({total_entries} entries) - Use [ ] to scroll" - )) - .borders(Borders::ALL); - - let content_lines: Vec = if journal.entries.is_empty() { - vec![ - Line::from(""), - Line::from(Span::styled( - "No journal entries found for this process", - Style::default().add_modifier(Modifier::DIM), - )), - ] - } else { - journal - .entries - .iter() - .skip(scroll_offset) - .take(visible_lines) - .map(|entry| { - let priority_style = match entry.priority { - socktop_connector::LogLevel::Error - | socktop_connector::LogLevel::Critical => Style::default().fg(Color::Red), - socktop_connector::LogLevel::Warning => Style::default().fg(Color::Yellow), - socktop_connector::LogLevel::Info | socktop_connector::LogLevel::Notice => { - Style::default().fg(Color::Blue) - } - _ => Style::default(), - }; - - let timestamp = &entry.timestamp[..entry.timestamp.len().min(16)]; // Show just time - let message_max_len = area.width.saturating_sub(30) as usize; // Leave space for timestamp + priority - let message = &entry.message[..entry.message.len().min(message_max_len)]; - - Line::from(vec![ - Span::styled(timestamp, Style::default().add_modifier(Modifier::DIM)), - Span::raw(" "), - Span::styled( - format!("{:>7}", format!("{:?}", entry.priority)), - priority_style, - ), - Span::raw(" "), - Span::raw(message), - if entry.message.len() > message_max_len { - Span::styled("...", Style::default().add_modifier(Modifier::DIM)) - } else { - Span::raw("") - }, - ]) - }) - .collect() - }; - - let content = Paragraph::new(content_lines).block(journal_block); - - f.render_widget(content, area); - - // Render scrollbar if there are more entries than visible - if total_entries > visible_lines { - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("↑")) - .end_symbol(Some("↓")); - - // Use the same max_scroll value we use for clamping - let mut scrollbar_state = - ScrollbarState::new(self.journal_scroll_max).position(scroll_offset); - - let scrollbar_area = Rect { - x: area.x + area.width.saturating_sub(1), - y: area.y + 1, - width: 1, - height: area.height.saturating_sub(2), - }; - - f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); - } - } - - fn render_scatter_plot_content(&self, f: &mut Frame, area: Rect, params: ScatterPlotParams) { - if area.width < 20 || area.height < 10 { - // Area too small for meaningful plot - let content = Paragraph::new(vec![Line::from(Span::styled( - "Area too small for plot", - Style::default().fg(MODAL_HINT_FG), - ))]) - .alignment(Alignment::Center) - .style(Style::default().bg(MODAL_BG)); - f.render_widget(content, area); - return; - } - - // Calculate plot dimensions (leave space for axes labels + legend) - let plot_width = area.width.saturating_sub(8) as usize; // Leave space for Y-axis labels - let plot_height = area.height.saturating_sub(6) as usize; // Leave space for legend (3 lines) + X-axis labels (2 lines) + title (1 line) - - if plot_width == 0 || plot_height == 0 { - return; - } - - // Create a 2D grid to represent the plot - let mut plot_grid = vec![vec![' '; plot_width]; plot_height]; - - // Plot main process - let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; - let main_y = plot_height.saturating_sub(1).saturating_sub( - ((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, - ); - if main_x < plot_width && main_y < plot_height { - plot_grid[main_y][main_x] = '●'; // Main process marker - } - - // Plot threads (use different marker) - for thread in ¶ms.process.threads { - let thread_user_ms = thread.cpu_time_user as f64 / 1000.0; - let thread_system_ms = thread.cpu_time_system as f64 / 1000.0; - - let thread_x = ((thread_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; - let thread_y = plot_height.saturating_sub(1).saturating_sub( - ((thread_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, - ); - - if thread_x < plot_width && thread_y < plot_height { - if plot_grid[thread_y][thread_x] == ' ' { - plot_grid[thread_y][thread_x] = '○'; // Thread marker (hollow circle) - } else if plot_grid[thread_y][thread_x] == '○' { - plot_grid[thread_y][thread_x] = '◎'; // Multiple threads at same point - } else { - plot_grid[thread_y][thread_x] = '◉'; // Mixed threads/processes at same point - } - } - } - - // Plot child processes - for child in ¶ms.process.child_processes { - let child_user_ms = child.cpu_time_user as f64 / 1000.0; - let child_system_ms = child.cpu_time_system as f64 / 1000.0; - - let child_x = ((child_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; - let child_y = plot_height.saturating_sub(1).saturating_sub( - ((child_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, - ); - - if child_x < plot_width && child_y < plot_height { - if plot_grid[child_y][child_x] == ' ' { - plot_grid[child_y][child_x] = '•'; // Child process marker - } else { - plot_grid[child_y][child_x] = '◉'; // Multiple items at same point - } - } - } - - // Render the plot - let mut lines = Vec::new(); - - // Add Y-axis labels and plot content - for (i, row) in plot_grid.iter().enumerate() { - let y_value = params.max_system * (1.0 - (i as f64 / (plot_height - 1) as f64)); - // Always format with 4 characters width, right-aligned, to prevent axis shifting - let y_label = if y_value >= 100.0 { - format!("{y_value:>4.0}") - } else { - format!("{y_value:>4.1}") - }; - - let plot_content: String = row.iter().collect(); - - lines.push(Line::from(vec![ - Span::styled(y_label, Style::default()), - Span::styled(" │", Style::default()), - Span::styled(plot_content, Style::default()), - ])); - } - - // Add X-axis - let x_axis_padding = " ".to_string(); // Match Y-axis label width - let x_axis_line = "─".repeat(plot_width + 1); - lines.push(Line::from(vec![ - Span::styled(x_axis_padding, Style::default()), - Span::styled(x_axis_line, Style::default()), - ])); - - // Add X-axis labels - let x_label_start = "0.0".to_string(); - let x_label_mid = format!("{:.1}", params.max_user / 2.0); - let x_label_end = format!("{:.1}", params.max_user); - - let spacing = plot_width / 3; - let x_labels = format!( - " {}{}{}{}{}", - x_label_start, - " ".repeat(spacing.saturating_sub(x_label_start.len())), - x_label_mid, - " ".repeat(spacing.saturating_sub(x_label_mid.len())), - x_label_end - ); - - lines.push(Line::from(vec![Span::styled(x_labels, Style::default())])); - - // Add axis titles with better visibility - lines.push(Line::from(vec![Span::styled( - " User CPU Time (ms) →", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )])); - - // Add Y-axis label and legend at the top - lines.insert( - 0, - Line::from(vec![Span::styled( - "↑ System CPU Time (ms)", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )]), - ); - lines.insert( - 1, - Line::from(vec![Span::styled( - "● Main ○ Thread • Child ◉ Multiple", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::DIM), - )]), - ); - lines.insert(2, Line::from("")); // Spacing after legend - - let content = Paragraph::new(lines) - .style(Style::default()) - .alignment(Alignment::Left); - - f.render_widget(content, area); - } - - fn render_loading_top_row(&self, f: &mut Frame, area: Rect) { - let top_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) - .split(area); - - self.render_loading_metadata(f, top_chunks[0]); - self.render_loading_scatter(f, top_chunks[1]); - } - - fn render_loading_middle_row(&self, f: &mut Frame, area: Rect) { - let middle_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(30), - Constraint::Percentage(40), - Constraint::Percentage(30), - ]) - .split(area); - - self.render_loading_graphs(f, middle_chunks[0]); - self.render_loading_table(f, middle_chunks[1]); - self.render_loading_command(f, middle_chunks[2]); - } - - fn render_loading_metadata(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Process Info & CPU History") - .borders(Borders::ALL); - - let content = Paragraph::new("Loading process metadata...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_loading_scatter(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Thread CPU Time Distribution") - .borders(Borders::ALL); - - let content = Paragraph::new("Loading CPU time data...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_loading_graphs(&self, f: &mut Frame, area: Rect) { - let block = Block::default().title("Memory & I/O").borders(Borders::ALL); - - let content = Paragraph::new("Loading memory & I/O data...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_loading_table(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Child Processes") - .borders(Borders::ALL); - - let content = Paragraph::new("Loading child process data...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_loading_command(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Command & Details") - .borders(Borders::ALL); - - let content = Paragraph::new("Loading command details...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_loading_journal_events(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Journal Events") - .borders(Borders::ALL); - - let content = Paragraph::new("Loading journal entries...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_unsupported_message(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Process Details") - .borders(Borders::ALL); - - let content = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled( - "⚠ Agent Update Required", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - "This agent version does not support per-process metrics.", - Style::default().add_modifier(Modifier::DIM), - )), - Line::from(Span::styled( - "Please update your socktop_agent to the latest version.", - Style::default().add_modifier(Modifier::DIM), - )), - ]) - .block(block) - .alignment(Alignment::Center); - - f.render_widget(content, area); - } - - fn render_top_row_with_sparkline( - &self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - cpu_history: &std::collections::VecDeque, - ) { - // Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%) - let top_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(60), // CPU sparkline - Constraint::Percentage(40), // Thread scatter plot - ]) - .split(area); - - self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history); - self.render_thread_scatter_plot(f, top_chunks[1], process); - } - - fn render_cpu_sparkline( - &self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - cpu_history: &std::collections::VecDeque, - ) { - // Normalize CPU to 0-100% by dividing by thread count - // This shows per-core utilization rather than total utilization across all cores - let thread_count = process.thread_count; - - // Calculate actual average and current (normalized to 0-100%) - let current_cpu = normalize_cpu_usage( - cpu_history.back().copied().unwrap_or(0.0), - thread_count - ); - let avg_cpu = if cpu_history.is_empty() { - 0.0 - } else { - let total: f32 = cpu_history.iter().sum(); - normalize_cpu_usage(total / cpu_history.len() as f32, thread_count) - }; - let title = format!("📊 CPU avg: {avg_cpu:.1}% (now: {current_cpu:.1}%)"); - - // Similar to main CPU rendering but for process CPU - if cpu_history.len() < 2 { - let block = Block::default().title(title).borders(Borders::ALL); - let inner = block.inner(area); - f.render_widget(block, area); - - let content = Paragraph::new("Collecting CPU history data...") - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - f.render_widget(content, inner); - return; - } - - let max_points = area.width.saturating_sub(10) as usize; // Leave room for Y-axis labels - let start = cpu_history.len().saturating_sub(max_points); - - // Create data points for the chart (normalized to 0-100%) - let data: Vec<(f64, f64)> = cpu_history - .iter() - .skip(start) - .enumerate() - .map(|(i, &val)| { - let normalized = normalize_cpu_usage(val, thread_count); - (i as f64, normalized as f64) - }) - .collect(); - - let datasets = vec![ - Dataset::default() - .name("CPU %") - .marker(ratatui::symbols::Marker::Braille) - .graph_type(GraphType::Line) - .style(Style::default().fg(Color::Cyan)) - .data(&data), - ]; - - let x_max = data.len().max(1) as f64; - - // Dynamic Y-axis scaling in 10% increments - let max_cpu = data.iter().map(|(_, y)| *y).fold(0.0f64, f64::max); - let y_max = calculate_dynamic_y_max(max_cpu); - - let y_labels = vec![ - Line::from("0%"), - Line::from(format!("{:.0}%", y_max / 2.0)), - Line::from(format!("{y_max:.0}%")), - ]; - - let chart = Chart::new(datasets) - .block(Block::default().borders(Borders::ALL).title(title)) - .x_axis( - Axis::default() - .style(Style::default().fg(Color::Gray)) - .bounds([0.0, x_max]), - ) - .y_axis( - Axis::default() - .style(Style::default().fg(Color::Gray)) - .labels(y_labels) - .bounds([0.0, y_max]), - ); - - f.render_widget(chart, area); - } - - fn render_middle_row_with_metadata( - &mut self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - mem_history: &std::collections::VecDeque, - io_read_history: &std::collections::VecDeque, - io_write_history: &std::collections::VecDeque, - ) { - // Split middle row: Memory/IO (30%) | Thread table (40%) | Command + Metadata (30%) - let middle_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(30), - Constraint::Percentage(40), - Constraint::Percentage(30), - ]) - .split(area); - - self.render_memory_io_graphs( - f, - middle_chunks[0], - process, - mem_history, - io_read_history, - io_write_history, - ); - self.render_thread_table(f, middle_chunks[1], process); - self.render_command_and_metadata(f, middle_chunks[2], process); - } - - fn render_command_and_metadata( - &self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - ) { - let details_block = Block::default() - .title("Command & Details") - .borders(Borders::ALL) - .padding(Padding::horizontal(1)); - - // Calculate uptime - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let uptime_secs = now.saturating_sub(process.start_time); - let uptime_str = format_uptime(uptime_secs); - - // Format CPU times - let user_time_sec = process.cpu_time_user as f64 / 1_000_000.0; - let system_time_sec = process.cpu_time_system as f64 / 1_000_000.0; - - let mut content_lines = vec![ - Line::from(vec![ - Span::styled("⚡ Status: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(&process.status), - ]), - Line::from(vec![ - Span::styled("⏱️ Uptime: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(uptime_str), - ]), - Line::from(vec![ - Span::styled("🧵 Threads: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format!("{}", process.thread_count)), - ]), - Line::from(vec![ - Span::styled("👶 Children: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format!("{}", process.child_processes.len())), - ]), - ]; - - // Add file descriptors if available - if let Some(fd_count) = process.fd_count { - content_lines.push(Line::from(vec![ - Span::styled("📁 FDs: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format!("{fd_count}")), - ])); - } - - content_lines.push(Line::from("")); - - // Process hierarchy with clickable parent PID - if let Some(ppid) = process.parent_pid { - content_lines.push(Line::from(vec![ - Span::styled("👪 Parent: ", Style::default().add_modifier(Modifier::BOLD)), - Span::styled( - format!("{ppid}"), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::UNDERLINED), - ), - Span::styled( - " [P]", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::DIM), - ), - ])); - } - - content_lines.push(Line::from(vec![ - Span::styled("👤 UID: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format!("{}", process.user_id)), - Span::styled(" 👥 GID: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format!("{}", process.group_id)), - ])); - - content_lines.push(Line::from("")); - content_lines.push(Line::from(vec![Span::styled( - "⏲️ CPU Time", - Style::default().add_modifier(Modifier::BOLD), - )])); - content_lines.push(Line::from(vec![ - Span::styled(" User: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{user_time_sec:.2}s")), - ])); - content_lines.push(Line::from(vec![ - Span::styled(" System: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{system_time_sec:.2}s")), - ])); - - content_lines.push(Line::from("")); - - // Executable path if available - if let Some(exe) = &process.executable_path { - content_lines.push(Line::from(vec![Span::styled( - "📂 Executable", - Style::default().add_modifier(Modifier::BOLD), - )])); - // Truncate if too long - let max_width = (area.width.saturating_sub(6)) as usize; - if exe.len() > max_width { - let truncated = format!("...{}", &exe[exe.len().saturating_sub(max_width - 3)..]); - content_lines.push(Line::from(vec![Span::styled( - format!(" {truncated}"), - Style::default().add_modifier(Modifier::DIM), - )])); - } else { - content_lines.push(Line::from(vec![Span::styled( - format!(" {exe}"), - Style::default().add_modifier(Modifier::DIM), - )])); - } - } - - // Working directory if available - if let Some(cwd) = &process.working_directory { - content_lines.push(Line::from("")); - content_lines.push(Line::from(vec![Span::styled( - "📁 Working Dir", - Style::default().add_modifier(Modifier::BOLD), - )])); - // Truncate if too long - let max_width = (area.width.saturating_sub(6)) as usize; - if cwd.len() > max_width { - let truncated = format!("...{}", &cwd[cwd.len().saturating_sub(max_width - 3)..]); - content_lines.push(Line::from(vec![Span::styled( - format!(" {truncated}"), - Style::default().add_modifier(Modifier::DIM), - )])); - } else { - content_lines.push(Line::from(vec![Span::styled( - format!(" {cwd}"), - Style::default().add_modifier(Modifier::DIM), - )])); - } - } - - content_lines.push(Line::from("")); - - // Add command line (wrap if needed) - content_lines.push(Line::from(vec![Span::styled( - "⚙️ Command", - Style::default().add_modifier(Modifier::BOLD), - )])); - - - // Split command into multiple lines if too long - let cmd_text = &process.command; - let max_width = (area.width.saturating_sub(6)) as usize; // More conservative to avoid wrapping issues - if cmd_text.len() > max_width { - for chunk in cmd_text.as_bytes().chunks(max_width) { - if let Ok(s) = std::str::from_utf8(chunk) { - content_lines.push(Line::from(vec![Span::styled( - format!(" {s}"), - Style::default().add_modifier(Modifier::DIM), - )])); - } - } - } else { - content_lines.push(Line::from(vec![Span::styled( - format!(" {cmd_text}"), - Style::default().add_modifier(Modifier::DIM), - )])); - } - - let content = Paragraph::new(content_lines).block(details_block); - - f.render_widget(content, area); - } -} diff --git a/socktop/src/ui/cpu.rs b/socktop/src/ui/cpu.rs index e78add0..eb10a76 100644 --- a/socktop/src/ui/cpu.rs +++ b/socktop/src/ui/cpu.rs @@ -589,6 +589,7 @@ mod render_tests { fn fake_metrics(cores: Vec) -> Metrics { Metrics { + sampled_at_ms: None, cpu_total: 0.0, cpu_per_core: cores, mem_total: 1024, diff --git a/socktop/src/ui/disks.rs b/socktop/src/ui/disks.rs index 543c8b0..ba21d6a 100644 --- a/socktop/src/ui/disks.rs +++ b/socktop/src/ui/disks.rs @@ -1,7 +1,8 @@ //! Disk cards with per-device gauge and title line. use crate::types::Metrics; -use crate::ui::util::{disk_icon, human, truncate_middle}; +use crate::ui::fit::truncate_middle_cols; +use crate::ui::util::{disk_icon, human}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::Style, @@ -69,7 +70,7 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { "{}{}{}{} {} / {} ({}%)", indent, disk_icon(&d.name), - truncate_middle(&d.name, (slot.width.saturating_sub(6)) as usize / 2), + truncate_middle_cols(&d.name, slot.width.saturating_sub(6) / 2), temp_str, human(used), human(d.total), diff --git a/socktop/src/ui/fit.rs b/socktop/src/ui/fit.rs index 75011af..48b3df2 100644 --- a/socktop/src/ui/fit.rs +++ b/socktop/src/ui/fit.rs @@ -43,6 +43,46 @@ pub fn truncate_cols(s: &str, max: u16) -> String { out } +/// Shortens `s` to at most `max` columns by cutting the MIDDLE, marking the +/// cut with `…` — device names like `/dev/nvme0n1p1` keep their distinctive +/// prefix and suffix. Column- and char-boundary-safe; the byte-slicing +/// predecessor in `util.rs` panicked on non-ASCII names. +pub fn truncate_middle_cols(s: &str, max: u16) -> String { + if cols(s) <= max { + return s.to_string(); + } + if max <= 1 { + return truncate_cols(s, max); + } + // Reserve one column for the ellipsis; split the rest left/right. + let left_budget = (max - 1) / 2; + let right_budget = max - 1 - left_budget; + + let mut left_end = 0; // byte index + let mut used = 0u16; + for (i, ch) in s.char_indices() { + let w = cols(ch.encode_utf8(&mut [0u8; 4])); + if used + w > left_budget { + break; + } + used += w; + left_end = i + ch.len_utf8(); + } + + let mut right_start = s.len(); + let mut used = 0u16; + for (i, ch) in s.char_indices().rev() { + let w = cols(ch.encode_utf8(&mut [0u8; 4])); + if used + w > right_budget || i < left_end { + break; + } + used += w; + right_start = i; + } + + format!("{}…{}", &s[..left_end], &s[right_start..]) +} + /// Picks the first (richest) candidate pair that fits side by side in `width` columns /// with at least `gap` columns between them. /// @@ -108,6 +148,23 @@ mod tests { assert_eq!(truncate_cols("🔒ab", 2), "…"); } + /// Middle truncation keeps both ends — the parts that identify a device — + /// and must never exceed the budget or split a character. + #[test] + fn truncate_middle_keeps_both_ends_within_budget() { + assert_eq!(truncate_middle_cols("/dev/nvme0n1p1", 20), "/dev/nvme0n1p1"); + let out = truncate_middle_cols("/dev/nvme0n1p1", 9); + assert_eq!(cols(&out), 9); + assert!(out.starts_with("/dev"), "{out}"); + assert!(out.ends_with("1p1"), "{out}"); + assert!(out.contains('…'), "{out}"); + // Non-ASCII names must not panic (the old byte-slicing version did). + for max in 0..12u16 { + let out = truncate_middle_cols("диск-🗄️-данные", max); + assert!(cols(&out) <= max.max(1), "{out:?} exceeds {max}"); + } + } + #[test] fn pick_pair_takes_the_richest_that_fits() { let candidates = [ diff --git a/socktop/src/ui/gpu.rs b/socktop/src/ui/gpu.rs index d246c5f..c35153a 100644 --- a/socktop/src/ui/gpu.rs +++ b/socktop/src/ui/gpu.rs @@ -249,6 +249,7 @@ mod render_tests { fn metrics(gpus: Option>) -> Metrics { Metrics { + sampled_at_ms: None, cpu_total: 0.0, cpu_per_core: vec![], mem_total: 1024, diff --git a/socktop/src/ui/modal_process.rs b/socktop/src/ui/modal_process.rs index 9b9ace7..a3f91e5 100644 --- a/socktop/src/ui/modal_process.rs +++ b/socktop/src/ui/modal_process.rs @@ -472,13 +472,28 @@ impl ModalManager { .borders(Borders::ALL); let content_lines: Vec = if journal.entries.is_empty() { - vec![ + let mut lines = vec![ Line::from(""), Line::from(Span::styled( "No journal entries found for this process", Style::default().add_modifier(Modifier::DIM), )), - ] + ]; + // Access limits, not absence of logs: show journalctl's own hint + // (typical when the agent runs as an unprivileged user, e.g. demo + // mode) plus the practical fix. + if let Some(notice) = &journal.notice { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!("⚠ {notice}"), + Style::default().fg(Color::Yellow), + ))); + lines.push(Line::from(Span::styled( + " Run the agent as a service (or a user in the systemd-journal group) for full journal access.", + Style::default().add_modifier(Modifier::DIM), + ))); + } + lines } else { journal .entries @@ -624,16 +639,29 @@ impl ModalManager { // labels + axis title + (top) Y-axis title + legend + spacing. let mut lines: Vec = Vec::with_capacity(plot_height + 6); - // Y-axis labels and plot content - let mut row_buf = String::with_capacity(plot_width); - for y in 0..plot_height { - let y_value = params.max_system * (1.0 - (y as f64 / (plot_height - 1).max(1) as f64)); - // 4-char fixed-width label so the axis doesn't shift as digits change. - let y_label = if y_value >= 100.0 { - format!("{y_value:>4.0}") + // Format a CPU-time value: whole ms once past 100, one decimal below. + let fmt_ms = |v: f64| { + if v >= 100.0 { + format!("{v:.0}") } else { - format!("{y_value:>4.1}") - }; + format!("{v:.1}") + } + }; + + // Y-axis labels, right-aligned to the widest value this frame so the + // axis stays a straight line. The old fixed 4-char field predates the + // CPU-time unit fix; honest millisecond values (e.g. 136114) blew + // through it and skewed the whole axis. + let y_values: Vec = (0..plot_height) + .map(|y| { + fmt_ms(params.max_system * (1.0 - (y as f64 / (plot_height - 1).max(1) as f64))) + }) + .collect(); + let y_label_w = y_values.iter().map(|s| s.len()).max().unwrap_or(4).max(4); + + let mut row_buf = String::with_capacity(plot_width); + for (y, y_value) in y_values.iter().enumerate() { + let y_label = format!("{y_value:>y_label_w$}"); // Build the row's char slice into a reusable String buffer. row_buf.clear(); @@ -650,8 +678,8 @@ impl ModalManager { ])); } - // Add X-axis - let x_axis_padding = " ".to_string(); // Match Y-axis label width + // Add X-axis (padding = Y label width + the space before the bar) + let x_axis_padding = " ".repeat(y_label_w + 1); let x_axis_line = "─".repeat(plot_width + 1); lines.push(Line::from(vec![ Span::styled(x_axis_padding, Style::default()), @@ -659,13 +687,14 @@ impl ModalManager { ])); // Add X-axis labels - let x_label_start = "0.0".to_string(); - let x_label_mid = format!("{:.1}", params.max_user / 2.0); - let x_label_end = format!("{:.1}", params.max_user); + let x_label_start = fmt_ms(0.0); + let x_label_mid = fmt_ms(params.max_user / 2.0); + let x_label_end = fmt_ms(params.max_user); let spacing = plot_width / 3; let x_labels = format!( - " {}{}{}{}{}", + "{}{}{}{}{}{}", + " ".repeat(y_label_w + 1), x_label_start, " ".repeat(spacing.saturating_sub(x_label_start.len())), x_label_mid, @@ -677,7 +706,7 @@ impl ModalManager { // Add axis titles with better visibility lines.push(Line::from(vec![Span::styled( - " User CPU Time (ms) →", + format!("{}User CPU Time (ms) →", " ".repeat(y_label_w + 1)), Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), diff --git a/socktop/src/ui/processes.rs b/socktop/src/ui/processes.rs index 33fd194..3e78bd9 100644 --- a/socktop/src/ui/processes.rs +++ b/socktop/src/ui/processes.rs @@ -499,7 +499,6 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces } } -/// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End) /// Parameters for process key event handling pub struct ProcessKeyParams<'a> { pub selected_process_pid: &'a mut Option, @@ -509,16 +508,6 @@ pub struct ProcessKeyParams<'a> { pub filtered_indices: &'a [usize], } -/// LEGACY: Use processes_handle_key_with_selection for enhanced functionality -#[allow(dead_code)] -pub fn processes_handle_key( - scroll_offset: &mut usize, - key: crossterm::event::KeyEvent, - page_size: usize, -) { - crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size); -} - pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool { use crossterm::event::KeyCode; @@ -598,83 +587,6 @@ pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool { } } -/// Handle mouse for content scrolling and scrollbar dragging. -/// Returns Some(new_sort) if the header "CPU %" or "Mem" was clicked. -/// LEGACY: Use processes_handle_mouse_with_selection for enhanced functionality -#[allow(dead_code)] -pub fn processes_handle_mouse( - scroll_offset: &mut usize, - drag: &mut Option, - mouse: MouseEvent, - area: Rect, - total_rows: usize, -) -> Option { - // Inner and content areas (match draw_top_processes) - let inner = Rect { - x: area.x + 1, - y: area.y + 1, - width: area.width.saturating_sub(2), - height: area.height.saturating_sub(2), - }; - if inner.height == 0 || inner.width <= 2 { - return None; - } - let content = Rect { - x: inner.x, - y: inner.y, - width: inner.width.saturating_sub(2), - height: inner.height, - }; - - // Scrollbar interactions (click arrows/page/drag) - per_core_handle_scrollbar_mouse(scroll_offset, drag, mouse, area, total_rows); - - // Wheel scrolling when inside the content - crate::ui::cpu::per_core_handle_mouse(scroll_offset, mouse, content, content.height as usize); - - // Header click to change sort - let header_area = Rect { - x: content.x, - y: content.y, - width: content.width, - height: 1, - }; - let inside_header = mouse.row == header_area.y - && mouse.column >= header_area.x - && mouse.column < header_area.x + header_area.width; - - if inside_header && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - // Split the header the same way the draw path did, so a click lands on the - // column actually on screen even when PID has been dropped. - let columns = ProcColumns::for_width(header_area.width); - let cols = Layout::default() - .direction(Direction::Horizontal) - .constraints(columns.constraints()) - .spacing(COL_SPACING) // must match Table::column_spacing in the draw path - .split(header_area); - if let Some(cpu) = columns.cpu_index().map(|i| cols[i]) - && mouse.column >= cpu.x - && mouse.column < cpu.x + cpu.width - { - return Some(ProcSortBy::CpuDesc); - } - if let Some(mem) = columns.mem_index().map(|i| cols[i]) - && mouse.column >= mem.x - && mouse.column < mem.x + mem.width - { - return Some(ProcSortBy::MemDesc); - } - } - - // Clamp to valid range - per_core_clamp( - scroll_offset, - total_rows, - (content.height.saturating_sub(1)) as usize, - ); - None -} - /// Parameters for process mouse event handling pub struct ProcessMouseParams<'a> { pub scroll_offset: &'a mut usize, @@ -948,6 +860,7 @@ mod click_tests { fn metrics() -> Metrics { Metrics { + sampled_at_ms: None, cpu_total: 0.0, cpu_per_core: vec![], mem_total: 32_000_000_000, @@ -1003,20 +916,29 @@ mod click_tests { } fn click(width: u16, column: u16) -> Option { + let m = metrics(); let mut scroll = 0usize; let mut drag = None; - processes_handle_mouse( - &mut scroll, - &mut drag, - MouseEvent { + let mut sel_pid = None; + let mut sel_idx = None; + let idxs = [0usize]; + processes_handle_mouse_with_selection(ProcessMouseParams { + scroll_offset: &mut scroll, + selected_process_pid: &mut sel_pid, + selected_process_index: &mut sel_idx, + drag: &mut drag, + mouse: MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column, row: 1, modifiers: KeyModifiers::NONE, }, - Rect::new(0, 0, width, 8), - 1, - ) + area: Rect::new(0, 0, width, 8), + total_rows: 1, + metrics: Some(&m), + search_box_visible: false, + filtered_indices: &idxs, + }) } /// The hit-test rects are computed by a separate `Layout` call from the one `Table` diff --git a/socktop/src/ui/util.rs b/socktop/src/ui/util.rs index a0437cb..9e4f79c 100644 --- a/socktop/src/ui/util.rs +++ b/socktop/src/ui/util.rs @@ -22,19 +22,6 @@ pub fn human(b: u64) -> String { format!("{tb:.2}TB") } -pub fn truncate_middle(s: &str, max: usize) -> String { - if s.len() <= max { - return s.to_string(); - } - if max <= 3 { - return "...".into(); - } - let keep = max - 3; - let left = keep / 2; - let right = keep - left; - format!("{}...{}", &s[..left], &s[s.len() - right..]) -} - pub fn disk_icon(name: &str) -> &'static str { let n = name.to_ascii_lowercase(); if n.contains(':') { diff --git a/socktop/src/ws.rs b/socktop/src/ws.rs deleted file mode 100644 index e69de29..0000000 diff --git a/socktop/tests/profiles.rs b/socktop/tests/profiles.rs index fb37ba8..df7fdfc 100644 --- a/socktop/tests/profiles.rs +++ b/socktop/tests/profiles.rs @@ -8,6 +8,7 @@ static ENV_LOCK: Mutex<()> = Mutex::new(()); #[allow(dead_code)] // touch crate fn touch() { let _ = socktop::types::Metrics { + sampled_at_ms: None, cpu_total: 0.0, cpu_per_core: vec![], mem_total: 0, diff --git a/socktop_agent/Cargo.toml b/socktop_agent/Cargo.toml index 6de4b95..ff700c5 100644 --- a/socktop_agent/Cargo.toml +++ b/socktop_agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "socktop_agent" -version = "1.50.2" +version = "1.60.0" authors = ["Jason Witty "] description = "Socktop agent daemon. Serves host metrics over WebSocket." edition = "2024" @@ -10,11 +10,10 @@ homepage = "https://github.com/jasonwitty/socktop" repository = "https://github.com/jasonwitty/socktop" [dependencies] -# Tokio: Use minimal features instead of "full" to reduce binary size -# Only include: rt-multi-thread (async runtime), net (WebSocket), sync (Mutex/RwLock), macros (#[tokio::test]) -# Excluded: io, fs, process, signal, time (not needed for this workload) -# Savings: ~200-300KB binary size, faster compile times -tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros"] } +# Tokio: minimal features instead of "full" to reduce binary size. +# rt-multi-thread (runtime), net (WebSocket), sync (Mutex/oneshot), +# macros (#[tokio::test]), process (async journalctl). +tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "process"] } axum = { version = "0.7", features = ["ws", "macros"] } sysinfo = { version = "0.37", features = ["network", "disk", "component"] } serde = { version = "1", features = ["derive"] } @@ -24,6 +23,10 @@ futures-util = "0.3.31" tracing = { version = "0.1", optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } gfxinfo = { version = "0.1.2", optional = true } +# Direct NVML fallback for distros that ship only libnvidia-ml.so.1 (Debian +# and derivatives) — gfxinfo's default init dlopens the unversioned name. +# Same version gfxinfo already pulls in, so this adds no new build cost. +nvml-wrapper = { version = "0.10", optional = true } once_cell = "1.19" axum-server = { version = "0.7", features = ["tls-rustls"] } rustls = { version = "0.23", features = ["aws-lc-rs"] } @@ -36,7 +39,7 @@ time = { version = "0.3", default-features = false, features = ["formatting", "m [features] default = ["gpu"] -gpu = ["gfxinfo"] +gpu = ["gfxinfo", "nvml-wrapper"] logging = ["tracing", "tracing-subscriber"] [build-dependencies] diff --git a/socktop_agent/build.rs b/socktop_agent/build.rs index cb34d8a..ecfb2fa 100644 --- a/socktop_agent/build.rs +++ b/socktop_agent/build.rs @@ -1,13 +1,15 @@ fn main() { - // Vendored protoc for reproducible builds - let protoc = protoc_bin_vendored::protoc_bin_path().expect("protoc"); - println!("cargo:rerun-if-changed=proto/processes.proto"); // Compile protobuf definitions for processes let mut cfg = prost_build::Config::new(); cfg.out_dir(std::env::var("OUT_DIR").unwrap()); - cfg.protoc_executable(protoc); // Use the vendored protoc directly + // Vendored protoc for reproducible builds where available. It ships no + // riscv64 binary, so on such hosts fall through to $PROTOC / PATH + // (prost-build's default lookup) — apt: protobuf-compiler. + if let Ok(protoc) = protoc_bin_vendored::protoc_bin_path() { + cfg.protoc_executable(protoc); + } // Use local path (ensures file is inside published crate tarball) cfg.compile_protos(&["proto/processes.proto"], &["proto"]) // relative to CARGO_MANIFEST_DIR .expect("compile protos"); diff --git a/socktop_agent/src/gpu.rs b/socktop_agent/src/gpu.rs index 9e0eb08..861b2e8 100644 --- a/socktop_agent/src/gpu.rs +++ b/socktop_agent/src/gpu.rs @@ -1,6 +1,4 @@ // gpu.rs -#[cfg(feature = "gpu")] -use gfxinfo::active_gpu; #[derive(Debug, Clone, serde::Serialize)] pub struct GpuMetrics { @@ -10,23 +8,118 @@ pub struct GpuMetrics { pub mem_total_bytes: u64, } +/// Collect metrics for the active GPU. `None` when there is no usable GPU. +/// +/// Runs on a dedicated worker thread (see `worker`): gfxinfo's handle holds +/// an `Rc` (not `Send`), and *creating* it runs a full NVML library +/// init — ~20ms of blocking work that used to execute on the async runtime +/// for every collection. The worker owns one handle for the process lifetime, +/// so steady-state collection is just NVML queries. Measured on an RTX 5080 +/// box, re-initing per collect was ~80% of the agent's entire active CPU. #[cfg(feature = "gpu")] -pub fn collect_all_gpus() -> Result, Box> { - let gpu = active_gpu()?; // Use ? to unwrap Result - let info = gpu.info(); - - let metrics = GpuMetrics { - name: gpu.model().to_string(), - utilization_gpu_pct: info.load_pct() as u32, - mem_used_bytes: info.used_vram(), - mem_total_bytes: info.total_vram(), - }; - - Ok(vec![metrics]) +pub async fn collect_all_gpus() -> Option> { + worker::collect().await } #[cfg(not(feature = "gpu"))] -pub fn collect_all_gpus() -> Result, Box> { - // GPU support not available on this platform - Ok(vec![]) +pub async fn collect_all_gpus() -> Option> { + None +} + +#[cfg(feature = "gpu")] +mod worker { + use super::GpuMetrics; + use once_cell::sync::OnceCell; + use std::sync::mpsc; + + type Reply = tokio::sync::oneshot::Sender>>; + static TX: OnceCell> = OnceCell::new(); + + pub async fn collect() -> Option> { + let tx = TX.get_or_init(spawn); + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + tx.send(reply_tx).ok()?; + reply_rx.await.ok().flatten() + } + + fn spawn() -> mpsc::Sender { + let (tx, rx) = mpsc::channel::(); + std::thread::Builder::new() + .name("socktop-gpu".into()) + .spawn(move || run(rx)) + .expect("spawn gpu worker thread"); + tx + } + + enum Handle { + /// gfxinfo's own detection (AMD sysfs, NVIDIA via unversioned NVML). + Gfx(Box), + /// Direct NVML with an explicit versioned soname. Debian & friends + /// ship only libnvidia-ml.so.1 (the unversioned symlink lives in the + /// dev package), so gfxinfo's default dlopen fails there even though + /// the driver is fully functional. + Nvml(Box), + } + + fn probe() -> Option { + if let Ok(g) = gfxinfo::active_gpu() { + return Some(Handle::Gfx(g)); + } + nvml_wrapper::Nvml::builder() + .lib_path(std::ffi::OsStr::new("libnvidia-ml.so.1")) + .init() + .ok() + .map(|nvml| Handle::Nvml(Box::new(nvml))) + } + + fn collect_from(handle: &Handle) -> Option> { + match handle { + Handle::Gfx(gpu) => { + let info = gpu.info(); + Some(vec![GpuMetrics { + name: gpu.model().to_string(), + utilization_gpu_pct: info.load_pct().clamp(0, 100), + mem_used_bytes: info.used_vram(), + mem_total_bytes: info.total_vram(), + }]) + } + Handle::Nvml(nvml) => { + let device = nvml.device_by_index(0).ok()?; + let mem = device.memory_info().ok()?; + Some(vec![GpuMetrics { + name: device.name().unwrap_or_else(|_| "NVIDIA GPU".into()), + utilization_gpu_pct: device + .utilization_rates() + .map(|u| u.gpu.clamp(0, 100)) + .unwrap_or(0), + mem_used_bytes: mem.used, + mem_total_bytes: mem.total, + }]) + } + } + } + + fn run(rx: mpsc::Receiver) { + let mut handle: Option = None; + // Probing failed: remember and answer None without re-initing the GPU + // stack per request. The agent's negative cache stops asking anyway. + let mut probe_failed = false; + while let Ok(reply) = rx.recv() { + if handle.is_none() && !probe_failed { + handle = probe(); + probe_failed = handle.is_none(); + } + let out = handle.as_ref().and_then(collect_from); + // A live GPU cannot report 0 total VRAM; zeros mean the session + // died (e.g. driver reload). Drop the handle so the next request + // re-probes. + if let Some(v) = &out + && !v.is_empty() + && v.iter().all(|g| g.mem_total_bytes == 0) + { + handle = None; + } + let _ = reply.send(out.filter(|v| !v.is_empty())); + } + } } diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index 0600fe6..ea1ad71 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -13,49 +13,60 @@ use std::collections::HashMap; use std::fs; #[cfg(target_os = "linux")] use std::io; -use std::process::Command; use std::sync::Mutex; use std::time::Duration as StdDuration; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use sysinfo::{ProcessRefreshKind, ProcessesToUpdate}; #[cfg(feature = "logging")] use tracing::warn; // NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo. -// Read (utime, stime) in milliseconds from /proc/{pid}/stat in one go. -// Returns (0, 0) if the file can't be read. -// -// We use `rfind(')')` to step past the `comm` field, which can contain -// arbitrary characters (including spaces and parens), then index the -// post-comm fields by position. This is the same trick `read_proc_jiffies` -// uses below — `split_whitespace().collect::>()` from the start of -// the file would mis-parse process names with spaces, and also wastes an -// allocation per call. Two callers used to read this file twice (once for -// user, once for system); now it's one syscall per detailed-process record. +/// Shared parsing for `/proc//stat` (and per-thread `task//stat`). +/// +/// The second field, `comm`, can contain arbitrary bytes including spaces and +/// parentheses, so naive whitespace splitting mis-parses such names. All +/// callers step past the LAST `')'` and index the remaining space-separated +/// fields from there: 0 = state, 1 = ppid, 11 = utime, 12 = stime, +/// 19 = starttime. #[cfg(target_os = "linux")] -fn get_cpu_times_ms(pid: u32) -> (u64, u64) { +mod procstat { + /// Everything after `") "` — the post-comm fields. + pub fn after_comm(stat: &str) -> Option<&str> { + stat.get(stat.rfind(')')? + 2..) + } + pub fn field(stat: &str, n: usize) -> Option<&str> { + after_comm(stat)?.split_whitespace().nth(n) + } + /// (utime, stime) in clock ticks. + pub fn utime_stime(stat: &str) -> Option<(u64, u64)> { + let mut it = after_comm(stat)?.split_whitespace(); + let utime = it.nth(11)?.parse().ok()?; + let stime = it.next()?.parse().ok()?; + Some((utime, stime)) + } + /// One clock tick at USER_HZ=100 (universal on Linux) in microseconds. + pub const TICK_US: u64 = 10_000; +} + +// Read (utime, stime) in MICROSECONDS from /proc/{pid}/stat in one syscall. +// Returns (0, 0) if the file can't be read. Units match the wire contract +// (`DetailedProcessInfo.cpu_time_user` is documented as µs) and the thread +// records — this used to return ms, making process/child CPU times render +// 1000x too small next to thread times. +#[cfg(target_os = "linux")] +fn get_cpu_times_us(pid: u32) -> (u64, u64) { let Ok(s) = fs::read_to_string(format!("/proc/{pid}/stat")) else { return (0, 0); }; - let Some(rpar) = s.rfind(')') else { + let Some((utime, stime)) = procstat::utime_stime(&s) else { return (0, 0); }; - let Some(after) = s.get(rpar + 2..) else { - return (0, 0); - }; - let mut it = after.split_whitespace(); - // Post-comm field offsets: state, ppid, pgrp, session, tty_nr, tpgid, - // flags, minflt, cminflt, majflt, cmajflt, utime, stime, ... - // utime is offset 11; stime follows. - let utime = it.nth(11).and_then(|s| s.parse::().ok()).unwrap_or(0); - let stime = it.next().and_then(|s| s.parse::().ok()).unwrap_or(0); - // 1 tick = 10ms at 100 Hz (USER_HZ). - (utime * 10, stime * 10) + (utime * procstat::TICK_US, stime * procstat::TICK_US) } #[cfg(not(target_os = "linux"))] -fn get_cpu_times_ms(_pid: u32) -> (u64, u64) { +fn get_cpu_times_us(_pid: u32) -> (u64, u64) { (0, 0) } // Runtime toggles (read once) @@ -117,46 +128,32 @@ fn name_cache_cleanup_threshold() -> usize { }) } -// Tiny TTL caches to avoid rescanning sensors every 500ms +// Tiny TTL caches to avoid rescanning sensors every 500ms. +// +// The cached type is Option<...>: a fresh `None` means "we looked recently +// and found nothing" — machines with no matching sensor/GPU no longer rescan +// on every request, only once per TTL. const TTL: Duration = Duration::from_millis(1500); -struct TempCache { - at: Option, - v: Option, -} -static TEMP: OnceCell> = OnceCell::new(); +static TEMP: crate::state::TtlCell> = crate::state::TtlCell::new(); +static GPUS: crate::state::TtlCell>> = + crate::state::TtlCell::new(); -// Last time `state.components` was refreshed (by any caller). Both +// Gate on `state.components` refreshes (hwmon scans). Both // collect_fast_metrics and collect_disks need fresh sensor values; without -// this gate they were each doing their own `Components::refresh` on their -// own cadence, paying the hwmon syscall cost twice per polling cycle. -// 1s is short enough that disk temps stay accurate (they change slowly) and -// long enough to suppress back-to-back refreshes from concurrent endpoints. +// this they each paid the hwmon syscall cost on their own cadence. 1s keeps +// disk temps accurate (they change slowly) while suppressing back-to-back +// refreshes from concurrent endpoints. const COMPONENTS_REFRESH_TTL: Duration = Duration::from_millis(1000); -static COMPONENTS_LAST_REFRESH: OnceCell>> = OnceCell::new(); +static COMPONENTS_STAMP: crate::state::TtlCell<()> = crate::state::TtlCell::new(); -/// Refresh `state.components` only if the cached refresh timestamp is older -/// than `COMPONENTS_REFRESH_TTL`. Caller must already hold the components -/// lock. +/// Refresh `state.components` at most once per `COMPONENTS_REFRESH_TTL`. +/// Caller must already hold the components lock. fn refresh_components_if_stale(components: &mut sysinfo::Components) { - let lock = COMPONENTS_LAST_REFRESH.get_or_init(|| Mutex::new(None)); - let mut last = match lock.lock() { - Ok(g) => g, - Err(_) => return, // Poisoned — skip; values stay as-is until next call - }; - let now = Instant::now(); - let stale = last.is_none_or(|t| now.duration_since(t) >= COMPONENTS_REFRESH_TTL); - if stale { + if COMPONENTS_STAMP.claim_stale(COMPONENTS_REFRESH_TTL) { components.refresh(false); - *last = Some(now); } } -struct GpuCache { - at: Option, - v: Option>, -} -static GPUC: OnceCell> = OnceCell::new(); - // Static caches for unchanging data static HOSTNAME: OnceCell = OnceCell::new(); struct NetworkNameCache { @@ -166,54 +163,6 @@ struct NetworkNameCache { static NETWORK_CACHE: OnceCell> = OnceCell::new(); static CPU_VEC: OnceCell>> = OnceCell::new(); -fn cached_temp() -> Option { - if !temp_enabled() { - return None; - } - let now = Instant::now(); - let lock = TEMP.get_or_init(|| Mutex::new(TempCache { at: None, v: None })); - let mut c = lock.lock().ok()?; - if c.at.is_none_or(|t| now.duration_since(t) >= TTL) { - c.at = Some(now); - // caller will fill this; we just hold a slot - c.v = None; - } - c.v -} - -fn set_temp(v: Option) { - if let Some(lock) = TEMP.get() - && let Ok(mut c) = lock.lock() - { - c.v = v; - c.at = Some(Instant::now()); - } -} - -fn cached_gpus() -> Option> { - if !gpu_enabled() { - return None; - } - let now = Instant::now(); - let lock = GPUC.get_or_init(|| Mutex::new(GpuCache { at: None, v: None })); - let mut c = lock.lock().ok()?; - if c.at.is_none_or(|t| now.duration_since(t) >= TTL) { - // mark stale; caller will refresh - c.at = Some(now); - c.v = None; - } - c.v.clone() -} - -fn set_gpus(v: Option>) { - if let Some(lock) = GPUC.get() - && let Ok(mut c) = lock.lock() - { - c.v = v.clone(); - c.at = Some(Instant::now()); - } -} - // Collect only fast-changing metrics (CPU/mem/net + optional temps/gpus). pub async fn collect_fast_metrics(state: &AppState) -> Metrics { let ttl = StdDuration::from_millis(metrics_ttl_ms()); @@ -253,10 +202,13 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics { let swap_used = sys.used_swap(); drop(sys); - // CPU temperature: only refresh sensors if cache is stale - let cpu_temp_c = if cached_temp().is_some() { - cached_temp() - } else if temp_enabled() { + // CPU temperature: only rescan sensors when the cached result (even a + // cached "no sensor found") goes stale. + let cpu_temp_c = if !temp_enabled() { + None + } else if let Some(cached) = TEMP.get_fresh(TTL) { + cached + } else { let val = { let mut components = state.components.lock().await; refresh_components_if_stale(&mut components); @@ -273,10 +225,8 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics { } }) }; - set_temp(val); + TEMP.set(val); val - } else { - None }; // Networks with reusable name cache @@ -320,47 +270,37 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics { cache.infos.clone() }; - // GPUs: if we already determined none exist, short-circuit (no repeated probing) - let gpus = if gpu_enabled() { - if state.gpu_checked.load(std::sync::atomic::Ordering::Acquire) - && !state.gpu_present.load(std::sync::atomic::Ordering::Relaxed) - { - None - } else if cached_gpus().is_some() { - cached_gpus() - } else { - let v = match collect_all_gpus() { - Ok(v) if !v.is_empty() => Some(v), - Ok(_) => None, - Err(_e) => { - #[cfg(feature = "logging")] - warn!("gpu collection failed: {_e}"); - None - } - }; - // First probe records presence; subsequent calls rely on cache flags. - if !state - .gpu_checked - .swap(true, std::sync::atomic::Ordering::AcqRel) - { - if v.is_some() { - state - .gpu_present - .store(true, std::sync::atomic::Ordering::Release); - } else { - state - .gpu_present - .store(false, std::sync::atomic::Ordering::Release); - } - } - set_gpus(v.clone()); - v - } - } else { + // GPUs: negative-probe cache short-circuits GPU-less hosts; otherwise the + // TTL cache answers, and only a stale miss reaches the worker thread. + let gpus = if !gpu_enabled() + || (state.gpu_checked.load(std::sync::atomic::Ordering::Acquire) + && !state.gpu_present.load(std::sync::atomic::Ordering::Relaxed)) + { None + } else if let Some(cached) = GPUS.get_fresh(TTL) { + cached + } else { + let v = collect_all_gpus().await; + // First probe records presence; subsequent calls rely on the flags. + if !state + .gpu_checked + .swap(true, std::sync::atomic::Ordering::AcqRel) + { + state + .gpu_present + .store(v.is_some(), std::sync::atomic::Ordering::Release); + } + GPUS.set(v.clone()); + v }; + let sampled_at_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let metrics = Metrics { + sampled_at_ms, cpu_total, cpu_per_core, mem_total, @@ -381,6 +321,48 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics { metrics } +/// Best-effort parent-disk name for a partition device name: +/// "nvme0n1p1" -> "nvme0n1", "mmcblk0p2" -> "mmcblk0", "sda1" -> "sda". +/// Works with or without a "/dev/" prefix. +fn parent_disk_name(name: &str) -> &str { + if let Some(pos) = name.rfind('p') { + let suffix = &name[pos + 1..]; + if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) { + return &name[..pos]; + } + } + name.trim_end_matches(|c: char| c.is_ascii_digit()) +} + +/// Whether a device name refers to a partition rather than a whole disk. +/// +/// On Linux, whole-disk devices are directories under /sys/block and +/// partitions are not, so "is a partition" = "not in /sys/block, but the +/// derived parent is". This gets right the cases the old name heuristic got +/// wrong: a whole-disk filesystem on nvme0n1 (ends in a digit but IS in +/// /sys/block) and zram1 (a whole device). Non-Linux keeps the heuristic. +fn is_partition_name(name: &str) -> bool { + let bare = name.strip_prefix("/dev/").unwrap_or(name); + #[cfg(target_os = "linux")] + { + let sys_block = std::path::Path::new("/sys/block"); + if sys_block.is_dir() { + return !sys_block.join(bare).is_dir() + && sys_block.join(parent_disk_name(bare)).is_dir(); + } + } + is_partition_heuristic(bare) +} + +/// Name-based fallback for platforms without /sys/block: a p marker +/// or a trailing non-zero digit. +fn is_partition_heuristic(bare: &str) -> bool { + bare.contains("p1") + || bare.contains("p2") + || bare.contains("p3") + || bare.ends_with(|c: char| c.is_ascii_digit() && c != '0') +} + // Cached disks pub async fn collect_disks(state: &AppState) -> Vec { let ttl = StdDuration::from_millis(disks_ttl_ms()); @@ -445,19 +427,7 @@ pub async fn collect_disks(state: &AppState) -> Vec { return None; } - // Determine if this is a partition - let is_partition = name.contains("p1") - || name.contains("p2") - || name.contains("p3") - || name.ends_with('1') - || name.ends_with('2') - || name.ends_with('3') - || name.ends_with('4') - || name.ends_with('5') - || name.ends_with('6') - || name.ends_with('7') - || name.ends_with('8') - || name.ends_with('9'); + let is_partition = is_partition_name(&name); // Try to find temperature for this disk let temperature = disk_temps.iter().find_map(|(key, &temp)| { @@ -491,25 +461,7 @@ pub async fn collect_disks(state: &AppState) -> Vec { for partition in &partitions { if partition.is_partition { - // Extract parent disk name - // nvme0n1p1 -> nvme0n1, sda1 -> sda, mmcblk0p1 -> mmcblk0 - let parent_name = if let Some(pos) = partition.name.rfind('p') { - // Check if character after 'p' is a digit - if partition - .name - .chars() - .nth(pos + 1) - .is_some_and(|c| c.is_ascii_digit()) - { - &partition.name[..pos] - } else { - // Handle sda1, sdb2, etc (just trim trailing digit) - partition.name.trim_end_matches(char::is_numeric) - } - } else { - // Handle sda1, sdb2, etc (just trim trailing digit) - partition.name.trim_end_matches(char::is_numeric) - }; + let parent_name = parent_disk_name(&partition.name); // Look up temperature for the PARENT disk, not the partition // Strip /dev/ prefix if present for matching @@ -553,21 +505,7 @@ pub async fn collect_disks(state: &AppState) -> Vec { // Add partitions after their parent disk for partition in partitions { if partition.is_partition { - // Find parent disk index - let parent_name = if let Some(pos) = partition.name.rfind('p') { - if partition - .name - .chars() - .nth(pos + 1) - .is_some_and(|c| c.is_ascii_digit()) - { - &partition.name[..pos] - } else { - partition.name.trim_end_matches(char::is_numeric) - } - } else { - partition.name.trim_end_matches(char::is_numeric) - }; + let parent_name = parent_disk_name(&partition.name); // Find where to insert this partition (after its parent) if let Some(parent_idx) = disks.iter().position(|d| d.name == parent_name) { @@ -619,15 +557,8 @@ fn read_total_jiffies() -> io::Result { #[cfg(target_os = "linux")] #[inline] fn read_proc_jiffies(pid: u32) -> Option { - let path = format!("/proc/{pid}/stat"); - let s = fs::read_to_string(path).ok()?; - // Find the right parenthesis that terminates comm; everything after is space-separated fields starting at "state" - let rpar = s.rfind(')')?; - let after = s.get(rpar + 2..)?; // skip ") " - let mut it = after.split_whitespace(); - // utime (14th field) is offset 11 from "state", stime (15th) is next - let utime = it.nth(11)?.parse::().ok()?; - let stime = it.next()?.parse::().ok()?; + let s = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let (utime, stime) = procstat::utime_stime(&s)?; Some(utime.saturating_add(stime)) } @@ -818,9 +749,12 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload { }; // Convert to percentage of total CPU capacity - // e.g., 100% on 2 cores of 8 core system = 25% total CPU - let raw = p.cpu_usage(); // This is per-core percentage - let total_cpu = raw.clamp(0.0, 100.0) / cpu_count; + // e.g., 100% on 2 cores of 8 core system = 25% total CPU. + // sysinfo reports per-core percentage which EXCEEDS 100 for + // multi-threaded processes, so clamp AFTER dividing — clamping + // first truncated e.g. 400%-on-8-cores to 12.5% instead of 50%. + let raw = p.cpu_usage(); + let total_cpu = (raw / cpu_count.max(1.0)).clamp(0.0, 100.0); proc_cache.reusable_vec.push(ProcessInfo { pid, @@ -984,15 +918,8 @@ fn proc_state_label(c: char) -> &'static str { #[cfg(target_os = "linux")] fn read_parent_pid_from_proc(pid: u32) -> Option { let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; - // Format: pid (comm) state ppid ... — comm can contain spaces/parens, - // so we step past the closing paren first. - let ppid_start = stat.rfind(')')?; - // After ") ": state, ppid, ... — ppid is the second field. - stat[ppid_start + 1..] - .split_whitespace() - .nth(1)? - .parse::() - .ok() + // Post-comm field 1 is ppid. + procstat::field(&stat, 1)?.parse::().ok() } /// Collect process information from /proc files @@ -1035,16 +962,9 @@ fn collect_process_info_from_proc( let thread_count = st.threads; let status = proc_state_label(st.state_ch).to_string(); - // Read start time from stat — comm-safe via rfind(')'). + // starttime is post-comm field 19. let start_time = if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { - let stat_end = stat.rfind(')')?; - // After ") ": state, ppid, ..., starttime — starttime is the 20th - // post-comm field (index 19). - stat[stat_end + 1..] - .split_whitespace() - .nth(19)? - .parse::() - .ok()? + procstat::field(&stat, 19)?.parse::().ok()? } else { 0 }; @@ -1079,7 +999,7 @@ fn collect_process_info_from_proc( .map(|p| p.to_string_lossy().to_string()); // One read of /proc/{pid}/stat covers both user + system CPU times. - let (cpu_time_user, cpu_time_system) = get_cpu_times_ms(pid); + let (cpu_time_user, cpu_time_system) = get_cpu_times_us(pid); Some(DetailedProcessInfo { pid, @@ -1192,18 +1112,7 @@ fn collect_thread_info(pid: u32) -> Vec { continue; }; - // Thread/comm names can contain spaces or parens, so step past the - // last ')' before parsing post-comm fields. Post-comm offsets: - // 0: state, 1: ppid, 2: pgrp, ..., 11: utime, 12: stime - let Some(rpar) = stat_content.rfind(')') else { - continue; - }; - let Some(after) = stat_content.get(rpar + 1..) else { - continue; - }; - let mut it = after.split_whitespace(); - let status = it - .next() + let status = procstat::field(&stat_content, 0) .and_then(|s| s.chars().next()) .map(|c| match c { 'R' => "Running", @@ -1218,14 +1127,9 @@ fn collect_thread_info(pid: u32) -> Vec { .unwrap_or("Unknown") .to_string(); - // 10 fields between state and utime (ppid..cmajflt). - let utime = it.nth(10).and_then(|s| s.parse::().ok()).unwrap_or(0); - let stime = it.next().and_then(|s| s.parse::().ok()).unwrap_or(0); - - // Convert clock ticks to microseconds (assuming 100 Hz) - // 1 tick = 10ms = 10,000 microseconds - let cpu_time_user = utime * 10_000; - let cpu_time_system = stime * 10_000; + let (utime, stime) = procstat::utime_stime(&stat_content).unwrap_or((0, 0)); + let cpu_time_user = utime * procstat::TICK_US; + let cpu_time_system = stime * procstat::TICK_US; threads.push(crate::types::ThreadInfo { tid, @@ -1257,10 +1161,17 @@ pub async fn collect_process_metrics( system.refresh_processes_specifics( ProcessesToUpdate::Some(&[sysinfo::Pid::from_u32(pid)]), false, + // cmd/exe/cwd feed the modal's Command & Details pane. They're + // immutable per process, so OnlyIfNotSet reads them once per PID and + // serves the cache afterwards — the "minimal refresh" optimization + // had dropped them entirely, leaving the pane blank. ProcessRefreshKind::nothing() .with_memory() .with_cpu() - .with_disk_usage(), + .with_disk_usage() + .with_cmd(sysinfo::UpdateKind::OnlyIfNotSet) + .with_exe(sysinfo::UpdateKind::OnlyIfNotSet) + .with_cwd(sysinfo::UpdateKind::OnlyIfNotSet), ); let process = system @@ -1350,7 +1261,7 @@ pub async fn collect_process_metrics( let threads = collect_thread_info(pid); // One read of /proc/{pid}/stat covers both user + system CPU times. - let (cpu_time_user, cpu_time_system) = get_cpu_times_ms(pid); + let (cpu_time_user, cpu_time_system) = get_cpu_times_us(pid); // Now construct the detailed info without holding the lock let detailed_info = DetailedProcessInfo { @@ -1384,9 +1295,26 @@ pub async fn collect_process_metrics( }) } -/// Collect journal entries for a specific process -pub fn collect_journal_entries(pid: u32) -> Result { - let output = Command::new("journalctl") +/// Epoch microseconds -> RFC 3339 UTC for display. The old code +/// Debug-formatted a SystemTime and string-replaced it into a raw epoch +/// string that was neither ISO 8601 nor what the field documented. +fn format_journal_timestamp(timestamp_us: u64) -> String { + time::OffsetDateTime::from_unix_timestamp_nanos(timestamp_us as i128 * 1000) + .ok() + .and_then(|t| { + t.format(&time::format_description::well_known::Rfc3339) + .ok() + }) + .unwrap_or_else(|| timestamp_us.to_string()) +} + +/// Collect journal entries for a specific process. +/// +/// Async via tokio::process — journalctl can take hundreds of ms on slow +/// storage, and the old std::process call blocked one of the runtime's two +/// worker threads for the duration. +pub async fn collect_journal_entries(pid: u32) -> Result { + let output = tokio::process::Command::new("journalctl") .args([ &format!("_PID={pid}"), "--output=json", @@ -1394,6 +1322,7 @@ pub fn collect_journal_entries(pid: u32) -> Result { "--no-pager", ]) .output() + .await .map_err(|e| format!("Failed to execute journalctl: {e}"))?; if !output.status.success() { @@ -1415,27 +1344,14 @@ pub fn collect_journal_entries(pid: u32) -> Result { let json: serde_json::Value = serde_json::from_str(line).map_err(|e| format!("Failed to parse journal JSON: {e}"))?; - // Extract relevant fields - let timestamp_str = json + // __REALTIME_TIMESTAMP is epoch microseconds as a string. + let timestamp_us = json .get("__REALTIME_TIMESTAMP") .and_then(|v| v.as_str()) - .unwrap_or("0"); + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); - // Convert timestamp to ISO 8601 format - let timestamp = if let Ok(ts_micros) = timestamp_str.parse::() { - let ts_secs = ts_micros / 1_000_000; - let ts_nanos = (ts_micros % 1_000_000) * 1000; - let time = SystemTime::UNIX_EPOCH - + Duration::from_secs(ts_secs) - + Duration::from_nanos(ts_nanos); - // Simple ISO 8601 format - we can improve this if needed - format!("{time:?}") - .replace("SystemTime { tv_sec: ", "") - .replace(", tv_nsec: ", ".") - .replace(" }", "") - } else { - timestamp_str.to_string() - }; + let timestamp = format_journal_timestamp(timestamp_us); let priority = match json.get("PRIORITY").and_then(|v| v.as_str()) { Some("0") => LogLevel::Emergency, @@ -1482,6 +1398,7 @@ pub fn collect_journal_entries(pid: u32) -> Result { entries.push(JournalEntry { timestamp, + timestamp_us, priority, message, unit, @@ -1493,7 +1410,26 @@ pub fn collect_journal_entries(pid: u32) -> Result { } // Sort by timestamp (newest first) - entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + entries.sort_by_key(|e| std::cmp::Reverse(e.timestamp_us)); + + // journalctl exits 0 with no output when the invoking user simply cannot + // SEE the process's entries (e.g. a user-run agent asking about a system + // service) — but it explains itself on stderr ("You are currently not + // seeing messages from other users and the system…"). Pass that hint + // along so the client can distinguish "no logs" from "no access". + let notice = if entries.is_empty() { + let err = String::from_utf8_lossy(&output.stderr); + let hint: String = err + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .take(2) + .collect::>() + .join(" "); + if hint.is_empty() { None } else { Some(hint) } + } else { + None + }; let response_timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1507,6 +1443,70 @@ pub fn collect_journal_entries(pid: u32) -> Result { entries, total_count, truncated, + notice, cached_at: response_timestamp, }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// comm can contain spaces and parens; parsing must key off the LAST ')'. + #[cfg(target_os = "linux")] + #[test] + fn procstat_handles_hostile_comm_names() { + let stat = "1234 (weird name) (2)) R 1 2 3 4 5 6 7 8 9 10 700 800 0 0 20"; + assert_eq!(procstat::field(stat, 0), Some("R")); + assert_eq!(procstat::field(stat, 1), Some("1")); + assert_eq!(procstat::utime_stime(stat), Some((700, 800))); + } + + /// USER_HZ ticks convert to MICROSECONDS — the wire contract. This used + /// to be *10 (ms), rendering process CPU times 1000x too small next to + /// thread times. + #[cfg(target_os = "linux")] + #[test] + fn cpu_times_are_microseconds() { + assert_eq!(procstat::TICK_US, 10_000); + } + + #[test] + fn parent_disk_name_strips_partition_suffixes() { + assert_eq!(parent_disk_name("nvme0n1p1"), "nvme0n1"); + assert_eq!(parent_disk_name("nvme1n1p12"), "nvme1n1"); + assert_eq!(parent_disk_name("mmcblk0p2"), "mmcblk0"); + assert_eq!(parent_disk_name("sda1"), "sda"); + assert_eq!(parent_disk_name("/dev/nvme0n1p1"), "/dev/nvme0n1"); + // 'p' inside a word is not a partition marker. + assert_eq!(parent_disk_name("mapper/vg-lv"), "mapper/vg-lv"); + } + + /// The old heuristic flagged whole-disk names ending in a digit + /// (nvme0n1, zram1) as partitions. On Linux /sys/block decides; this + /// pins the real-machine behavior for devices every Linux box has. + #[cfg(target_os = "linux")] + #[test] + fn sys_block_devices_are_not_partitions() { + let sys_block = std::path::Path::new("/sys/block"); + if !sys_block.is_dir() { + return; // exotic environment; nothing to assert + } + for entry in std::fs::read_dir(sys_block).unwrap().flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + assert!( + !is_partition_name(&name), + "{name} is a whole disk but was flagged as a partition" + ); + } + } + + #[test] + fn journal_timestamps_are_rfc3339() { + let s = format_journal_timestamp(1_786_752_000_000_000); + assert_eq!(s, "2026-08-15T00:00:00Z"); + // Sub-second precision survives. + let s = format_journal_timestamp(1_786_752_000_123_456); + assert!(s.starts_with("2026-08-15T00:00:00.123456"), "{s}"); + } +} diff --git a/socktop_agent/src/state.rs b/socktop_agent/src/state.rs index 36ad18a..74edfda 100644 --- a/socktop_agent/src/state.rs +++ b/socktop_agent/src/state.rs @@ -74,6 +74,55 @@ pub struct AppState { pub cache_journal_entries: Arc>>>, } +/// TTL-gated value behind a std Mutex, for `static` caches on hot paths. +/// Replaces the hand-rolled TempCache/GpuCache/refresh-timestamp statics +/// that each reimplemented the same at/value pair. +pub struct TtlCell { + inner: std::sync::Mutex>, +} + +impl Default for TtlCell { + fn default() -> Self { + Self::new() + } +} + +impl TtlCell { + pub const fn new() -> Self { + Self { + inner: std::sync::Mutex::new(CacheEntry::new()), + } + } + /// The stored value, only while fresh. Poisoned lock reads as a miss. + pub fn get_fresh(&self, ttl: Duration) -> Option { + let g = self.inner.lock().ok()?; + if g.is_fresh(ttl) { + g.value.clone() + } else { + None + } + } + pub fn set(&self, v: T) { + if let Ok(mut g) = self.inner.lock() { + g.set(v); + } + } + /// True exactly once per TTL window: restamps and tells the caller to do + /// the refresh. Atomic check-and-stamp so concurrent callers don't both + /// refresh. + pub fn claim_stale(&self, ttl: Duration) -> bool { + let Ok(mut g) = self.inner.lock() else { + return false; + }; + if g.at.is_none_or(|t| t.elapsed() >= ttl) { + g.at = Some(Instant::now()); + true + } else { + false + } + } +} + #[derive(Clone, Debug)] pub struct CacheEntry { pub at: Option, @@ -87,7 +136,7 @@ impl Default for CacheEntry { } impl CacheEntry { - pub fn new() -> Self { + pub const fn new() -> Self { Self { at: None, value: None, diff --git a/socktop_agent/src/tls.rs b/socktop_agent/src/tls.rs index b224556..5d199bf 100644 --- a/socktop_agent/src/tls.rs +++ b/socktop_agent/src/tls.rs @@ -24,6 +24,17 @@ pub fn cert_paths() -> (PathBuf, PathBuf) { pub fn ensure_self_signed_cert() -> anyhow::Result<(PathBuf, PathBuf)> { let (cert_path, key_path) = cert_paths(); if cert_path.exists() && key_path.exists() { + // Keys generated by agents older than 1.60 were written with the + // default umask (typically 0644): tighten them on startup. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = fs::metadata(&key_path) + && meta.permissions().mode() & 0o077 != 0 + { + let _ = fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600)); + } + } return Ok((cert_path, key_path)); } fs::create_dir_all(cert_path.parent().unwrap())?; @@ -79,7 +90,16 @@ pub fn ensure_self_signed_cert() -> anyhow::Result<(PathBuf, PathBuf)> { let mut f = fs::File::create(&cert_path)?; f.write_all(cert_pem.as_bytes())?; - let mut k = fs::File::create(&key_path)?; + // The private key must not be world-readable (File::create honors the + // umask, which typically yields 0644). + let mut key_opts = fs::OpenOptions::new(); + key_opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + key_opts.mode(0o600); + } + let mut k = key_opts.open(&key_path)?; k.write_all(key_pem.as_bytes())?; println!( diff --git a/socktop_agent/src/types.rs b/socktop_agent/src/types.rs index 75e7d97..dce3f49 100644 --- a/socktop_agent/src/types.rs +++ b/socktop_agent/src/types.rs @@ -30,6 +30,11 @@ pub struct ProcessInfo { #[derive(Debug, Clone, Serialize)] pub struct Metrics { + /// Epoch ms when this snapshot was actually collected. The agent serves + /// TTL-cached snapshots, so the client needs the AGENT's sample time to + /// compute rates — measuring against client receive time turned cache + /// hits into a 0-then-2x sawtooth in the network graphs. + pub sampled_at_ms: u64, pub cpu_total: f32, pub cpu_per_core: Vec, pub mem_total: u64, @@ -93,7 +98,8 @@ pub struct ProcessMetricsResponse { #[derive(Debug, Clone, Serialize)] pub struct JournalEntry { - pub timestamp: String, // ISO 8601 formatted timestamp + pub timestamp: String, // RFC 3339 UTC, for display + pub timestamp_us: u64, // epoch microseconds, for sorting/formatting pub priority: LogLevel, pub message: String, pub unit: Option, // systemd unit name @@ -120,5 +126,9 @@ pub struct JournalResponse { pub entries: Vec, pub total_count: u32, pub truncated: bool, + /// journalctl's own explanation when the result is empty because of + /// journal ACCESS (not absence of logs) — e.g. a user-run agent asking + /// about a system service. None when entries exist or nothing to say. + pub notice: Option, pub cached_at: u64, // Unix timestamp when this data was cached } diff --git a/socktop_agent/src/ws.rs b/socktop_agent/src/ws.rs index 7d70865..54e0092 100644 --- a/socktop_agent/src/ws.rs +++ b/socktop_agent/src/ws.rs @@ -16,9 +16,7 @@ use crate::metrics::{collect_disks, collect_fast_metrics, collect_processes_all} use crate::proto::pb; use crate::state::AppState; -// Compression threshold based on typical payload size -// Temporarily increased for testing - revert to 768 for production -//const COMPRESSION_THRESHOLD: usize = 50_000; +// Payloads at or below this many bytes are sent as-is; larger ones are gzipped. const COMPRESSION_THRESHOLD: usize = 768; // Reusable buffer for compression to avoid allocations @@ -52,6 +50,66 @@ pub async fn ws_handler( ws.on_upgrade(move |socket| handle_socket(socket, state)) } +/// Per-PID cache limits: entries older than MAX_AGE are swept on every +/// insert and the map is capped at MAX_ENTRIES (oldest evicted first), so a +/// client walking PIDs cannot grow agent memory without bound. +const PER_PID_CACHE_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(60); +const PER_PID_CACHE_MAX_ENTRIES: usize = 64; + +/// Serve a per-PID request from a TTL cache, collecting on miss. One home +/// for the logic that get_process_metrics and get_journal_entries used to +/// duplicate ~50 lines apiece. +async fn respond_per_pid_cached( + socket: &mut WebSocket, + cache: &Mutex>>, + pid: u32, + ttl: std::time::Duration, + request_name: &str, + collect: impl FnOnce() -> Fut, +) where + T: serde::Serialize + Clone, + Fut: std::future::Future>, +{ + { + let cache = cache.lock().await; + if let Some(entry) = cache.get(&pid) + && entry.is_fresh(ttl) + && let Some(v) = entry.get() + { + let _ = send_json(socket, v).await; + return; + } + } + match collect().await { + Ok(resp) => { + { + let mut cache = cache.lock().await; + cache.retain(|_, e| e.at.is_some_and(|t| t.elapsed() < PER_PID_CACHE_MAX_AGE)); + while cache.len() >= PER_PID_CACHE_MAX_ENTRIES { + let oldest = cache.iter().min_by_key(|(_, e)| e.at).map(|(k, _)| *k); + match oldest { + Some(k) => cache.remove(&k), + None => break, + }; + } + cache + .entry(pid) + .or_insert_with(crate::state::CacheEntry::new) + .set(resp.clone()); + } + let _ = send_json(socket, &resp).await; + } + Err(err) => { + let error_response = serde_json::json!({ + "error": err, + "request": request_name, + "pid": pid + }); + let _ = send_json(socket, &error_response).await; + } + } +} + async fn handle_socket(mut socket: WebSocket, state: AppState) { state .client_count @@ -126,84 +184,30 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) { if let Some(pid_str) = text.strip_prefix("get_process_metrics:") && let Ok(pid) = pid_str.parse::() { - let ttl = std::time::Duration::from_millis(250); // 250ms TTL - - // Check cache first - { - let cache = state.cache_process_metrics.lock().await; - if let Some(entry) = cache.get(&pid) - && entry.is_fresh(ttl) - && let Some(cached_response) = entry.get() - { - let _ = send_json(&mut socket, cached_response).await; - continue; - } - } - - // Collect fresh data - match crate::metrics::collect_process_metrics(pid, &state).await { - Ok(response) => { - // Cache the response - { - let mut cache = state.cache_process_metrics.lock().await; - cache - .entry(pid) - .or_insert_with(crate::state::CacheEntry::new) - .set(response.clone()); - } - let _ = send_json(&mut socket, &response).await; - } - Err(err) => { - let error_response = serde_json::json!({ - "error": err, - "request": "get_process_metrics", - "pid": pid - }); - let _ = send_json(&mut socket, &error_response).await; - } - } + respond_per_pid_cached( + &mut socket, + &state.cache_process_metrics, + pid, + std::time::Duration::from_millis(250), + "get_process_metrics", + || crate::metrics::collect_process_metrics(pid, &state), + ) + .await; } } Message::Text(ref text) if text.starts_with("get_journal_entries:") => { if let Some(pid_str) = text.strip_prefix("get_journal_entries:") && let Ok(pid) = pid_str.parse::() { - let ttl = std::time::Duration::from_secs(1); // 1s TTL - - // Check cache first - { - let cache = state.cache_journal_entries.lock().await; - if let Some(entry) = cache.get(&pid) - && entry.is_fresh(ttl) - && let Some(cached_response) = entry.get() - { - let _ = send_json(&mut socket, cached_response).await; - continue; - } - } - - // Collect fresh data - match crate::metrics::collect_journal_entries(pid) { - Ok(response) => { - // Cache the response - { - let mut cache = state.cache_journal_entries.lock().await; - cache - .entry(pid) - .or_insert_with(crate::state::CacheEntry::new) - .set(response.clone()); - } - let _ = send_json(&mut socket, &response).await; - } - Err(err) => { - let error_response = serde_json::json!({ - "error": err, - "request": "get_journal_entries", - "pid": pid - }); - let _ = send_json(&mut socket, &error_response).await; - } - } + respond_per_pid_cached( + &mut socket, + &state.cache_journal_entries, + pid, + std::time::Duration::from_secs(1), + "get_journal_entries", + || crate::metrics::collect_journal_entries(pid), + ) + .await; } } Message::Close(_) => break, diff --git a/socktop_agent/tests/cache_tests.rs b/socktop_agent/tests/cache_tests.rs index 821de87..78a4f5c 100644 --- a/socktop_agent/tests/cache_tests.rs +++ b/socktop_agent/tests/cache_tests.rs @@ -42,6 +42,7 @@ async fn test_process_cache_ttl() { }; let journal_response = JournalResponse { + notice: None, entries: vec![], total_count: 0, truncated: false, diff --git a/socktop_agent/tests/process_details.rs b/socktop_agent/tests/process_details.rs index dfc52c3..7ffe0e7 100644 --- a/socktop_agent/tests/process_details.rs +++ b/socktop_agent/tests/process_details.rs @@ -33,7 +33,7 @@ async fn test_collect_journal_entries_self() { // Test collecting journal entries for our own process let pid = process::id(); - match collect_journal_entries(pid) { + match collect_journal_entries(pid).await { Ok(response) => { assert!(response.cached_at > 0); println!( @@ -74,7 +74,7 @@ async fn test_collect_journal_entries_invalid_pid() { // Test with an invalid PID - journalctl might still return empty results let invalid_pid = 999999; - match collect_journal_entries(invalid_pid) { + match collect_journal_entries(invalid_pid).await { Ok(response) => { println!( "✓ Journal query completed for invalid PID {} (empty result expected): {} entries", @@ -87,3 +87,19 @@ async fn test_collect_journal_entries_invalid_pid() { } } } + +/// The Command & Details pane went blank when the minimal-refresh +/// optimization dropped cmd from the detail endpoint's refresh kind. +#[tokio::test] +async fn test_process_metrics_include_command() { + let state = AppState::new(); + let pid = std::process::id(); + let resp = collect_process_metrics(pid, &state) + .await + .expect("collect self"); + assert!( + !resp.process.command.is_empty(), + "command should not be empty for self (cmdline is always readable)" + ); + println!("command = {}", resp.process.command); +} diff --git a/socktop_connector/Cargo.toml b/socktop_connector/Cargo.toml index 7ff30c6..63ddff7 100644 --- a/socktop_connector/Cargo.toml +++ b/socktop_connector/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "socktop_connector" -version = "1.50.0" +version = "1.60.0" edition = "2024" license = "MIT" description = "WebSocket connector library for socktop agent communication" diff --git a/socktop_connector/build.rs b/socktop_connector/build.rs index 9390bf5..5b873e9 100644 --- a/socktop_connector/build.rs +++ b/socktop_connector/build.rs @@ -1,8 +1,12 @@ fn main() -> Result<(), Box> { - // Set the protoc binary path to use the vendored version for CI compatibility - // SAFETY: We're only setting PROTOC in a build script environment, which is safe - unsafe { - std::env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?); + // Vendored protoc for reproducible builds where available. It ships no + // riscv64 binary, so on such hosts leave $PROTOC / PATH lookup to + // prost-build (apt: protobuf-compiler). + // SAFETY: We're only setting PROTOC in a build script environment. + if let Ok(protoc) = protoc_bin_vendored::protoc_bin_path() { + unsafe { + std::env::set_var("PROTOC", protoc); + } } prost_build::compile_protos(&["processes.proto"], &["."])?; diff --git a/socktop_connector/src/connector.rs b/socktop_connector/src/connector.rs deleted file mode 100644 index 3ed5b35..0000000 --- a/socktop_connector/src/connector.rs +++ /dev/null @@ -1,1152 +0,0 @@ -//! WebSocket connector for communicating with socktop agents. - -// WebSocket state constants -#[cfg(feature = "wasm")] -#[allow(dead_code)] -const WEBSOCKET_CONNECTING: u16 = 0; -#[cfg(feature = "wasm")] -#[allow(dead_code)] -const WEBSOCKET_OPEN: u16 = 1; -#[cfg(feature = "wasm")] -#[allow(dead_code)] -const WEBSOCKET_CLOSING: u16 = 2; -#[cfg(feature = "wasm")] -#[allow(dead_code)] -const WEBSOCKET_CLOSED: u16 = 3; - -// Gzip magic header constants -const GZIP_MAGIC_1: u8 = 0x1f; -const GZIP_MAGIC_2: u8 = 0x8b; - -// Shared imports for both networking and WASM -#[cfg(any(feature = "networking", feature = "wasm"))] -use flate2::read::GzDecoder; -#[cfg(any(feature = "networking", feature = "wasm"))] -use std::io::Read; -#[cfg(any(feature = "networking", feature = "wasm"))] -use prost::Message as ProstMessage; - -#[cfg(feature = "networking")] -use futures_util::{SinkExt, StreamExt}; -#[cfg(feature = "networking")] -use std::io::BufReader; -#[cfg(feature = "networking")] -use tokio::net::TcpStream; -#[cfg(feature = "networking")] -use tokio_tungstenite::{ - MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message, - tungstenite::client::IntoClientRequest, -}; -#[cfg(feature = "networking")] -use url::Url; - -#[cfg(feature = "wasm")] -use web_sys::WebSocket; - -#[cfg(all(feature = "wasm", not(feature = "networking")))] -use pb::Processes; -#[cfg(all(feature = "wasm", not(feature = "networking")))] -use wasm_bindgen::{JsCast, JsValue, closure::Closure}; - -#[cfg(feature = "tls")] -use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; -#[cfg(feature = "tls")] -use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; -#[cfg(feature = "tls")] -use rustls::{ClientConfig, RootCertStore}; -#[cfg(feature = "tls")] -use rustls::{DigitallySignedStruct, SignatureScheme}; -#[cfg(feature = "tls")] -use rustls_pemfile::Item; -#[cfg(feature = "tls")] -use std::{fs::File, sync::Arc}; -#[cfg(feature = "tls")] -use tokio_tungstenite::{Connector, connect_async_tls_with_config}; - -use crate::error::{ConnectorError, Result}; -use crate::types::{AgentRequest, AgentResponse}; -#[cfg(any(feature = "networking", feature = "wasm"))] -use crate::types::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload, ProcessMetricsResponse, JournalResponse}; -#[cfg(feature = "tls")] -fn ensure_crypto_provider() { - use std::sync::Once; - static INIT: Once = Once::new(); - INIT.call_once(|| { - let _ = rustls::crypto::ring::default_provider().install_default(); - }); -} - -#[cfg(any(feature = "networking", feature = "wasm"))] -mod pb { - // generated by build.rs - include!(concat!(env!("OUT_DIR"), "/socktop.rs")); -} - -#[cfg(feature = "networking")] -pub type WsStream = WebSocketStream>; - -/// Configuration for connecting to a socktop agent -#[derive(Debug, Clone)] -pub struct ConnectorConfig { - pub url: String, - pub tls_ca_path: Option, - pub verify_hostname: bool, - pub ws_protocols: Option>, - pub ws_version: Option, -} - -impl ConnectorConfig { - pub fn new(url: impl Into) -> Self { - Self { - url: url.into(), - tls_ca_path: None, - verify_hostname: false, - ws_protocols: None, - ws_version: None, - } - } - - pub fn with_tls_ca(mut self, ca_path: impl Into) -> Self { - self.tls_ca_path = Some(ca_path.into()); - self - } - - pub fn with_hostname_verification(mut self, verify: bool) -> Self { - self.verify_hostname = verify; - self - } - - /// Set WebSocket sub-protocols to negotiate - pub fn with_protocols(mut self, protocols: Vec) -> Self { - self.ws_protocols = Some(protocols); - self - } - - /// Set WebSocket protocol version (default is "13") - pub fn with_version(mut self, version: impl Into) -> Self { - self.ws_version = Some(version.into()); - self - } -} - -/// A WebSocket connector for communicating with socktop agents. -/// When the `networking` feature is disabled, the connector struct is available -/// for type compatibility but networking methods will return errors. -pub struct SocktopConnector { - config: ConnectorConfig, - #[cfg(feature = "networking")] - stream: Option, - #[cfg(feature = "wasm")] - #[allow(dead_code)] // Used in WASM builds - websocket: Option, -} - -impl SocktopConnector { - /// Create a new connector with the given configuration - pub fn new(config: ConnectorConfig) -> Self { - Self { - config, - #[cfg(feature = "networking")] - stream: None, - #[cfg(feature = "wasm")] - websocket: None, - } - } -} - -#[cfg(feature = "networking")] -impl SocktopConnector { - /// Connect to the agent - pub async fn connect(&mut self) -> Result<()> { - let stream = connect_to_agent(&self.config).await?; - self.stream = Some(stream); - Ok(()) - } - - /// Send a request to the agent and get the response - pub async fn request(&mut self, request: AgentRequest) -> Result { - let stream = self.stream.as_mut().ok_or(ConnectorError::NotConnected)?; - - match request { - AgentRequest::Metrics => { - let metrics = request_metrics(stream) - .await - .ok_or_else(|| ConnectorError::invalid_response("Failed to get metrics"))?; - Ok(AgentResponse::Metrics(metrics)) - } - AgentRequest::Disks => { - let disks = request_disks(stream) - .await - .ok_or_else(|| ConnectorError::invalid_response("Failed to get disks"))?; - Ok(AgentResponse::Disks(disks)) - } - AgentRequest::Processes => { - let processes = request_processes(stream) - .await - .ok_or_else(|| ConnectorError::invalid_response("Failed to get processes"))?; - Ok(AgentResponse::Processes(processes)) - } - AgentRequest::ProcessMetrics { pid } => { - let process_metrics = request_process_metrics(stream, pid) - .await - .ok_or_else(|| ConnectorError::invalid_response("Failed to get process metrics"))?; - Ok(AgentResponse::ProcessMetrics(process_metrics)) - } - AgentRequest::JournalEntries { pid } => { - let journal_entries = request_journal_entries(stream, pid) - .await - .ok_or_else(|| ConnectorError::invalid_response("Failed to get journal entries"))?; - Ok(AgentResponse::JournalEntries(journal_entries)) - } - } - } - - /// Check if the connector is connected - pub fn is_connected(&self) -> bool { - self.stream.is_some() - } - - /// Disconnect from the agent - pub async fn disconnect(&mut self) -> Result<()> { - if let Some(mut stream) = self.stream.take() { - let _ = stream.close(None).await; - } - Ok(()) - } -} - -// Connect to the agent and return the WS stream -#[cfg(feature = "networking")] -async fn connect_to_agent(config: &ConnectorConfig) -> Result { - #[cfg(feature = "tls")] - ensure_crypto_provider(); - - let mut u = Url::parse(&config.url)?; - if let Some(ca_path) = &config.tls_ca_path { - if u.scheme() == "ws" { - let _ = u.set_scheme("wss"); - } - return connect_with_ca_and_config(u.as_str(), ca_path, config).await; - } - // No TLS - hostname verification is not applicable - connect_without_ca_and_config(u.as_str(), config).await -} - -#[cfg(feature = "networking")] -async fn connect_without_ca_and_config(url: &str, config: &ConnectorConfig) -> Result { - let mut req = url.into_client_request()?; - - // Apply WebSocket protocol configuration - if let Some(version) = &config.ws_version { - req.headers_mut().insert( - "Sec-WebSocket-Version", - version - .parse() - .map_err(|_| ConnectorError::protocol_error("Invalid WebSocket version"))?, - ); - } - - if let Some(protocols) = &config.ws_protocols { - let protocols_str = protocols.join(", "); - req.headers_mut().insert( - "Sec-WebSocket-Protocol", - protocols_str - .parse() - .map_err(|_| ConnectorError::protocol_error("Invalid WebSocket protocols"))?, - ); - } - - let (ws, _) = connect_async(req).await?; - Ok(ws) -} - -#[cfg(feature = "tls")] -#[cfg(feature = "networking")] -async fn connect_with_ca_and_config( - url: &str, - ca_path: &str, - config: &ConnectorConfig, -) -> Result { - // Initialize the crypto provider for rustls - let _ = rustls::crypto::ring::default_provider().install_default(); - - let mut root = RootCertStore::empty(); - let mut reader = BufReader::new(File::open(ca_path)?); - let mut der_certs = Vec::new(); - while let Ok(Some(item)) = rustls_pemfile::read_one(&mut reader) { - if let Item::X509Certificate(der) = item { - der_certs.push(der); - } - } - root.add_parsable_certificates(der_certs); - - let mut cfg = ClientConfig::builder() - .with_root_certificates(root) - .with_no_client_auth(); - - let mut req = url.into_client_request()?; - - // Apply WebSocket protocol configuration - if let Some(version) = &config.ws_version { - req.headers_mut().insert( - "Sec-WebSocket-Version", - version - .parse() - .map_err(|_| ConnectorError::protocol_error("Invalid WebSocket version"))?, - ); - } - - if let Some(protocols) = &config.ws_protocols { - let protocols_str = protocols.join(", "); - req.headers_mut().insert( - "Sec-WebSocket-Protocol", - protocols_str - .parse() - .map_err(|_| ConnectorError::protocol_error("Invalid WebSocket protocols"))?, - ); - } - - if !config.verify_hostname { - #[derive(Debug)] - struct NoVerify; - impl ServerCertVerifier for NoVerify { - fn verify_server_cert( - &self, - _end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &ServerName, - _ocsp_response: &[u8], - _now: UnixTime, - ) -> std::result::Result { - Ok(ServerCertVerified::assertion()) - } - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> std::result::Result { - Ok(HandshakeSignatureValid::assertion()) - } - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> std::result::Result { - Ok(HandshakeSignatureValid::assertion()) - } - fn supported_verify_schemes(&self) -> Vec { - vec![ - SignatureScheme::ECDSA_NISTP256_SHA256, - SignatureScheme::ED25519, - SignatureScheme::RSA_PSS_SHA256, - ] - } - } - cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify)); - // Note: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking. - } - let cfg = Arc::new(cfg); - let (ws, _) = connect_async_tls_with_config( - req, - None, - config.verify_hostname, - Some(Connector::Rustls(cfg)), - ) - .await?; - Ok(ws) -} - -#[cfg(not(feature = "tls"))] -#[cfg(feature = "networking")] -async fn connect_with_ca_and_config( - _url: &str, - _ca_path: &str, - _config: &ConnectorConfig, -) -> Result { - Err(ConnectorError::tls_error( - "TLS support not compiled in", - std::io::Error::new(std::io::ErrorKind::Unsupported, "TLS not available"), - )) -} - -// Send a "get_metrics" request and await a single JSON reply -#[cfg(feature = "networking")] -async fn request_metrics(ws: &mut WsStream) -> Option { - if ws.send(Message::Text("get_metrics".into())).await.is_err() { - return None; - } - match ws.next().await { - Some(Ok(Message::Binary(b))) => { - gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::(&s).ok()) - } - Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), - _ => None, - } -} - -// Send a "get_disks" request and await a JSON Vec -#[cfg(feature = "networking")] -async fn request_disks(ws: &mut WsStream) -> Option> { - if ws.send(Message::Text("get_disks".into())).await.is_err() { - return None; - } - match ws.next().await { - Some(Ok(Message::Binary(b))) => { - gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::>(&s).ok()) - } - Some(Ok(Message::Text(json))) => serde_json::from_str::>(&json).ok(), - _ => None, - } -} - -// Send a "get_processes" request and await a ProcessesPayload decoded from protobuf (binary, may be gzipped) -#[cfg(feature = "networking")] -async fn request_processes(ws: &mut WsStream) -> Option { - if ws - .send(Message::Text("get_processes".into())) - .await - .is_err() - { - return None; - } - match ws.next().await { - Some(Ok(Message::Binary(b))) => { - let gz = is_gzip(&b); - let data = if gz { gunzip_to_vec(&b).ok()? } else { b }; - match pb::Processes::decode(data.as_slice()) { - Ok(pb) => { - let rows: Vec = pb - .rows - .into_iter() - .map(|p: pb::Process| ProcessInfo { - pid: p.pid, - name: p.name, - cpu_usage: p.cpu_usage, - mem_bytes: p.mem_bytes, - }) - .collect(); - Some(ProcessesPayload { - process_count: pb.process_count as usize, - top_processes: rows, - }) - } - Err(e) => { - if std::env::var("SOCKTOP_DEBUG").ok().as_deref() == Some("1") { - eprintln!("protobuf decode failed: {e}"); - } - // Fallback: maybe it's JSON (bytes already decompressed if gz) - match String::from_utf8(data) { - Ok(s) => serde_json::from_str::(&s).ok(), - Err(_) => None, - } - } - } - } - Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), - _ => None, - } -} - -// Send a "get_process_metrics:{pid}" request and await a JSON ProcessMetricsResponse -#[cfg(feature = "networking")] -async fn request_process_metrics(ws: &mut WsStream, pid: u32) -> Option { - let request = format!("get_process_metrics:{}", pid); - if ws.send(Message::Text(request)).await.is_err() { - return None; - } - match ws.next().await { - Some(Ok(Message::Binary(b))) => { - gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::(&s).ok()) - } - Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), - _ => None, - } -} - -// Send a "get_journal_entries:{pid}" request and await a JSON JournalResponse -#[cfg(feature = "networking")] -async fn request_journal_entries(ws: &mut WsStream, pid: u32) -> Option { - let request = format!("get_journal_entries:{}", pid); - if ws.send(Message::Text(request)).await.is_err() { - return None; - } - match ws.next().await { - Some(Ok(Message::Binary(b))) => { - gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::(&s).ok()) - } - Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), - _ => None, - } -} - -// Decompress a gzip-compressed binary frame into a String. -/// Unified gzip decompression to string for both networking and WASM -#[cfg(any(feature = "networking", feature = "wasm"))] -fn gunzip_to_string(bytes: &[u8]) -> Result { - let mut decoder = GzDecoder::new(bytes); - let mut decompressed = String::new(); - decoder.read_to_string(&mut decompressed).map_err(|e| { - ConnectorError::protocol_error(format!("Gzip decompression failed: {e}")) - })?; - Ok(decompressed) -} - -/// Unified gzip decompression to bytes for both networking and WASM -#[cfg(any(feature = "networking", feature = "wasm"))] -fn gunzip_to_vec(bytes: &[u8]) -> Result> { - let mut decoder = GzDecoder::new(bytes); - let mut decompressed = Vec::new(); - decoder.read_to_end(&mut decompressed).map_err(|e| { - ConnectorError::protocol_error(format!("Gzip decompression failed: {e}")) - })?; - Ok(decompressed) -} - -/// Unified gzip detection for both networking and WASM -#[cfg(any(feature = "networking", feature = "wasm"))] -fn is_gzip(bytes: &[u8]) -> bool { - bytes.len() >= 2 && bytes[0] == GZIP_MAGIC_1 && bytes[1] == GZIP_MAGIC_2 -} - -/// Convenience function to create a connector and connect in one step. -/// -/// This function is for non-TLS WebSocket connections (`ws://`). Since there's no -/// certificate involved, hostname verification is not applicable. -/// -/// For TLS connections with certificate pinning, use `connect_to_socktop_agent_with_tls()`. -#[cfg(feature = "networking")] -pub async fn connect_to_socktop_agent(url: impl Into) -> Result { - let config = ConnectorConfig::new(url); - let mut connector = SocktopConnector::new(config); - connector.connect().await?; - Ok(connector) -} - -/// Convenience function to create a connector with TLS and connect in one step. -/// -/// This function enables TLS with certificate pinning using the provided CA certificate. -/// The `verify_hostname` parameter controls whether the server's hostname is verified -/// against the certificate (recommended for production, can be disabled for testing). -#[cfg(feature = "tls")] -#[cfg(feature = "networking")] -#[cfg_attr(docsrs, doc(cfg(feature = "tls")))] -pub async fn connect_to_socktop_agent_with_tls( - url: impl Into, - ca_path: impl Into, - verify_hostname: bool, -) -> Result { - let config = ConnectorConfig::new(url) - .with_tls_ca(ca_path) - .with_hostname_verification(verify_hostname); - let mut connector = SocktopConnector::new(config); - connector.connect().await?; - Ok(connector) -} - -/// Convenience function to create a connector with custom WebSocket protocol configuration. -/// -/// This function allows you to specify WebSocket protocol version and sub-protocols. -/// Most users should use the simpler `connect_to_socktop_agent()` function instead. -/// -/// # Example -/// ```no_run -/// use socktop_connector::connect_to_socktop_agent_with_config; -/// -/// # #[tokio::main] -/// # async fn main() -> Result<(), Box> { -/// let connector = connect_to_socktop_agent_with_config( -/// "ws://localhost:3000/ws", -/// Some(vec!["socktop".to_string()]), // WebSocket sub-protocols -/// Some("13".to_string()), // WebSocket version (13 is standard) -/// ).await?; -/// # Ok(()) -/// # } -/// ``` -#[cfg(feature = "networking")] -pub async fn connect_to_socktop_agent_with_config( - url: impl Into, - protocols: Option>, - version: Option, -) -> Result { - let mut config = ConnectorConfig::new(url); - - if let Some(protocols) = protocols { - config = config.with_protocols(protocols); - } - - if let Some(version) = version { - config = config.with_version(version); - } - - let mut connector = SocktopConnector::new(config); - connector.connect().await?; - Ok(connector) -} - -// WASM WebSocket implementation -#[cfg(all(feature = "wasm", not(feature = "networking")))] -impl SocktopConnector { - /// Connect to the agent using WASM WebSocket - pub async fn connect(&mut self) -> Result<()> { - let websocket = WebSocket::new(&self.config.url).map_err(|e| { - ConnectorError::protocol_error(format!("Failed to create WebSocket: {e:?}")) - })?; - - // Set binary type for proper message handling - websocket.set_binary_type(web_sys::BinaryType::Arraybuffer); - - // Wait for connection to be ready with proper async delays - let start_time = js_sys::Date::now(); - let timeout_ms = 10000.0; // 10 second timeout (increased from 5) - - // Poll connection status until ready or timeout - loop { - let ready_state = websocket.ready_state(); - - if ready_state == WEBSOCKET_OPEN { - // OPEN - connection is ready - break; - } else if ready_state == WEBSOCKET_CLOSED { - // CLOSED - return Err(ConnectorError::protocol_error( - "WebSocket connection closed", - )); - } else if ready_state == WEBSOCKET_CLOSING { - // CLOSING - return Err(ConnectorError::protocol_error("WebSocket is closing")); - } - - // Check timeout - let now = js_sys::Date::now(); - if now - start_time > timeout_ms { - return Err(ConnectorError::protocol_error( - "WebSocket connection timeout", - )); - } - - // Proper async delay using setTimeout Promise - let promise = js_sys::Promise::new(&mut |resolve, _| { - let closure = Closure::once(move || resolve.call0(&JsValue::UNDEFINED)); - web_sys::window() - .unwrap() - .set_timeout_with_callback_and_timeout_and_arguments_0( - closure.as_ref().unchecked_ref(), - 100, // 100ms delay between polls - ) - .unwrap(); - closure.forget(); - }); - - let _ = wasm_bindgen_futures::JsFuture::from(promise).await; - } - - self.websocket = Some(websocket); - Ok(()) - } - - /// Send a request to the agent and get the response - pub async fn request(&mut self, request: AgentRequest) -> Result { - let ws = self - .websocket - .as_ref() - .ok_or(ConnectorError::NotConnected)?; - - // Use the legacy string format that the agent expects - let request_string = request.to_legacy_string(); - - // Send request - ws.send_with_str(&request_string).map_err(|e| { - ConnectorError::protocol_error(format!("Failed to send message: {e:?}")) - })?; - - // Wait for response using JavaScript Promise - let (response, binary_data) = self.wait_for_response_with_binary().await?; - - // Parse the response based on the request type - match request { - AgentRequest::Metrics => { - // Check if this is binary data (protobuf from agent) - if response.starts_with("BINARY_DATA:") { - // Extract the byte count - let byte_count: usize = response - .strip_prefix("BINARY_DATA:") - .unwrap_or("0") - .parse() - .unwrap_or(0); - - // For now, return a placeholder metrics response indicating binary data received - // TODO: Implement proper protobuf decoding for binary data - let placeholder_metrics = Metrics { - cpu_total: 0.0, - cpu_per_core: vec![0.0], - mem_total: 0, - mem_used: 0, - swap_total: 0, - swap_used: 0, - hostname: format!("Binary protobuf data ({byte_count} bytes)"), - cpu_temp_c: None, - disks: vec![], - networks: vec![], - top_processes: vec![], - gpus: None, - process_count: None, - }; - Ok(AgentResponse::Metrics(placeholder_metrics)) - } else { - // Try to parse as JSON (fallback) - let metrics: Metrics = serde_json::from_str(&response).map_err(|e| { - ConnectorError::serialization_error(format!( - "Failed to parse metrics: {e}" - )) - })?; - Ok(AgentResponse::Metrics(metrics)) - } - } - AgentRequest::Disks => { - let disks: Vec = serde_json::from_str(&response).map_err(|e| { - ConnectorError::serialization_error(format!("Failed to parse disks: {e}")) - })?; - Ok(AgentResponse::Disks(disks)) - } - AgentRequest::Processes => { - log_debug(&format!( - "🔍 Processing process request - response: {}", - if response.len() > 100 { - format!("{}...", &response[..100]) - } else { - response.clone() - } - )); - log_debug(&format!( - "🔍 Binary data available: {}", - binary_data.is_some() - )); - if let Some(ref data) = binary_data { - log_debug(&format!("🔍 Binary data size: {} bytes", data.len())); - // Check if it's gzipped data and decompress it first - if is_gzip(data) { - log_debug("🔍 Process data is gzipped, decompressing..."); - match gunzip_to_vec(data) { - Ok(decompressed_bytes) => { - log_debug(&format!( - "🔍 Successfully decompressed {} bytes, now decoding protobuf...", - decompressed_bytes.len() - )); - // Now decode the decompressed bytes as protobuf - match ::decode( - decompressed_bytes.as_slice(), - ) { - Ok(protobuf_processes) => { - log_debug(&format!( - "✅ Successfully decoded {} processes from gzipped protobuf", - protobuf_processes.rows.len() - )); - - // Convert protobuf processes to ProcessInfo structs - let processes: Vec = protobuf_processes - .rows - .into_iter() - .map(|p| ProcessInfo { - pid: p.pid, - name: p.name, - cpu_usage: p.cpu_usage, - mem_bytes: p.mem_bytes, - }) - .collect(); - - let processes_payload = ProcessesPayload { - top_processes: processes, - process_count: protobuf_processes.process_count - as usize, - }; - return Ok(AgentResponse::Processes(processes_payload)); - } - Err(e) => { - log_debug(&format!( - "❌ Failed to decode decompressed protobuf: {e}" - )); - } - } - } - Err(e) => { - log_debug(&format!( - "❌ Failed to decompress gzipped process data: {e}" - )); - } - } - } - } - - // Check if this is binary data (protobuf from agent) - if response.starts_with("BINARY_DATA:") { - // Extract the binary data size and decode protobuf - let byte_count_str = response.strip_prefix("BINARY_DATA:").unwrap_or("0"); - let _byte_count: usize = byte_count_str.parse().unwrap_or(0); - - // Check if we have the actual binary data - if let Some(binary_bytes) = binary_data { - log_debug(&format!( - "🔧 Decoding {} bytes of protobuf process data", - binary_bytes.len() - )); - - // Try to decode the protobuf data using the prost Message trait - match ::decode(&binary_bytes[..]) { - Ok(protobuf_processes) => { - log_debug(&format!( - "✅ Successfully decoded {} processes from protobuf", - protobuf_processes.rows.len() - )); - - // Convert protobuf processes to ProcessInfo structs - let processes: Vec = protobuf_processes - .rows - .into_iter() - .map(|p| ProcessInfo { - pid: p.pid, - name: p.name, - cpu_usage: p.cpu_usage, - mem_bytes: p.mem_bytes, - }) - .collect(); - - let processes_payload = ProcessesPayload { - top_processes: processes, - process_count: protobuf_processes.process_count as usize, - }; - Ok(AgentResponse::Processes(processes_payload)) - } - Err(e) => { - log_debug(&format!("❌ Failed to decode protobuf: {e}")); - // Fallback to empty processes - let processes = ProcessesPayload { - top_processes: vec![], - process_count: 0, - }; - Ok(AgentResponse::Processes(processes)) - } - } - } else { - log_debug( - "❌ Binary data indicator received but no actual binary data preserved", - ); - let processes = ProcessesPayload { - top_processes: vec![], - process_count: 0, - }; - Ok(AgentResponse::Processes(processes)) - } - } else { - // Try to parse as JSON (fallback) - let processes: ProcessesPayload = - serde_json::from_str(&response).map_err(|e| { - ConnectorError::serialization_error(format!( - "Failed to parse processes: {e}" - )) - })?; - Ok(AgentResponse::Processes(processes)) - } - } - AgentRequest::ProcessMetrics { pid: _ } => { - // Parse JSON response for process metrics - let process_metrics: ProcessMetricsResponse = serde_json::from_str(&response).map_err(|e| { - ConnectorError::serialization_error(format!("Failed to parse process metrics: {e}")) - })?; - Ok(AgentResponse::ProcessMetrics(process_metrics)) - } - AgentRequest::JournalEntries { pid: _ } => { - // Parse JSON response for journal entries - let journal_entries: JournalResponse = serde_json::from_str(&response).map_err(|e| { - ConnectorError::serialization_error(format!("Failed to parse journal entries: {e}")) - })?; - Ok(AgentResponse::JournalEntries(journal_entries)) - } - } - } - - async fn wait_for_response_with_binary(&self) -> Result<(String, Option>)> { - let ws = self - .websocket - .as_ref() - .ok_or(ConnectorError::NotConnected)?; - - let start_time = js_sys::Date::now(); - let timeout_ms = 10000.0; // 10 second timeout - - // Store the response in a shared location - let response_cell = std::rc::Rc::new(std::cell::RefCell::new(None::)); - let binary_data_cell = std::rc::Rc::new(std::cell::RefCell::new(None::>)); - let error_cell = std::rc::Rc::new(std::cell::RefCell::new(None::)); - - // Use a unique request ID to avoid message collision - let _request_id = js_sys::Math::random(); - let response_received = std::rc::Rc::new(std::cell::RefCell::new(false)); - - // Set up the message handler that only processes if we haven't gotten a response yet - { - let response_cell = response_cell.clone(); - let binary_data_cell = binary_data_cell.clone(); - let response_received = response_received.clone(); - let onmessage_callback = Closure::wrap(Box::new(move |e: web_sys::MessageEvent| { - // Only process if we haven't already received a response for this request - if !*response_received.borrow() { - // Handle text messages (JSON responses for metrics/disks) - if let Ok(data) = e.data().dyn_into::() { - let message = data.as_string().unwrap_or_default(); - if !message.is_empty() { - // Debug: Log what we received (truncated) - let preview = if message.len() > 100 { - format!("{}...", &message[..100]) - } else { - message.clone() - }; - log_debug(&format!("🔍 Received text: {preview}")); - - *response_cell.borrow_mut() = Some(message); - *response_received.borrow_mut() = true; - } - } - // Handle binary messages (could be JSON as text bytes or actual protobuf) - else if let Ok(array_buffer) = e.data().dyn_into::() { - let uint8_array = js_sys::Uint8Array::new(&array_buffer); - let length = uint8_array.length() as usize; - let mut bytes = vec![0u8; length]; - uint8_array.copy_to(&mut bytes); - - log_debug(&format!("🔍 Received binary data: {length} bytes")); - - // Debug: Log the first few bytes to see what we're dealing with - let first_bytes = if bytes.len() >= 4 { - format!( - "0x{:02x} 0x{:02x} 0x{:02x} 0x{:02x}", - bytes[0], bytes[1], bytes[2], bytes[3] - ) - } else { - format!("Only {} bytes available", bytes.len()) - }; - log_debug(&format!("🔍 First bytes: {first_bytes}")); - - // Try to decode as UTF-8 text first (in case it's JSON sent as binary) - match String::from_utf8(bytes.clone()) { - Ok(text) => { - // If it decodes to valid UTF-8, check if it looks like JSON - let trimmed = text.trim(); - if (trimmed.starts_with('{') && trimmed.ends_with('}')) - || (trimmed.starts_with('[') && trimmed.ends_with(']')) - { - log_debug(&format!( - "🔍 Binary data is actually JSON text: {}", - if text.len() > 100 { - format!("{}...", &text[..100]) - } else { - text.clone() - } - )); - *response_cell.borrow_mut() = Some(text); - *response_received.borrow_mut() = true; - } else { - log_debug(&format!( - "🔍 Binary data is UTF-8 text but not JSON: {}", - if text.len() > 100 { - format!("{}...", &text[..100]) - } else { - text.clone() - } - )); - *response_cell.borrow_mut() = Some(text); - *response_received.borrow_mut() = true; - } - } - Err(_) => { - // If it's not valid UTF-8, check if it's gzipped data - if is_gzip(&bytes) { - log_debug(&format!( - "🔍 Binary data appears to be gzipped ({length} bytes)" - )); - // Try to decompress using unified gzip decompression - match gunzip_to_string(&bytes) { - Ok(decompressed_text) => { - log_debug(&format!( - "🔍 Gzipped data decompressed to text: {}", - if decompressed_text.len() > 100 { - format!("{}...", &decompressed_text[..100]) - } else { - decompressed_text.clone() - } - )); - *response_cell.borrow_mut() = Some(decompressed_text); - *response_received.borrow_mut() = true; - } - Err(e) => { - log_debug(&format!( - "🔍 Failed to decompress gzip: {e}" - )); - // Fallback: treat as actual binary protobuf data - *binary_data_cell.borrow_mut() = Some(bytes.clone()); - *response_cell.borrow_mut() = - Some(format!("BINARY_DATA:{length}")); - *response_received.borrow_mut() = true; - } - } - } else { - // If it's not valid UTF-8 and not gzipped, it's likely actual binary protobuf data - log_debug(&format!( - "🔍 Binary data is actual protobuf ({length} bytes)" - )); - *binary_data_cell.borrow_mut() = Some(bytes); - *response_cell.borrow_mut() = - Some(format!("BINARY_DATA:{length}")); - *response_received.borrow_mut() = true; - } - } - } - } else { - // Log what type of data we got - log_debug(&format!("🔍 Received unknown data type: {:?}", e.data())); - } - } - }) as Box); - ws.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref())); - onmessage_callback.forget(); - } - - // Set up the error handler - { - let error_cell = error_cell.clone(); - let response_received = response_received.clone(); - let onerror_callback = Closure::wrap(Box::new(move |_e: web_sys::ErrorEvent| { - if !*response_received.borrow() { - *error_cell.borrow_mut() = Some("WebSocket error occurred".to_string()); - *response_received.borrow_mut() = true; - } - }) as Box); - ws.set_onerror(Some(onerror_callback.as_ref().unchecked_ref())); - onerror_callback.forget(); - } - - // Poll for response with proper async delays - loop { - // Check for response - if *response_received.borrow() { - if let Some(response) = response_cell.borrow().as_ref() { - let binary_data = binary_data_cell.borrow().clone(); - return Ok((response.clone(), binary_data)); - } - if let Some(error) = error_cell.borrow().as_ref() { - return Err(ConnectorError::protocol_error(error)); - } - } - - // Check timeout - let now = js_sys::Date::now(); - if now - start_time > timeout_ms { - *response_received.borrow_mut() = true; // Mark as done to prevent future processing - return Err(ConnectorError::protocol_error("WebSocket response timeout")); - } - - // Wait 50ms before checking again - let promise = js_sys::Promise::new(&mut |resolve, _| { - let closure = Closure::once(move || resolve.call0(&JsValue::UNDEFINED)); - web_sys::window() - .unwrap() - .set_timeout_with_callback_and_timeout_and_arguments_0( - closure.as_ref().unchecked_ref(), - 50, - ) - .unwrap(); - closure.forget(); - }); - let _ = wasm_bindgen_futures::JsFuture::from(promise).await; - } - } - - /// Check if the connector is connected - pub fn is_connected(&self) -> bool { - self.websocket - .as_ref() - .is_some_and(|ws| ws.ready_state() == WEBSOCKET_OPEN) - } - - /// Disconnect from the agent - pub async fn disconnect(&mut self) -> Result<()> { - if let Some(ws) = self.websocket.take() { - let _ = ws.close(); - } - Ok(()) - } - - /// Request metrics from the agent - pub async fn get_metrics(&mut self) -> Result { - match self.request(AgentRequest::Metrics).await? { - AgentResponse::Metrics(metrics) => Ok(metrics), - _ => Err(ConnectorError::protocol_error( - "Unexpected response type for metrics", - )), - } - } - - /// Request disk information from the agent - pub async fn get_disks(&mut self) -> Result> { - match self.request(AgentRequest::Disks).await? { - AgentResponse::Disks(disks) => Ok(disks), - _ => Err(ConnectorError::protocol_error( - "Unexpected response type for disks", - )), - } - } - - /// Request process information from the agent - pub async fn get_processes(&mut self) -> Result { - match self.request(AgentRequest::Processes).await? { - AgentResponse::Processes(processes) => Ok(processes), - _ => Err(ConnectorError::protocol_error( - "Unexpected response type for processes", - )), - } - } -} - -// Helper function for logging that works in WASI environments -/// Unified debug logging for both networking and WASM modes -#[cfg(any(feature = "networking", feature = "wasm"))] -#[allow(dead_code)] -fn log_debug(message: &str) { - #[cfg(feature = "networking")] - if std::env::var("SOCKTOP_DEBUG").ok().as_deref() == Some("1") { - eprintln!("{message}"); - } - - #[cfg(all(feature = "wasm", not(feature = "networking")))] - eprintln!("{message}"); -} - -// Stub implementations when neither networking nor wasm is enabled -#[cfg(not(any(feature = "networking", feature = "wasm")))] -impl SocktopConnector { - /// Connect to the socktop agent endpoint. - /// - /// Note: Networking functionality is disabled. Enable the "networking" feature to use this function. - pub async fn connect(&mut self) -> Result<()> { - Err(ConnectorError::protocol_error( - "Networking functionality disabled. Enable the 'networking' feature to connect to agents.", - )) - } - - /// Send a request to the agent and await a response. - /// - /// Note: Networking functionality is disabled. Enable the "networking" feature to use this function. - pub async fn request(&mut self, _request: AgentRequest) -> Result { - Err(ConnectorError::protocol_error( - "Networking functionality disabled. Enable the 'networking' feature to send requests.", - )) - } - - /// Close the connection to the agent. - /// - /// Note: Networking functionality is disabled. This is a no-op when networking is disabled. - pub async fn disconnect(&mut self) -> Result<()> { - Ok(()) // No-op when networking is disabled - } -} diff --git a/socktop_connector/src/networking/connection.rs b/socktop_connector/src/networking/connection.rs index 8ba2ece..efa894d 100644 --- a/socktop_connector/src/networking/connection.rs +++ b/socktop_connector/src/networking/connection.rs @@ -6,7 +6,7 @@ use crate::error::{ConnectorError, Result}; use std::io::BufReader; use std::sync::Arc; use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use url::Url; #[cfg(feature = "tls")] @@ -15,7 +15,7 @@ use { rustls::{ DigitallySignedStruct, RootCertStore, SignatureScheme, client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, - crypto::ring, + crypto::{WebPkiSupportedAlgorithms, ring}, pki_types::{CertificateDer, ServerName, UnixTime}, }, rustls_pemfile::Item, @@ -64,7 +64,8 @@ async fn connect_without_ca_and_config(url: &str, config: &ConnectorConfig) -> R ); } - let (ws, _) = connect_async(req).await?; + // `true` disables Nagle: small request/response frames, latency matters. + let (ws, _) = tokio_tungstenite::connect_async_with_config(req, None, true).await?; Ok(ws) } @@ -85,7 +86,12 @@ async fn connect_with_ca_and_config( der_certs.push(der); } } - root.add_parsable_certificates(der_certs); + if der_certs.is_empty() { + return Err(ConnectorError::protocol_error(format!( + "no certificates found in --tls-ca file: {ca_path}" + ))); + } + root.add_parsable_certificates(der_certs.iter().cloned()); let mut cfg = ClientConfig::builder() .with_root_certificates(root) @@ -114,57 +120,89 @@ async fn connect_with_ca_and_config( } if !config.verify_hostname { - #[derive(Debug)] - struct NoVerify; - impl ServerCertVerifier for NoVerify { - fn verify_server_cert( - &self, - _end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &ServerName, - _ocsp_response: &[u8], - _now: UnixTime, - ) -> std::result::Result { - Ok(ServerCertVerified::assertion()) - } - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> std::result::Result { - Ok(HandshakeSignatureValid::assertion()) - } - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> std::result::Result { - Ok(HandshakeSignatureValid::assertion()) - } - fn supported_verify_schemes(&self) -> Vec { - vec![ - SignatureScheme::ECDSA_NISTP256_SHA256, - SignatureScheme::ED25519, - SignatureScheme::RSA_PSS_SHA256, - ] - } - } - cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify)); - // Note: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking. + // Default mode: certificate PINNING without hostname verification. + // The server must present a certificate byte-identical to one in the + // --tls-ca file. This intentionally ignores expiry and chain building + // (the operator pinned this exact cert), but unlike a blanket accept + // it makes MITM certs fail the handshake. + cfg.dangerous() + .set_certificate_verifier(Arc::new(PinnedCertVerifier::new(der_certs))); } let cfg = Arc::new(cfg); + // Third argument is tungstenite's `disable_nagle`: always true — socktop + // exchanges small request/response frames where Nagle only adds latency. let (ws, _) = tokio_tungstenite::connect_async_tls_with_config( req, None, - config.verify_hostname, + true, Some(Connector::Rustls(cfg)), ) .await?; Ok(ws) } +/// Accepts exactly the certificates the user pinned via `--tls-ca`, nothing else. +/// +/// Used when hostname verification is off (the default for self-signed +/// home-lab certs). Signature validation still runs with the ring provider's +/// full algorithm set; only the certificate identity check is replaced — +/// by an exact DER comparison against the pinned certificate(s). +#[cfg(feature = "tls")] +#[derive(Debug)] +struct PinnedCertVerifier { + pinned: Vec>, + algorithms: WebPkiSupportedAlgorithms, +} + +#[cfg(feature = "tls")] +impl PinnedCertVerifier { + fn new(pinned: Vec>) -> Self { + Self { + pinned, + algorithms: ring::default_provider().signature_verification_algorithms, + } + } +} + +#[cfg(feature = "tls")] +impl ServerCertVerifier for PinnedCertVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> std::result::Result { + if self.pinned.iter().any(|p| p == end_entity) { + Ok(ServerCertVerified::assertion()) + } else { + Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::ApplicationVerificationFailure, + )) + } + } + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> std::result::Result { + rustls::crypto::verify_tls12_signature(message, cert, dss, &self.algorithms) + } + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> std::result::Result { + rustls::crypto::verify_tls13_signature(message, cert, dss, &self.algorithms) + } + fn supported_verify_schemes(&self) -> Vec { + self.algorithms.supported_schemes() + } +} + #[cfg(not(feature = "tls"))] async fn connect_with_ca_and_config( _url: &str, @@ -181,3 +219,73 @@ async fn connect_with_ca_and_config( fn ensure_crypto_provider() { let _ = ring::default_provider().install_default(); } + +#[cfg(all(test, feature = "tls"))] +mod tests { + use super::*; + + fn verifier(pinned: &[&[u8]]) -> PinnedCertVerifier { + let _ = ring::default_provider().install_default(); + PinnedCertVerifier::new( + pinned + .iter() + .map(|b| CertificateDer::from(b.to_vec())) + .collect(), + ) + } + + fn verify(v: &PinnedCertVerifier, presented: &[u8]) -> bool { + v.verify_server_cert( + &CertificateDer::from(presented.to_vec()), + &[], + &ServerName::try_from("agent.test").unwrap(), + &[], + UnixTime::now(), + ) + .is_ok() + } + + /// The regression this verifier exists to prevent: the old NoVerify + /// accepted ANY certificate when hostname verification was off, so the + /// documented pinning was a no-op. The pinned cert must be accepted and + /// every other cert rejected. + #[test] + fn only_the_pinned_certificate_is_accepted() { + let v = verifier(&[b"pinned-cert-der"]); + assert!(verify(&v, b"pinned-cert-der")); + assert!(!verify(&v, b"some-mitm-cert"), "unpinned cert accepted"); + assert!(!verify(&v, b""), "empty cert accepted"); + } + + /// A --tls-ca file may hold several certs (e.g. during rotation); any of + /// them must satisfy the pin. + #[test] + fn any_cert_in_a_multi_cert_pem_satisfies_the_pin() { + let v = verifier(&[b"old-cert", b"new-cert"]); + assert!(verify(&v, b"old-cert")); + assert!(verify(&v, b"new-cert")); + assert!(!verify(&v, b"third-party-cert")); + } + + /// Fail closed: an empty pin set must reject everything rather than + /// falling back to accept-all. + #[test] + fn an_empty_pin_set_rejects_all_certificates() { + let v = verifier(&[]); + assert!(!verify(&v, b"anything")); + } + + /// Signature schemes come from the real provider, not a hardcoded list — + /// an agent using e.g. RSA-PKCS1 must still be able to handshake. + #[test] + fn signature_schemes_come_from_the_provider() { + let v = verifier(&[b"x"]); + let schemes = v.supported_verify_schemes(); + assert!( + schemes.len() > 3, + "suspiciously short scheme list: {schemes:?}" + ); + assert!(schemes.contains(&SignatureScheme::RSA_PKCS1_SHA256)); + assert!(schemes.contains(&SignatureScheme::ECDSA_NISTP256_SHA256)); + } +} diff --git a/socktop_connector/src/types.rs b/socktop_connector/src/types.rs index 47df1c0..e93601d 100644 --- a/socktop_connector/src/types.rs +++ b/socktop_connector/src/types.rs @@ -54,6 +54,10 @@ pub struct GpuInfo { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Metrics { + /// Epoch ms when the agent actually collected this snapshot (agents may + /// serve TTL-cached data). Absent on agents older than 1.60. + #[serde(default)] + pub sampled_at_ms: Option, pub cpu_total: f32, pub cpu_per_core: Vec, pub mem_total: u64, @@ -147,6 +151,10 @@ pub struct JournalResponse { pub entries: Vec, pub total_count: u32, pub truncated: bool, + /// Agent-side explanation for an empty result (journal access limits). + /// Absent on agents older than 1.60. + #[serde(default)] + pub notice: Option, pub cached_at: u64, // Unix timestamp when this data was cached } diff --git a/socktop_connector/src/wasm/requests.rs b/socktop_connector/src/wasm/requests.rs index 17ecc15..5fbb016 100644 --- a/socktop_connector/src/wasm/requests.rs +++ b/socktop_connector/src/wasm/requests.rs @@ -46,6 +46,7 @@ pub async fn send_request_and_wait( // For now, return a placeholder metrics response indicating binary data received // TODO: Implement proper protobuf decoding for binary data let placeholder_metrics = Metrics { + sampled_at_ms: None, cpu_total: 0.0, cpu_per_core: vec![0.0], mem_total: 0, diff --git a/socktop_wasm_test/Cargo.lock b/socktop_wasm_test/Cargo.lock index 90051ac..d6fee8f 100644 --- a/socktop_wasm_test/Cargo.lock +++ b/socktop_wasm_test/Cargo.lock @@ -475,9 +475,7 @@ dependencies = [ [[package]] name = "socktop_connector" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a63dadaa5105df11b0684759a829012257d48e72a469cc554c0cf4394605f5a" +version = "1.51.0" dependencies = [ "flate2", "js-sys", diff --git a/socktop_wasm_test/Cargo.toml b/socktop_wasm_test/Cargo.toml index 4da08eb..9fb275f 100644 --- a/socktop_wasm_test/Cargo.toml +++ b/socktop_wasm_test/Cargo.toml @@ -10,8 +10,8 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -# Use WASM features for WebSocket connectivity (published version) -socktop_connector = { version = "0.1.5", default-features = false, features = ["wasm"] } +# Use WASM features for WebSocket connectivity (in-repo connector via path) +socktop_connector = { path = "../socktop_connector", default-features = false, features = ["wasm"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" wasm-bindgen = "0.2" diff --git a/test_thiserror.rs b/test_thiserror.rs deleted file mode 100644 index e69de29..0000000 diff --git a/zellij_socktop_plugin/Cargo.lock b/zellij_socktop_plugin/Cargo.lock new file mode 100644 index 0000000..cdc2aac --- /dev/null +++ b/zellij_socktop_plugin/Cargo.lock @@ -0,0 +1,4384 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc95d1bdb8e6666b2b217308eeeb09f2d6728d104be3e31916cc74d15420331" +dependencies = [ + "generic-array", +] + +[[package]] +name = "aes" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884391ef1066acaa41e766ba8f596341b96e93ce34f9a43e7d24bf0a0eaf0561" +dependencies = [ + "aes-soft", + "aesni", + "cipher", +] + +[[package]] +name = "aes-gcm" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5278b5fabbb9bd46e24aa69b2fdea62c99088e0a950a9be40e3e0101298f88da" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aes-soft" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be14c7498ea50828a38d0e24a765ed2effe92a705885b57d029cd67d45744072" +dependencies = [ + "cipher", + "opaque-debug", +] + +[[package]] +name = "aesni" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2e11f5e94c2f7d386164cc2aa1f97823fed6f259e486940a71c174dd01b0ce" +dependencies = [ + "cipher", + "opaque-debug", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand 2.5.0", + "futures-lite 2.6.1", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite 2.6.1", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite 2.6.1", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.2", + "futures-lite 2.6.1", + "rustix 1.1.4", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite 2.6.1", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite 2.6.1", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4cec68f03f32e44924783795810fa50a7035d8c8ebe78580ad7e6c703fba38" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f8e7987cbd042a63249497f41aed09f8e65add917ea6566effbc56578d6801" +dependencies = [ + "generic-array", +] + +[[package]] +name = "clap" +version = "3.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" +dependencies = [ + "atty", + "bitflags 1.3.2", + "clap_derive", + "clap_lex", + "indexmap 1.9.3", + "once_cell", + "strsim 0.10.0", + "termcolor", + "textwrap 0.16.2", +] + +[[package]] +name = "clap_complete" +version = "3.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f7a2e0a962c45ce25afce14220bc24f9dade0a1787f185cecf96bfba7847cd8" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "3.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008" +dependencies = [ + "heck 0.4.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "clap_lex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" +dependencies = [ + "os_str_bytes", +] + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "colorsys" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54261aba646433cb567ec89844be4c4825ca92a4f8afba52fc4dd88436e31bbd" + +[[package]] +name = "common-path" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2382f75942f4b3be3690fe4f86365e9c853c1587d6ee58212cebf6e2a9ccd101" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const_fn" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413d67b29ef1021b4d60f4aa1e925ca031751e213832b4b1d588fae623c05c60" + +[[package]] +name = "cookie" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03a5d7b21829bc7b4bf4754a978a241ae54ea55a40f92bb20216e54096f4b951" +dependencies = [ + "aes-gcm", + "base64 0.13.1", + "hkdf", + "hmac", + "percent-encoding", + "rand 0.8.7", + "sha2", + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpuid-bool" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb25d077389e53838a8158c8e99174c5a9d902dee4904320db714f3c653ffba" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-mac" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bff07008ec701e8028e2ceb8f83f0e4274ee62bd2dbdc4fefff2e9a91824081a" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + +[[package]] +name = "ctr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb4a30d54f7443bf3d6191dcd486aca19e67cb3c49fa7a06a319966346707e7f" +dependencies = [ + "cipher", +] + +[[package]] +name = "curl" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a45ee8994e5307cb4c60cfc1c20bf7263ffb771ddc135c9f768a14bcbc15b09" +dependencies = [ + "curl-sys", + "libc", + "openssl-probe", + "openssl-sys", + "schannel", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "curl-sys" +version = "0.4.90+curl-8.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97799a0d220bfb3361e0fe4936966ff8c4b24d65c3f06dfc70d7b680b44e7897" +dependencies = [ + "cc", + "libc", + "libnghttp2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", + "windows-sys 0.61.2", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[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 = [ + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "destructure_traitobject" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c877555693c14d2f84191cfd3ad8582790fc52b5e2274b40b59cf5f5cea25c7" + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "discard" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.2", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "file-id" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13be71e6ca82e91bc0cb862bebaac0b2d1924a5a1d970c822b2f98b63fda8c3" +dependencies = [ + "winapi-util", +] + +[[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 = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bebadab126f8120d410b677ed95eee4ba6eb7c6dd8e34a5ec88a08050e26132" +dependencies = [ + "futures-core", + "futures-sink", + "spinning_top", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand 2.5.0", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "ghash" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97304e4cd182c3846f7575ced3890c53012ce534ad9114046b0a9e00bb30a375" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51ab2f639c231793c5f6114bdb9bbe50a7dbbfcd7c7c6bd8475dec2d991e964f" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "hmac" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1441c6b1e930e2817404b5046f1f989899143a12bf92de603b69f4e0aee1e15" +dependencies = [ + "crypto-mac", + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes 1.12.1", + "fnv", + "itoa", +] + +[[package]] +name = "http-client" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1947510dc91e2bf586ea5ffb412caad7673264e14bb39fb9078da114a94ce1a5" +dependencies = [ + "async-std", + "async-trait", + "cfg-if", + "http-types", + "isahc", + "log", +] + +[[package]] +name = "http-types" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" +dependencies = [ + "anyhow", + "async-channel 1.9.0", + "async-std", + "base64 0.13.1", + "cookie", + "futures-lite 1.13.0", + "infer", + "pin-project-lite", + "rand 0.7.3", + "serde", + "serde_json", + "serde_qs", + "serde_urlencoded", + "url", +] + +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "infer" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "interprocess" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f2533f3be42fffe3b5e63b71aeca416c1c3bc33e4e27be018521e76b1f38fb" +dependencies = [ + "blocking", + "cfg-if", + "futures-core", + "futures-io", + "intmap", + "libc", + "once_cell", + "rustc_version 0.4.1", + "spinning", + "thiserror 1.0.69", + "to_method", + "winapi", +] + +[[package]] +name = "intmap" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae52f28f45ac2bc96edb7714de995cffc174a395fb0abf5bff453587c980d7b9" + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi 0.5.2", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "isahc" +version = "0.9.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2948a0ce43e2c2ef11d7edf6816508998d99e13badd1150be0914205df9388a" +dependencies = [ + "bytes 0.5.6", + "crossbeam-utils", + "curl", + "curl-sys", + "flume", + "futures-lite 1.13.0", + "http", + "log", + "once_cell", + "slab", + "sluice", + "tracing", + "tracing-futures", + "url", + "waker-fn", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kdl" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e03e2e96c5926fe761088d66c8c2aee3a4352a2573f4eaca50043ad130af9117" +dependencies = [ + "miette", + "nom 7.1.3", + "thiserror 1.0.69", +] + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libnghttp2-sys" +version = "0.1.13+1.68.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "492e00167f1418c15648144f42bbfc63099806ecee9bf8d09a6353d6b4856b3c" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +dependencies = [ + "serde_core", + "value-bag", +] + +[[package]] +name = "log-mdc" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94d21414c1f4a51209ad204c1776a3d0765002c76c6abcb602a6f09f1e881c7" + +[[package]] +name = "log4rs" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e947bb896e702c711fccc2bf02ab2abb6072910693818d1d6b07ee2b9dfd86c" +dependencies = [ + "anyhow", + "arc-swap", + "chrono", + "derive_more", + "fnv", + "humantime", + "libc", + "log", + "log-mdc", + "mock_instant", + "parking_lot", + "rand 0.9.5", + "serde", + "serde-value", + "serde_json", + "serde_yaml", + "thiserror 2.0.20", + "thread-id", + "typemap-ors", + "unicode-segmentation", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miette" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" +dependencies = [ + "backtrace", + "backtrace-ext", + "is-terminal", + "miette-derive", + "once_cell", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap 0.15.2", + "thiserror 1.0.69", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "mock_instant" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb517913cfcfb9eeda59f36020269075a152701a01606c612f547e4890be399" + +[[package]] +name = "multimap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nix" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" +dependencies = [ + "bitflags 1.3.2", + "cc", + "cfg-if", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "5.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08959a387a676302eebf4ddbcbc611da04285579f76f88ee0506c63b1a61dd4b" +dependencies = [ + "memchr", + "version_check", +] + +[[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 = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.13.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "notify-debouncer-full" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4812c1eb49be776fb8df4961623bdc01ec9dfdc1abe8211ceb09150a2e64219" +dependencies = [ + "crossbeam-channel", + "file-id", + "notify", + "parking_lot", + "walkdir", +] + +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-float" +version = "3.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1e1c390732d15f1d48471625cd92d154e66db2c56645e29a9cd26f4699f72dc" +dependencies = [ + "num-traits", +] + +[[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 = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" + +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + +[[package]] +name = "parking" +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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +dependencies = [ + "pest", +] + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset 0.4.2", + "indexmap 2.14.0", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset 0.5.7", + "indexmap 2.14.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + +[[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 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.7", +] + +[[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 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.3", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand 2.5.0", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.2", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "polyval" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc4aa140b9abd2bc40d9c3f7ccec842679cd79045ac3a7ac698c1a064b7cd" +dependencies = [ + "cpuid-bool", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8646e95016a7a6c4adea95bafa8a16baab64b583356217f2c85db4a39d9a86" +dependencies = [ + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +dependencies = [ + "bytes 1.12.1", + "prost-derive 0.11.9", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes 1.12.1", + "prost-derive 0.13.5", +] + +[[package]] +name = "prost-build" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119533552c9a7ffacc21e099c24a0ac8bb19c2a2a3f363de84cd9b844feab270" +dependencies = [ + "bytes 1.12.1", + "heck 0.4.1", + "itertools 0.10.5", + "lazy_static", + "log", + "multimap 0.8.3", + "petgraph 0.6.5", + "prettyplease 0.1.25", + "prost 0.11.9", + "prost-types 0.11.9", + "regex", + "syn 1.0.109", + "tempfile", + "which", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "log", + "multimap 0.10.1", + "once_cell", + "petgraph 0.7.1", + "prettyplease 0.2.37", + "prost 0.13.5", + "prost-types 0.13.5", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213622a1460818959ac1181aaeb2dc9c7f63df720db7d788b3e24eacd1983e13" +dependencies = [ + "prost 0.11.9", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver 0.9.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver 1.0.28", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser 0.7.0", +] + +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser 0.10.3", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + +[[package]] +name = "semver-parser" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +dependencies = [ + "pest", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float 2.10.1", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_qs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da05c97445caa12d05e848c4a4fcbbea29e748ac28f7e80e9b010392063770" +dependencies = [ + "sha1_smol", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer", + "cfg-if", + "cpufeatures", + "digest", + "opaque-debug", +] + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs 6.0.0", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e31d442c16f047a671b5a71e2161d6e68814012b7f5379d269ebd915fac2729" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[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-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "sluice" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5" +dependencies = [ + "async-channel 1.9.0", + "futures-core", + "futures-io", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "socktop_connector" +version = "1.51.0" +dependencies = [ + "flate2", + "js-sys", + "prost 0.13.5", + "prost-build 0.13.5", + "protoc-bin-vendored", + "serde", + "serde_json", + "thiserror 2.0.20", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "spinning" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4f0e86297cad2658d92a707320d87bf4e6ae1050287f51d19b67ef3f153a7b" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spinning_top" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9eb1a2f4c41445a3a0ff9abc5221c5fcd28e1f13cd7c0397706f9ac938ddb0" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "standback" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e113fb6f3de07a243d434a56ec6f186dfd51cb08448239fe7bcae73f87ff28ff" +dependencies = [ + "version_check", +] + +[[package]] +name = "stdweb" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5" +dependencies = [ + "discard", + "rustc_version 0.2.3", + "stdweb-derive", + "stdweb-internal-macros", + "stdweb-internal-runtime", + "wasm-bindgen", +] + +[[package]] +name = "stdweb-derive" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87a60a40fccc84bef0652345bbbbbe20a605bf5d0ce81719fc476f5c03b50ef" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_derive", + "syn 1.0.109", +] + +[[package]] +name = "stdweb-internal-macros" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fa5ff6ad0d98d1ffa8cb115892b6e69d67799f6763e162a1c9db421dc22e11" +dependencies = [ + "base-x", + "proc-macro2", + "quote", + "serde", + "serde_derive", + "serde_json", + "sha1", + "syn 1.0.109", +] + +[[package]] +name = "stdweb-internal-runtime" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" + +[[package]] +name = "strip-ansi-escapes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "011cbb39cf7c1f62871aea3cc46e5817b0937b49e9447370c93cacbe93a766d8" +dependencies = [ + "vte 0.10.1", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7318c509b5ba57f18533982607f24070a55d353e90d4cae30c467cdb2ad5ac5c" + +[[package]] +name = "strum_macros" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8bc6b87a5112aeeab1f4a9f7ab634fe6cbefc4850006df31267f4cfb9e3149" +dependencies = [ + "heck 0.3.3", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84231692eb0d4d41e4cdd0cabfdd2e6cd9e255e65f80c9aa7c98dd502b4233d" +dependencies = [ + "is-terminal", +] + +[[package]] +name = "supports-unicode" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f850c19edd184a205e883199a261ed44471c81e39bd95b1357f5febbef00e77a" +dependencies = [ + "is-terminal", +] + +[[package]] +name = "surf" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "718b1ae6b50351982dedff021db0def601677f2120938b070eadb10ba4038dd7" +dependencies = [ + "async-std", + "async-trait", + "cfg-if", + "futures-util", + "getrandom 0.2.17", + "http-client", + "http-types", + "log", + "mime_guess", + "once_cell", + "pin-project-lite", + "serde", + "serde_json", +] + +[[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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand 2.5.0", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "terminfo" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da31aef70da0f6352dbcb462683eb4dd2bfad01cf3fc96cf204547b9a839a585" +dependencies = [ + "dirs 4.0.0", + "fnv", + "nom 5.1.3", + "phf 0.11.3", + "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.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9509a978a10fcbace4991deae486ae10885e0f4c2c465123e08c9714a90648fa" +dependencies = [ + "anyhow", + "base64 0.21.7", + "bitflags 1.3.2", + "filedescriptor", + "finl_unicode", + "fixedbitset 0.4.2", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.24.3", + "num-derive", + "num-traits", + "ordered-float 3.9.2", + "pest", + "pest_derive", + "phf 0.10.1", + "regex", + "semver 0.11.0", + "sha2", + "signal-hook 0.1.17", + "siphasher 0.3.11", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-color-types", + "wezterm-dynamic 0.1.0", + "winapi", +] + +[[package]] +name = "textwrap" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7b3e525a49ec206798b40326a44121291b530c963cfb01018f63e135bac543d" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread-id" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2010d27add3f3240c1fef7959f46c814487b216baee662af53be645ba7831c07" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "time" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4752a97f8eebd6854ff91f1c1824cd6160626ac4bd44287f7f4ea2035a02a242" +dependencies = [ + "const_fn", + "libc", + "standback", + "stdweb", + "time-macros", + "version_check", + "winapi", +] + +[[package]] +name = "time-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957e9c6e26f12cb6d0dd7fc776bb67a706312e7299aed74c8dd5b17ebb27e2f1" +dependencies = [ + "proc-macro-hack", + "time-macros-impl", +] + +[[package]] +name = "time-macros-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c141a1b43194f3f56a1411225df8646c55781d5f26db825b3d98507eb482f" +dependencies = [ + "proc-macro-hack", + "proc-macro2", + "quote", + "standback", + "syn 1.0.109", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "to_method" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c4ceeeca15c8384bbc3e011dbd8fccb7f068a440b752b7d9b32ceb0ca0e2e8" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + +[[package]] +name = "typemap-ors" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68c24b707f02dd18f1e4ccceb9d49f2058c2fb86384ef9972592904d7a28867" +dependencies = [ + "unsafe-any-ors", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f214e8f697e925001e66ec2c6e37a4ef93f0f78c2eed7814394e10c62025b05" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "unsafe-any-ors" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a303d30665362d9680d7d91d78b23f5f899504d4f08b3c4cf08d055d87c0ad" +dependencies = [ + "destructure_traitobject", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "value-bag" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vte" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbce692ab4ca2f1f3047fcf732430249c0e971bfdd2b234cf2c47ad93af5983" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "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 0.2.1", +] + +[[package]] +name = "wezterm-color-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6e7a483dd2785ba72705c51e8b1be18300302db2a78368dac9bc8773857777" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic 0.1.0", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75e78c0cc60a76de5d93f9dad05651105351e151b6446ab305514945d7588aa" +dependencies = [ + "log", + "ordered-float 3.9.2", + "strsim 0.10.0", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float 4.6.0", + "strsim 0.11.1", + "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 = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zellij-tile" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cd7459277f12a843b3edba8ed44e32ee8f79a76cc26f165d65c09f1441377b" +dependencies = [ + "clap", + "serde", + "serde_json", + "strum", + "strum_macros", + "zellij-utils", +] + +[[package]] +name = "zellij-utils" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "019bf9a4795c67b97da79851f59f3f3970255d1f3e7a6dff4e1033366ca27009" +dependencies = [ + "anyhow", + "async-channel 1.9.0", + "async-std", + "backtrace", + "clap", + "clap_complete", + "colored", + "colorsys", + "common-path", + "crossbeam", + "directories", + "futures", + "humantime", + "include_dir", + "interprocess", + "kdl", + "lazy_static", + "libc", + "log", + "log4rs", + "miette", + "nix 0.23.2", + "notify-debouncer-full", + "once_cell", + "openssl-sys", + "percent-encoding", + "prost 0.11.9", + "prost-build 0.11.9", + "regex", + "rmp-serde", + "serde", + "serde_json", + "shellexpand", + "signal-hook 0.3.18", + "strip-ansi-escapes", + "strum", + "strum_macros", + "surf", + "tempfile", + "termwiz", + "thiserror 1.0.69", + "unicode-width", + "url", + "uuid", + "vte 0.11.1", +] + +[[package]] +name = "zellij_socktop_plugin" +version = "0.1.0" +dependencies = [ + "chrono", + "futures", + "serde", + "serde_json", + "socktop_connector", + "zellij-tile", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/zellij_socktop_plugin/Cargo.toml b/zellij_socktop_plugin/Cargo.toml index 0b23eeb..6f7166a 100644 --- a/zellij_socktop_plugin/Cargo.toml +++ b/zellij_socktop_plugin/Cargo.toml @@ -3,6 +3,9 @@ name = "zellij_socktop_plugin" version = "0.1.0" edition = "2021" +# Standalone package, not part of the parent workspace (same as socktop_wasm_test) +[workspace] + [lib] crate-type = ["cdylib"] @@ -10,7 +13,7 @@ crate-type = ["cdylib"] zellij-tile = "0.40.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -socktop_connector = { version = "0.1.5", default-features = false, features = ["wasm"] } +socktop_connector = { path = "../socktop_connector", default-features = false, features = ["wasm"] } futures = "0.3" [dependencies.chrono]