From dca378b5efe76f19e1c539decb6a9a6d016e493e Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 11 Apr 2026 16:30:18 +0200 Subject: [PATCH] initial commit --- CLAUDE.md | 86 +++++++++++++++++++ docs/agent-spec.md | 196 +++++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 148 ++++++++++++++++++++++++++++++++ docs/data-model.md | 101 ++++++++++++++++++++++ docs/protocol.md | 191 +++++++++++++++++++++++++++++++++++++++++ docs/ui-spec.md | 164 ++++++++++++++++++++++++++++++++++++ usecase.md | 43 ++++++++++ 7 files changed, 929 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/agent-spec.md create mode 100644 docs/architecture.md create mode 100644 docs/data-model.md create mode 100644 docs/protocol.md create mode 100644 docs/ui-spec.md create mode 100644 usecase.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5477d90 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**RMon** is a real-time data monitor and plotter for distributed control systems accessed over SSH. The UI runs locally; data is acquired by a remote agent. See `usecase.md` for requirements and `docs/` for full specs. + +## Language and Tech Stack + +**Rust throughout** — single language for agent, UI, and shared types. + +| Crate | Role | Key deps | +|-------|------|----------| +| `rmon-common` | Shared types, protocol message enums | `serde`, `bincode` | +| `rmon-agent` | Remote data acquisition server | `tokio`, `tracing`, `anyhow`, `toml`, `serde` | +| `rmon-ui` | Operator desktop application | `egui`, `eframe`, `egui_plot`, `tokio`, `tracing` | + +## Build Commands + +```sh +# Build everything (debug) +cargo build + +# Build the agent as a fully static binary (primary deployment target) +cargo build --release --target x86_64-unknown-linux-musl -p rmon-agent + +# Build agent for ARM (e.g. remote Raspberry Pi) +cargo build --release --target aarch64-unknown-linux-musl -p rmon-agent + +# Build the UI for the current platform +cargo build --release -p rmon-ui + +# Run tests +cargo test + +# Run tests for a single crate +cargo test -p rmon-common +cargo test -p rmon-agent + +# Run a single test +cargo test -p rmon-agent sources::csv::tests::parse_timestamp + +# Lint +cargo clippy --all-targets --all-features + +# Format +cargo fmt +``` + +## Architecture (short version) + +Two executables communicate via a binary TCP protocol over an SSH tunnel: + +- **`rmon-agent`**: runs on the remote machine, acquires data from heterogeneous sources (CSV, UDP, EPICS…), stores it, streams it to connected clients. Built as a fully static musl binary — single file, zero runtime deps, `scp` + run. + +- **`rmon-ui`**: runs on the operator's local desktop. Manages the SSH tunnel automatically (uses the system `ssh` binary + user's `~/.ssh/config`). Renders real-time plots with `egui`/`egui_plot`. + +- **`rmon-common`**: library crate shared by both. Contains all types (`SignalId`, `SignalInfo`, `Sample`, `StorageMode`) and the `ClientMessage`/`AgentMessage` protocol enums serialized with `bincode`. + +Full architecture: `docs/architecture.md` +Wire protocol: `docs/protocol.md` +Data model: `docs/data-model.md` +Agent config and source trait: `docs/agent-spec.md` +UI layout and state machine: `docs/ui-spec.md` + +## Key Design Decisions + +- **Protocol framing**: `[u32 LE length][bincode payload]` — no gRPC/HTTP overhead. Both sides are Rust so `bincode` + shared enum types is simplest. +- **Agent portability**: `x86_64-unknown-linux-musl` gives a fully static binary requiring only Linux kernel ≥ 3.2. The agent never has a GUI, so no X11/GL needed. +- **Remote access UX**: The UI deploys the agent binary via SSH itself (like VSCode Remote). Users only need to enter their SSH host alias; existing `~/.ssh/config` (including `ProxyJump`) handles multi-hop routing. +- **UI rendering**: `egui` immediate-mode with `glow` (OpenGL 2.1) backend. For signals with more points than screen pixels, min-max downsampling runs on the CPU before drawing. +- **Timestamps**: all source timestamps are normalized to `i64` nanoseconds since Unix epoch inside the source adapter. The rest of the codebase only sees `i64`. +- **Concurrency (agent)**: one tokio task per data source → `broadcast` channel → dispatcher fans out to storage writers and per-client session tasks. +- **UI/async boundary**: tokio runtime in a background thread; egui on main thread. `std::sync::mpsc` channels carry `UiEvent` (agent→UI) and `Command` (UI→agent). + +## Adding a New Data Source + +1. Create `rmon-agent/src/sources/{name}.rs` +2. Implement the `DataSource` trait (`name()`, `signals()`, `async fn run(self, tx)`) +3. Add a new variant to `SourceConfig` in `config.rs` and construct it in `sources/mod.rs` +4. Add config example to `docs/agent-spec.md` + +## Protocol Changes + +All protocol types live in `rmon-common/src/protocol.rs`. Since `bincode` is not self-describing, any change to `ClientMessage` or `AgentMessage` requires a protocol version bump in the `Handshake` message and both sides must be rebuilt together. diff --git a/docs/agent-spec.md b/docs/agent-spec.md new file mode 100644 index 0000000..5ec24f7 --- /dev/null +++ b/docs/agent-spec.md @@ -0,0 +1,196 @@ +# RMon Agent Specification + +## Binary + +- **Build target**: `x86_64-unknown-linux-musl` (primary), `aarch64-unknown-linux-musl` (secondary) +- **Static**: fully static binary, no dynamic library dependencies whatsoever +- **Runtime requirements**: Linux kernel ≥ 3.2, no root, no installed packages +- **Deployment**: single file, placed at `~/.rmon/agent` by the UI or manually by the user + +Build command: +```sh +cargo build --release --target x86_64-unknown-linux-musl -p rmon-agent +``` + +Cross-compilation for ARM: +```sh +cargo build --release --target aarch64-unknown-linux-musl -p rmon-agent +``` + +The `.cargo/config.toml` in `rmon-agent/` sets `[profile.release]` with `strip = true` and `lto = true` to minimize binary size. + +## CLI + +``` +rmon-agent [OPTIONS] + +Options: + --config Config file path (default: ~/.rmon/config.toml) + --port TCP listen port (default: 7891) + --bind Bind address (default: 127.0.0.1 — localhost only, relies on SSH tunnel) + --log Log level: error|warn|info|debug (default: info) + --log-file Log to file instead of stderr (default: stderr) + -v, --version Print version and exit + -h, --help Print help and exit +``` + +**The agent always binds to localhost only** (`127.0.0.1`). Remote access is exclusively through the SSH tunnel. Never expose the agent port on a public interface. + +## Configuration File + +TOML format. The agent watches the config file and reloads source definitions on change (without restarting). + +```toml +[storage] +# Base directory for recorded data +data_dir = "~/.rmon/data" + +# Default storage mode for all signals unless overridden per source +# "continuous" or "circular" +default_mode = "circular" +# For circular mode: how much history to keep in memory (and optionally on disk) +default_window = "2h" + +[[sources]] +type = "udp" +name = "imu_stream" +bind = "0.0.0.0:9000" + +# Signals received in each UDP packet (fixed binary layout) +[[sources.signals]] +id = "accel_x" +label = "Accelerometer X" +unit = "m/s²" +scale = 0.001 +offset = 0.0 +byte_offset = 0 # position in the UDP payload +byte_type = "f32_le" # type at that offset: f32_le, f32_be, f64_le, f64_be, i16_le, ... +path = ["imu", "accelerometer"] + +[[sources.signals]] +id = "accel_y" +label = "Accelerometer Y" +unit = "m/s²" +scale = 0.001 +offset = 0.0 +byte_offset = 4 +byte_type = "f32_le" +path = ["imu", "accelerometer"] + +# UDP timestamp: how to find the timestamp in the packet +[sources.timestamp] +byte_offset = 24 +byte_type = "u64_le" +format = "microseconds_since_epoch" +# Other format options: +# "nanoseconds_since_epoch" +# "seconds_since_epoch" +# "milliseconds_relative" (relative to agent start; converted to absolute) + +[[sources]] +type = "csv" +name = "coolant_temps" +path = "/mnt/shared/data/coolant.csv" + +# How often to poll the file for new rows (default: 1s) +poll_interval = "500ms" + +# Timestamp column +[sources.timestamp] +column = "Time" +format = "%Y.%m.%d %H:%M:%S" + +[[sources.signals]] +id = "inlet" +column = "T_inlet" +label = "Coolant Inlet Temperature" +unit = "°C" +scale = 1.0 +offset = 0.0 +path = ["temperatures", "coolant"] + +[[sources]] +type = "epics" +name = "vacuum" +# EPICS Channel Access address (if not using broadcast) +addr = "192.168.1.50:5064" + +[[sources.signals]] +id = "chamber_pressure" +pv = "VAC:CHAMBER:PRESS" +label = "Chamber Pressure" +unit = "mbar" +scale = 1.0 +offset = 0.0 +path = ["vacuum"] +``` + +## Source Adapter Trait + +All data sources implement: + +```rust +trait DataSource: Send + 'static { + /// Human-readable name (from config `name` field). + fn name(&self) -> &str; + + /// Full list of signals this source provides. + fn signals(&self) -> Vec; + + /// Start producing samples. Sends to `tx` until the returned future resolves or `tx` is dropped. + /// This runs in its own tokio task. + async fn run(self, tx: mpsc::Sender<(SignalId, Sample)>) -> anyhow::Result<()>; +} +``` + +New source types are added by implementing this trait and registering in `sources/mod.rs`. + +## Storage Engine + +### Circular Buffer (in-memory) + +Per-signal `VecDeque` bounded by `window` duration. New samples evict old ones when the oldest sample is older than `now - window`. Thread-safe via `tokio::sync::RwLock`. + +Historical queries on circular buffers are served from this in-memory structure. + +### Continuous Storage (on-disk) + +Append-only binary log: sequence of fixed-size `(i64, f64)` records (16 bytes each, little-endian). One file per signal per recording session. + +File path: `{data_dir}/{source_name}/{signal_id}/{session_start_ns}.bin` + +Historical queries on continuous storage use binary search on the file (records are sorted by timestamp) to find the start offset, then stream records to the client. + +## Concurrency Model + +``` +main thread + └── tokio runtime + ├── listener task (accepts TCP connections) + ├── per-source task (one per DataSource::run) + │ └── sends to dispatcher channel + ├── dispatcher task (fans out samples to storage + all sessions) + │ ├── storage writer tasks (one per signal being recorded) + │ └── per-session sender tasks (one per connected client) + └── per-session reader task (handles incoming ClientMessage requests) +``` + +The dispatcher uses a `tokio::sync::broadcast` channel. Each session's sender task has its own `broadcast::Receiver`. Lagged receivers are detected and reported as a warning (UI display gap, not a crash). + +## Signal ID Namespacing + +The full `SignalId` used in the protocol is constructed as: + +``` +{source_type}://{source_name}/{signal_local_id} +``` + +Example: `udp://imu_stream/accel_x` + +The `signal_local_id` is the `id` field from the config. Source names must be unique within one agent config. + +## Logging + +Uses the `tracing` crate. Output format: structured text to stderr (or log file if `--log-file` is set). + +No `println!` in production code; use `tracing::{info, warn, error, debug}`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0aefb17 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,148 @@ +# RMon Architecture + +## Overview + +RMon is split into two executables that communicate over a TCP connection tunneled through SSH: + +``` +Operator Machine Remote Network +┌─────────────────────────────┐ ┌──────────────────────────────────────┐ +│ rmon-ui │ │ rmon-agent │ +│ ┌──────────┐ ┌───────────┐ │ SSH │ ┌──────────┐ ┌──────────────────┐ │ +│ │ Plot │ │ Signal │ │ Tunnel │ │ Protocol │ │ Source Adapters │ │ +│ │ Panels │ │ Tree │◄├──────────┤► │ Server │◄─│ CSV / UDP / EPICS│ │ +│ └──────────┘ └───────────┘ │ │ └──────────┘ └──────────────────┘ │ +│ ┌──────────────────────┐ │ │ ┌──────────────────┐ │ +│ │ SSH Connection Mgr │ │ │ │ Storage Engine │ │ +│ └──────────────────────┘ │ │ │ Continuous/Circ. │ │ +└─────────────────────────────┘ │ └──────────────────┘ │ + └──────────────────────────────────────┘ +``` + +## Language and Build Targets + +**Single language: Rust** throughout agent, UI, and shared library. + +| Crate | Purpose | Build Target | +|-------|---------|--------------| +| `rmon-common` | Shared types and protocol messages | (library, no binary) | +| `rmon-agent` | Remote data acquisition server | `x86_64-unknown-linux-musl` (fully static, zero runtime deps) | +| `rmon-ui` | Operator desktop application | native platform (dynamically links X11 + libGL) | + +### Why musl for the agent + +The agent must run on remote machines with arbitrary (potentially old) Linux environments without root access. Compiling with `x86_64-unknown-linux-musl` produces a single binary with: +- No glibc dependency +- No dynamic linker requirement +- Works on any Linux kernel ≥ 3.2 +- Can be copied with `scp` and run immediately + +The UI runs on the operator's own modern desktop, so strict portability is not required there. + +### Why egui for the UI + +- Immediate-mode rendering — natural fit for real-time data that changes every frame +- `glow` backend uses OpenGL 2.1+, available on all modern desktop Linux setups +- Cross-platform (Linux, Windows, macOS) from a single Rust codebase +- Ships as a single binary (dynamically links only X11/Wayland + libGL which are always present on any desktop Linux) +- `egui_plot` provides interactive time-series plots with zoom/pan; custom `egui` painter used for high-throughput rendering paths + +## Cargo Workspace Layout + +``` +rmon/ +├── Cargo.toml # workspace root +├── rmon-common/ # shared types and protocol (lib crate) +│ └── src/ +│ ├── signal.rs # SignalId, SignalInfo, Sample, StorageMode +│ └── protocol.rs # ClientMessage, AgentMessage enums +├── rmon-agent/ # remote acquisition agent +│ ├── .cargo/config.toml # sets default target to x86_64-unknown-linux-musl +│ └── src/ +│ ├── main.rs +│ ├── config.rs # TOML config file parsing +│ ├── sources/ # one module per data source type +│ │ ├── trait.rs # DataSource trait +│ │ ├── csv.rs +│ │ ├── udp.rs +│ │ └── epics.rs +│ ├── storage/ +│ │ ├── continuous.rs +│ │ └── circular.rs +│ └── server/ +│ ├── listener.rs # TCP accept loop +│ └── session.rs # per-client session handler +└── rmon-ui/ # operator desktop UI + └── src/ + ├── main.rs + ├── app.rs # top-level App struct, egui App impl + ├── connection/ + │ ├── ssh.rs # SSH tunnel lifecycle + │ └── client.rs # async protocol client (wraps TCP stream) + ├── state/ + │ └── session.rs # connected session state, signal cache + └── ui/ + ├── connect_dialog.rs + ├── signal_tree.rs + ├── plot_panel.rs # one per open plot window + └── toolbar.rs +``` + +## Remote Access: SSH-Based Deployment + +RMon follows the same pattern as VSCode Remote: the UI manages the agent lifecycle transparently, leveraging the user's existing `~/.ssh/config` (including `ProxyJump` for multi-hop networks). + +### Connection flow + +1. User enters an SSH host alias (from their `~/.ssh/config`) and optional agent port +2. UI runs: `ssh {host} 'uname -m'` to detect remote architecture +3. UI checks if agent binary is current: `ssh {host} 'cat ~/.rmon/agent.sha256'` +4. If stale/missing: UI pipes the correct pre-built binary via SSH: + ``` + cat rmon-agent-{arch} | ssh {host} 'mkdir -p ~/.rmon && cat > ~/.rmon/agent && chmod +x ~/.rmon/agent' + ``` +5. UI starts the agent (if not already running): + ``` + ssh {host} 'pgrep -f "rmon-agent --port {port}" || nohup ~/.rmon/agent --port {port} >> ~/.rmon/agent.log 2>&1 &' + ``` +6. UI establishes SSH local port forward: `ssh -N -L {local_port}:localhost:{remote_port} {host}` +7. UI opens TCP connection to `localhost:{local_port}` and sends `Handshake` + +No special software needs to be installed on the remote machine. All multi-hop SSH configuration is handled transparently by the system `ssh` binary reading the user's `~/.ssh/config`. + +## Data Flow (steady state) + +``` +Source (e.g. UDP) + │ raw packets + source timestamp + ▼ +DataSource adapter + │ normalizes timestamp → i64 nanoseconds since Unix epoch + │ applies scale/offset + ▼ +Dispatcher (tokio broadcast channel per signal) + │ + ├──► Storage engine (writes to ring buffer or append log) + │ + └──► Session handlers (one per connected UI client) + │ batches samples, sends DataBatch over TCP + ▼ + rmon-ui client + │ updates per-signal ring buffer (last N seconds for display) + ▼ + Plot panels (egui repaint) +``` + +## Protocol Summary + +Binary framing: `[u32 little-endian length][bincode-serialized payload]` + +Messages are typed Rust enums defined in `rmon-common::protocol`. See `docs/protocol.md` for the full message reference. + +## Agent Configuration + +The agent is configured via a TOML file (default: `~/.rmon/config.toml`). It declares: +- Which data sources to connect to (type, address, signal definitions) +- Default storage policy + +See `docs/agent-spec.md` for the full configuration reference. diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..8e7345c --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,101 @@ +# RMon Data Model + +## Signal + +A **signal** is a named scalar time-series. Every signal has a unique `SignalId` and associated metadata. + +```rust +/// Globally unique signal identifier. +/// Convention: "{source_type}://{source_name}/{path/to/signal}" +/// Examples: +/// "udp://imu_stream/accel_x" +/// "csv:///mnt/data/temperatures.csv/sensor_3" +/// "epics://PV:SYS:TEMP:READBACK" +type SignalId = String; + +struct SignalInfo { + id: SignalId, + label: String, // human-readable display name + unit: String, // e.g. "m/s²", "°C", "" + scale: f64, // raw_value * scale + offset = engineering_value + offset: f64, + sampling_period: Option,// None if irregular/event-driven + source_type: SourceType, + path: Vec, // tree path for UI grouping, e.g. ["imu", "accel"] +} + +enum SourceType { Csv, Udp, Epics, Custom(String) } +``` + +Signals are **read-only** from the UI's perspective. The agent owns all signal definitions (from its config file). + +## Sample + +```rust +struct Sample { + /// Nanoseconds since Unix epoch (UTC). + /// All source timestamps are normalized to this by the adapter. + timestamp: i64, + value: f64, +} +``` + +All timestamps are normalized at ingestion time inside the adapter. The adapter knows the source-specific format (string, u64 seconds, etc.) and converts it. The rest of the system only sees `i64` nanoseconds. + +## Timestamp Normalization Examples + +| Source format | Example | Agent conversion | +|--------------|---------|-----------------| +| `%Y.%m.%d %H:%M:%S` string | `"2024.01.15 14:32:07"` | parse → UTC `i64` ns | +| u64 microseconds since epoch | `1705329127000000` | multiply by 1000 | +| u64 seconds since epoch | `1705329127` | multiply by 1_000_000_000 | +| Relative milliseconds | `12345` | add agent start epoch | + +Adapters declare their `TimestampFormat` in config; the normalization logic lives in a shared utility in `rmon-agent::sources`. + +## Storage Modes + +The agent can store signal data in two modes per signal group: + +### Continuous Storage + +```rust +StorageMode::Continuous +``` + +- Data is appended to an on-disk log starting when recording is requested +- Data is **kept until explicitly deleted** (either via UI command or manual deletion) +- File format: binary append log of `(i64 timestamp, f64 value)` pairs, one file per signal +- File path: `{storage_dir}/{signal_id_escaped}/{start_timestamp}.bin` + +### Circular (Loop) Storage + +```rust +StorageMode::Circular { window: Duration } +``` + +- Agent maintains an in-memory ring buffer of the last `window` duration +- On UI request, can also persist to disk (same format as continuous) +- `window` is configured per signal group in the agent config + +### Storage Location + +Configured per agent in `config.toml`. Defaults to `~/.rmon/data/`. The agent never deletes data automatically (except from in-memory circular buffers). + +## Signal Tree + +For UI navigation, signals are organized into a tree using the `path` field of `SignalInfo`. Each element of the `Vec` is a tree level. + +Example paths: +``` +["system", "imu", "accelerometer"] → system > imu > accelerometer > {signals} +["temperatures", "coolant"] → temperatures > coolant > {signals} +``` + +The UI renders this as a collapsible tree panel. Signals can be dragged from the tree onto plot panels. + +## UI-Side Signal Cache + +The UI maintains a per-signal ring buffer for display (configurable, default: last 60 seconds). Incoming `DataBatch` messages are appended to this buffer. Historical query responses fill in older portions. + +The display buffer is separate from the agent's storage — it only holds what the UI needs to render the current view. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..7d1614e --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,191 @@ +# RMon Wire Protocol + +## Transport + +- **TCP** over an SSH local port-forward tunnel +- **Port**: configurable, default `7891` +- One persistent TCP connection per UI client +- Multiple clients can connect to the same agent simultaneously + +## Framing + +Every message (in both directions) uses the same length-prefix frame: + +``` +┌─────────────────────┬────────────────────────────┐ +│ length : u32 LE │ payload : [u8; length] │ +└─────────────────────┴────────────────────────────┘ +``` + +- `length` is the byte count of the serialized payload (excludes the 4-byte length field itself) +- `payload` is a `bincode`-serialized `ClientMessage` (client→agent) or `AgentMessage` (agent→client) +- `bincode` configuration: little-endian, fixed-size integers, no trailing bytes + +Maximum frame size: **16 MiB** (`length` values above this are a protocol error; close connection). + +## Versioning + +The `Handshake` / `HandshakeAck` exchange negotiates compatibility. If the agent does not support the client's version it sends `HandshakeAck { accepted: false }` and closes the connection. Both sides must be built from the same `rmon-common` version to guarantee `bincode` schema compatibility. + +## Client → Agent Messages + +```rust +enum ClientMessage { + /// Must be the first message sent. No other messages are valid before HandshakeAck. + Handshake { + protocol_version: u16, // current: 1 + client_version: String, + }, + + /// Request the full list of signals the agent knows about. + /// Agent replies with one SignalList message. + ListSignals, + + /// Subscribe to live data for the given signals. + /// Agent begins sending DataBatch messages for these signals. + /// `from_ns`: if Some, also request buffered/stored data from that timestamp forward + /// before live data begins. + Subscribe { + ids: Vec, + from_ns: Option, + }, + + /// Stop live delivery for these signals. Does not affect storage. + Unsubscribe { + ids: Vec, + }, + + /// Request stored historical data for a signal. + /// Agent replies with one or more HistoryChunk messages (done=true on last chunk). + /// `max_points`: agent may downsample to fit; 0 = no limit. + QueryHistory { + id: SignalId, + from_ns: i64, + to_ns: i64, + max_points: u32, + }, + + /// Start recording the given signals. Mode overrides any existing mode for these signals. + StartRecording { + ids: Vec, + mode: StorageMode, + }, + + /// Stop recording (data already written is kept). + StopRecording { + ids: Vec, + }, + + /// Request current agent status (version, uptime, source states). + GetStatus, +} +``` + +## Agent → Client Messages + +```rust +enum AgentMessage { + /// Response to Handshake. + HandshakeAck { + protocol_version: u16, + agent_version: String, + accepted: bool, + reject_reason: Option, + }, + + /// Full signal list, sent in response to ListSignals. + /// Sent unsolicited again if the signal list changes (source added/removed). + SignalList { + signals: Vec, + }, + + /// Live data batch for subscribed signals. + /// The agent batches samples collected since the last send (target: ~50ms intervals). + /// Multiple signals may be grouped into one message to reduce framing overhead. + DataBatch { + batches: Vec, + }, + + /// One chunk of a historical query response. + HistoryChunk { + id: SignalId, + samples: Vec, + done: bool, // true on the final chunk for this query + }, + + /// Acknowledgement of StartRecording / StopRecording. + RecordingStatus { + id: SignalId, + recording: bool, + mode: Option, + }, + + /// Response to GetStatus. + StatusReport { + agent_version: String, + hostname: String, + uptime_secs: u64, + sources: Vec, + }, + + /// Unrecoverable error for a specific operation. Connection remains open. + Error { + context: String, // e.g. "QueryHistory" or "Subscribe" + message: String, + }, +} + +struct SignalBatch { + id: SignalId, + samples: Vec, +} + +struct SourceStatus { + name: String, + source_type: SourceType, + connected: bool, + last_sample_ns: Option, + error: Option, +} +``` + +## Session Lifecycle + +``` +Client Agent + │ │ + │──── Handshake ────────────────────► │ + │ │ + │◄─── HandshakeAck (accepted=true) ─── │ + │ │ + │──── ListSignals ───────────────────► │ + │◄─── SignalList ───────────────────── │ + │ │ + │──── Subscribe {ids, from_ns} ──────► │ ←── live data starts flowing + │◄═══ DataBatch (repeated) ═══════════ │ + │ │ + │──── QueryHistory ──────────────────► │ + │◄─── HistoryChunk (done=false) ────── │ + │◄─── HistoryChunk (done=false) ────── │ + │◄─── HistoryChunk (done=true) ─────── │ + │ │ + │──── StartRecording ────────────────► │ + │◄─── RecordingStatus ──────────────── │ + │ │ + │ [TCP close / SSH tunnel drops] │ + │ │ ←── agent continues running, + data continues to be stored +``` + +## Batching Policy + +The agent collects samples from all sources in a shared `tokio::broadcast` channel. A per-session task drains the channel and sends `DataBatch` messages at a target interval of **50 ms** (20 Hz UI update rate). If a batch would exceed 1 MiB, it is split and sent immediately. + +High-frequency signals (e.g., 10 kHz UDP) are automatically decimated to 10× the batch rate (200 Hz) for live display; full-resolution data is still stored. + +## Error Handling + +- `Error` messages are non-fatal; the session continues +- If the agent cannot fulfill a `QueryHistory` (signal not found, time range not stored), it sends `Error` then continues +- If the TCP connection drops, the agent discards the session's subscriptions; no other sessions are affected +- The UI reconnects automatically (with exponential backoff) if the tunnel drops diff --git a/docs/ui-spec.md b/docs/ui-spec.md new file mode 100644 index 0000000..42960d6 --- /dev/null +++ b/docs/ui-spec.md @@ -0,0 +1,164 @@ +# RMon UI Specification + +## Technology + +- **Framework**: `egui` + `eframe` (immediate-mode Rust GUI) +- **Render backend**: `glow` (OpenGL 2.1+) — chosen for maximum Linux compatibility +- **Plotting**: `egui_plot` for interactive panels; custom `egui` painter (line strip primitives) for high-throughput paths (>100k points/frame) +- **Async runtime**: `tokio` (runs in a background thread; communicates with egui via `std::sync::mpsc`) +- **SSH tunnel management**: spawns system `ssh` subprocess + +## Deployment + +Single binary. On Linux, dynamically links only: +- `libGL` (OpenGL driver, always present on any desktop Linux) +- `libX11` / `libxcb` (X11, or Wayland via `libwayland-client` with `wayland` feature) +- `libc` (glibc, standard on all Linux distributions) + +On Windows: fully static (links Win32 APIs and ANGLE for OpenGL ES via DirectX). + +Pre-built agent binaries for all supported architectures are **embedded in the UI binary** at compile time using `include_bytes!`. This allows the UI to deploy the agent without any extra files. + +## Layout + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ [Connect ▼] [host: myserver] [⏺ Record] [⏹ Stop] [Status: OK] │ ← Toolbar +├────────────────┬────────────────────────────────────────────────────┤ +│ Signal Tree │ Plot Panel 1 │ Plot Panel 2 │ +│ │ │ │ +│ ▼ imu │ [signal A] [signal B] [✕] │ [signal C] [✕] │ +│ ▼ accel │ ┌──────────────────────────┐ │ ┌─────────────┐ │ +│ accel_x ══╪═►│ time-series plot │ │ │ │ │ +│ accel_y │ │ │ │ │ │ │ +│ accel_z │ │ ╭─╮ ╭──╮ │ │ │ │ │ +│ ▼ gyro │ │ ╯ ╰──╯ ╰─ │ │ │ │ │ +│ gyro_x │ │ │ │ └─────────────┘ │ +│ gyro_y │ │ ┼────────────────────── │ │ │ +│ ▼ temps │ └──────────────────────────┘ │ [+ Add Plot] │ +│ inlet │ [cursor A: t=12.3s val=1.2] │ │ +│ outlet │ [cursor B: t=15.1s Δt=2.8s] │ │ +│ │ [A-B: Δval=-0.3 mean=1.05] │ │ +└───────────────┴────────────────────────────────┴───────────────────┘ +``` + +## Connection Dialog + +Shown on startup (or via toolbar "Connect" button) when not connected. + +Fields: +- **SSH Host**: free text, matched against `~/.ssh/config` Host entries (autocomplete offered) +- **Agent Port**: numeric, default `7891` +- **Agent Config**: path on remote machine, default `~/.rmon/config.toml` + +On connect: +1. Detect remote arch via `ssh {host} uname -m` +2. Check / upload agent binary (see `docs/architecture.md` connection flow) +3. Start agent if not running +4. Establish port forward +5. Perform protocol handshake +6. Fetch signal list → populate signal tree + +Connection errors are shown inline with a retry button. + +## Signal Tree Panel + +- Collapsible tree mirroring the `path` field of each `SignalInfo` +- Each leaf shows: label, unit, last value (live-updated), colored dot (source status) +- **Drag-and-drop**: drag a signal leaf onto a plot panel to add it +- **Right-click menu**: "Add to plot 1 / 2 / new plot", "Signal info" (opens info popup), "Start recording", "Stop recording" +- **Search/filter bar** at the top of the panel +- Recording state shown with red ⏺ icon on the signal leaf + +## Plot Panels + +Any number of plot panels can be open (horizontally tiled by default, resizable/detachable). + +### Axes +- X axis: time (absolute UTC or relative to a reference point; toggled in toolbar) +- Y axis: auto-range by default; can be locked manually +- Zoom: scroll wheel (time axis), Ctrl+scroll (Y axis) +- Pan: click-drag on the plot + +### Signals in a Plot +- Each signal rendered as a colored line +- Legend at top of plot (click to hide/show individual signals) +- Color is auto-assigned, can be changed via right-click on the legend entry +- Line style (solid/dashed/dotted) and width configurable per signal + +### Cursors +- Add cursors via toolbar button or right-click on plot → "Add cursor" +- Drag to position; snaps to nearest sample if within threshold +- Measurement bar below the plot shows: + - Per cursor: timestamp, value for each visible signal + - Between two cursors: Δt, Δvalue, mean, min, max over the interval + +### Math Signals +- Right-click plot → "Add math signal" +- Expression editor (simple formula: `a + b`, `a * 2.0`, `a - b`, etc. where `a`, `b` are signal IDs) +- Math signals appear in the legend with an `f(x)` icon and can be added to other plots + +### High-Throughput Rendering +When a signal has more points than pixels in the X direction, the renderer decimates using **min-max downsampling** (preserves peaks/troughs visually). This runs on the CPU in `plot_panel.rs` before passing vertices to egui's painter. + +## Toolbar + +- **Connect / Disconnect** dropdown (shows current host or "Not connected") +- **Time range** selector: presets (last 10s / 1min / 10min / 1h) or custom range picker +- **Time display** toggle: absolute UTC vs relative +- **Record** button: opens signal selection dialog → starts recording for selected signals +- **Status indicator**: green = connected, yellow = reconnecting, red = disconnected + +## Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `Space` | Pause / resume live scrolling | +| `R` | Reset all plot zoom/pan to live view | +| `C` | Add cursor to focused plot | +| `Del` | Remove selected cursor | +| `Ctrl+N` | Add new plot panel | +| `Ctrl+W` | Close focused plot panel | +| `Ctrl+F` | Focus signal tree search | +| `Ctrl+S` | Save current layout to file | + +## Session Persistence + +On clean exit, the UI saves the current session to `~/.config/rmon/last_session.toml`: +- Connected host alias +- Open plot panels and their signal assignments +- Cursor positions +- Time range and zoom state + +On next launch with the same host, it offers to restore the session. + +## State Machine (connection) + +``` +Disconnected + │ user clicks Connect + ▼ +Deploying (copying/verifying agent binary) + │ agent ready + ▼ +Tunneling (SSH port-forward process running) + │ port open + ▼ +Handshaking (TCP open, sent Handshake, awaiting HandshakeAck) + │ accepted + ▼ +Connected (normal operation, DataBatch messages flowing) + │ tunnel drops / error + ▼ +Reconnecting (exponential backoff, max 30s, reuses same tunnel if still up) + │ max retries exceeded + ▼ +Disconnected +``` + +## Async / UI Thread Boundary + +- `tokio` runtime runs in a dedicated OS thread +- The egui render loop runs on the main thread (required by most platform window systems) +- Communication: `std::sync::mpsc::channel` — the tokio tasks push `UiEvent` variants (DataBatch, SignalList, Status, Error) to the UI; the UI pushes `Command` variants (Subscribe, QueryHistory, StartRecording) to tokio +- `eframe::App::update()` drains the incoming event channel each frame before rendering diff --git a/usecase.md b/usecase.md new file mode 100644 index 0000000..1166a90 --- /dev/null +++ b/usecase.md @@ -0,0 +1,43 @@ +# RMon Use Case + +## Main Goal + +The goal is to have a fast data plotter / monitor for a distributed control system accessible only remotely via multiple ssh jumps + +- The UI will run locally on the operator pc on a modern Linux +- The Data will be accessed remotely +- The operator can choose to record some of the data for history review + +## Use case + +- The data are acquired at different frequency from a distributed system in a private network +- The access to the data are different, some examples include: + - CSV file + - UDP streams + - Specific software (e.g. EPICS) + - ... +- Timestamp could be different for each case, e.g.: + - for CSV could be as string %Y.%m.%d %H:%M:%S + - for UDP a u64 + - etc +- Some signal are accessed in a push mode (e.g. udp) and some in pull (e.g. CSV) mode +- Each signal could have the following info: + - Scale, offset + - Unit + - Label + - Sampling period +- Data could be stored, two option: + - continuos storage (from user start to user stop, data are kept if not explicitly required) + - loop storage (only last N:M hours, N:M specified from user) +- The user will access the signals from a UI on his local machine (mostly linux, but windows also possible) +- The user UI should be a portable executable with limit dynamic dependencies +- In the UI the user can: + - have multiple plots + - have multiple cursors + - make measurements and maths on the signals + - hide/show signals + - explore the list/tree of available signals and get info on them + - request to store siganls + - request historical data + - drag and drop to add signals + - signal plot style can be personalised