initial commit
This commit is contained in:
@@ -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}`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user