initial commit

This commit is contained in:
Martino Ferrari
2026-04-11 16:30:18 +02:00
commit dca378b5ef
7 changed files with 929 additions and 0 deletions
+86
View File
@@ -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.
+196
View File
@@ -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 <path> Config file path (default: ~/.rmon/config.toml)
--port <port> TCP listen port (default: 7891)
--bind <addr> Bind address (default: 127.0.0.1 — localhost only, relies on SSH tunnel)
--log <level> Log level: error|warn|info|debug (default: info)
--log-file <path> 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<SignalInfo>;
/// 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<Sample>` 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}`.
+148
View File
@@ -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.
+101
View File
@@ -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<Duration>,// None if irregular/event-driven
source_type: SourceType,
path: Vec<String>, // 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<String>` 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.
+191
View File
@@ -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<SignalId>,
from_ns: Option<i64>,
},
/// Stop live delivery for these signals. Does not affect storage.
Unsubscribe {
ids: Vec<SignalId>,
},
/// 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<SignalId>,
mode: StorageMode,
},
/// Stop recording (data already written is kept).
StopRecording {
ids: Vec<SignalId>,
},
/// 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<String>,
},
/// Full signal list, sent in response to ListSignals.
/// Sent unsolicited again if the signal list changes (source added/removed).
SignalList {
signals: Vec<SignalInfo>,
},
/// 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<SignalBatch>,
},
/// One chunk of a historical query response.
HistoryChunk {
id: SignalId,
samples: Vec<Sample>,
done: bool, // true on the final chunk for this query
},
/// Acknowledgement of StartRecording / StopRecording.
RecordingStatus {
id: SignalId,
recording: bool,
mode: Option<StorageMode>,
},
/// Response to GetStatus.
StatusReport {
agent_version: String,
hostname: String,
uptime_secs: u64,
sources: Vec<SourceStatus>,
},
/// 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<Sample>,
}
struct SourceStatus {
name: String,
source_type: SourceType,
connected: bool,
last_sample_ns: Option<i64>,
error: Option<String>,
}
```
## 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
+164
View File
@@ -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
+43
View File
@@ -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