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]