Initial working implementation (MVP)
This commit is contained in:
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# If you're creating an application, put the next line in the ignore list.
|
||||||
|
# If you're creating a library, remove it.
|
||||||
|
# Cargo.lock
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
/target
|
||||||
|
/target/x86_64-unknown-linux-musl
|
||||||
|
/target/aarch64-unknown-linux-musl
|
||||||
|
|
||||||
|
# Assets / Documentation
|
||||||
|
/docs/generated/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS specific
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# RMon Specific
|
||||||
|
rmon_askpass_*
|
||||||
|
rmon_ssh_sock_*
|
||||||
|
rmon_auth_req_*
|
||||||
|
rmon_auth_res_*
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Claude/Agent sessions
|
||||||
|
.claude/
|
||||||
|
.claude_session
|
||||||
|
.gemini/
|
||||||
Generated
+4323
File diff suppressed because it is too large
Load Diff
+36
@@ -0,0 +1,36 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"rmon-common",
|
||||||
|
"rmon-agent",
|
||||||
|
"rmon-ui",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["RMon Team"]
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
bincode = "1.3"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
tokio = { version = "1.36", features = ["full"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
toml = "0.8"
|
||||||
|
futures = "0.3"
|
||||||
|
bytes = "1.5"
|
||||||
|
itertools = "0.12"
|
||||||
|
thiserror = "1.0"
|
||||||
|
byteorder = "1.5"
|
||||||
|
async-trait = "0.1"
|
||||||
|
tokio-util = { version = "0.7", features = ["codec"] }
|
||||||
|
regex = "1.10"
|
||||||
|
notify = "6.1"
|
||||||
|
clap = { version = "4.4", features = ["derive"] }
|
||||||
|
tempfile = "3.10"
|
||||||
|
libc = "0.2"
|
||||||
|
rmon-common = { path = "rmon-common" }
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# RMon Developer Guide
|
||||||
|
|
||||||
|
This guide covers the internal architecture, protocol, and extension points of the RMon system.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
RMon is built with a focus on performance, safety, and portability.
|
||||||
|
|
||||||
|
### `rmon-common`
|
||||||
|
Contains shared types used by both the agent and UI.
|
||||||
|
- **Protocol**: Defined in `src/protocol.rs`. Uses `bincode` for serialization over a length-prefixed TCP stream.
|
||||||
|
- **Data Model**: `src/signal.rs` defines `Sample`, `SignalInfo`, and `SignalId`.
|
||||||
|
|
||||||
|
### `rmon-agent`
|
||||||
|
A multi-threaded server using `tokio`.
|
||||||
|
- **Dispatcher**: Samples from all sources flow through a central `mpsc` channel to a dispatcher task.
|
||||||
|
- **Fan-out**: The dispatcher sends samples to:
|
||||||
|
1. The **Storage Engine** (binary append logs).
|
||||||
|
2. All connected **Session Handlers** via a `broadcast` channel.
|
||||||
|
- **DataSource Trait**: Abstract interface for data ingestion. New source types only need to implement the `run` and `signals` methods.
|
||||||
|
|
||||||
|
### `rmon-ui`
|
||||||
|
An immediate-mode GUI application.
|
||||||
|
- **Backend Thread**: A separate thread runs the `tokio` runtime for non-blocking network I/O.
|
||||||
|
- **Event Loop**: Communicates with the UI thread via `mpsc` channels (`UiMessage`).
|
||||||
|
- **Repaint Policy**: The UI only repaints when new data arrives or on a fixed interval (50ms) to ensure smooth real-time performance.
|
||||||
|
|
||||||
|
## Adding a New Data Source
|
||||||
|
|
||||||
|
To add a new source type (e.g., `MQTT` or `Serial`):
|
||||||
|
|
||||||
|
1. **Update Config**: Add relevant fields to `SourceConfig` and `SignalSourceConfig` in `rmon-agent/src/config.rs`.
|
||||||
|
2. **Implement `DataSource`**: Create a new file in `rmon-agent/src/sources/` and implement the trait:
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
pub trait DataSource: Send + 'static {
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
fn signals(&self) -> Vec<SignalInfo>;
|
||||||
|
async fn run(self: Box<Self>, tx: mpsc::Sender<(SignalId, Sample)>) -> Result<()>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. **Register**: Add the source to `rmon-agent/src/sources/mod.rs` and update the `run_agent` loop in `rmon-agent/src/lib.rs`.
|
||||||
|
|
||||||
|
## Development Workflows
|
||||||
|
|
||||||
|
### Static Agent Build (Linux)
|
||||||
|
```bash
|
||||||
|
cargo build --release --target x86_64-unknown-linux-musl -p rmon-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
The project uses automated integration tests that spin up real agent instances and verify the protocol flow.
|
||||||
|
```bash
|
||||||
|
cargo test -p rmon-agent --test agent_test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protocol Changes
|
||||||
|
If you modify `rmon-common/src/protocol.rs`:
|
||||||
|
1. Increment `PROTOCOL_VERSION` in `protocol.rs`.
|
||||||
|
2. Rebuild both the UI and the Agent.
|
||||||
|
3. The UI will automatically detect the version mismatch on handshake and refuse to connect to old agents.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
.PHONY: all setup agent ui clean check help
|
||||||
|
|
||||||
|
# Default target: builds everything
|
||||||
|
all: ui
|
||||||
|
|
||||||
|
# Install the required rust target for static agent builds
|
||||||
|
setup:
|
||||||
|
rustup target add x86_64-unknown-linux-musl
|
||||||
|
|
||||||
|
# Build the agent statically (Zero dynamic dependencies)
|
||||||
|
agent:
|
||||||
|
@echo "Building rmon-agent (static x86_64-musl)..."
|
||||||
|
cargo build --release --target x86_64-unknown-linux-musl -p rmon-agent
|
||||||
|
|
||||||
|
# Build the UI
|
||||||
|
# Note: This depends on 'agent' because rmon-ui embeds the agent binary via include_bytes!
|
||||||
|
ui: agent
|
||||||
|
@echo "Building rmon-ui..."
|
||||||
|
cargo build --release -p rmon-ui
|
||||||
|
|
||||||
|
# Run all workspace tests
|
||||||
|
check:
|
||||||
|
cargo test --workspace -- --test-threads=1
|
||||||
|
|
||||||
|
# Generate coverage report (requires cargo-tarpaulin)
|
||||||
|
coverage:
|
||||||
|
cargo tarpaulin --workspace --timeout 120 --out Lcov --output-dir target/coverage
|
||||||
|
|
||||||
|
# Clean all build artifacts
|
||||||
|
clean:
|
||||||
|
cargo clean
|
||||||
|
|
||||||
|
# Show help
|
||||||
|
help:
|
||||||
|
@echo "RMon Build System"
|
||||||
|
@echo ""
|
||||||
|
@echo "Targets:"
|
||||||
|
@echo " setup - Install the x86_64-musl rust target"
|
||||||
|
@echo " agent - Build the statically-linked agent binary"
|
||||||
|
@echo " ui - Build the desktop UI (includes the agent binary)"
|
||||||
|
@echo " all - Build both (default)"
|
||||||
|
@echo " check - Run all workspace tests"
|
||||||
|
@echo " clean - Remove build artifacts"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# RMon: Remote Monitoring & Acquisition System
|
||||||
|
|
||||||
|
RMon is a lightweight, high-performance remote data acquisition and monitoring system. It consists of a statically-linked **Agent** that runs on remote Linux machines and a cross-platform **UI** that manages the agent lifecycle and visualizes data in real-time.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Zero-Dependency Agent**: Fully static `musl` binary. Copy and run on any Linux kernel ≥ 3.2.
|
||||||
|
- **VSCode-Style Deployment**: The UI automatically deploys and starts the agent over SSH. No manual installation required on the remote side.
|
||||||
|
- **Multi-Source Acquisition**: Support for UDP streams, CSV files, and custom Shell commands with Regex parsing.
|
||||||
|
- **Optimized Storage**: Real-time circular buffers for live display and append-only binary logs for persistent historical data.
|
||||||
|
- **Real-Time Visualization**: High-performance plotting using `egui` and `egui_plot`.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- `rmon-agent/`: The remote server that collects and stores data.
|
||||||
|
- `rmon-ui/`: The desktop application for monitoring and management.
|
||||||
|
- `rmon-common/`: Shared protocol definitions and data models.
|
||||||
|
- `docs/`: Technical specifications and architectural details.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Build the Agent (Static)
|
||||||
|
The agent must be built for the target architecture. For a standard x86_64 Linux target:
|
||||||
|
```bash
|
||||||
|
cargo build --release --target x86_64-unknown-linux-musl -p rmon-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run the UI
|
||||||
|
```bash
|
||||||
|
cargo run -p rmon-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Connect
|
||||||
|
1. Enter the **SSH Host** (alias from your `~/.ssh/config`).
|
||||||
|
2. Click **Connect**. The UI will automatically:
|
||||||
|
- Check if the agent is running on the remote host.
|
||||||
|
- Deploy the binary to `~/.rmon/agent` if missing.
|
||||||
|
- Start a persistent instance.
|
||||||
|
3. Select signals from the tree to begin plotting.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [User Guide](USER_GUIDE.md): How to configure and use RMon.
|
||||||
|
- [Developer Guide](DEVELOPER_GUIDE.md): Architecture, protocol, and how to add new sources.
|
||||||
|
- [Agent Specification](docs/agent-spec.md)
|
||||||
|
- [Protocol Reference](docs/protocol.md)
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
# RMon User Guide
|
||||||
|
|
||||||
|
This guide explains how to configure and use the RMon Agent and UI to monitor remote data.
|
||||||
|
|
||||||
|
## RMon Agent
|
||||||
|
|
||||||
|
The Agent is a lightweight server that runs on the remote machine. It collects data from configured sources and sends it to the UI.
|
||||||
|
|
||||||
|
### Persistent Storage
|
||||||
|
- All signals are persistently logged to `~/.rmon/data/`.
|
||||||
|
- Logs are stored in an optimized binary format (`(i64, f64)` pairs).
|
||||||
|
- Data is kept until explicitly deleted, ensuring no data loss when the UI is closed.
|
||||||
|
|
||||||
|
### Configuration (`~/.rmon/config.toml`)
|
||||||
|
|
||||||
|
The agent is configured via a TOML file. The UI can deploy this file automatically, but it can also be edited manually. **Note: Every source must define its own timestamp configuration.**
|
||||||
|
|
||||||
|
#### UDP Source
|
||||||
|
Useful for high-frequency binary data.
|
||||||
|
```toml
|
||||||
|
[[sources]]
|
||||||
|
type = "udp"
|
||||||
|
name = "imu_stream"
|
||||||
|
bind = "0.0.0.0:9000"
|
||||||
|
|
||||||
|
[sources.timestamp]
|
||||||
|
byte_offset = 24
|
||||||
|
byte_type = "u64_le"
|
||||||
|
format = "microseconds_since_epoch"
|
||||||
|
|
||||||
|
[[sources.signals]]
|
||||||
|
id = "accel_x"
|
||||||
|
label = "Accelerometer X"
|
||||||
|
unit = "m/s²"
|
||||||
|
scale = 0.001
|
||||||
|
offset = 0.0
|
||||||
|
byte_offset = 0
|
||||||
|
byte_type = "f32_le"
|
||||||
|
path = ["imu", "accelerometer"]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CSV Source
|
||||||
|
Useful for log files being written by other processes. Supports mapping specific columns to signals and parsing custom timestamp formats.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[sources]]
|
||||||
|
type = "csv"
|
||||||
|
name = "sensor_logs"
|
||||||
|
path = "/path/to/data.csv"
|
||||||
|
poll_interval = "500ms"
|
||||||
|
|
||||||
|
[sources.timestamp]
|
||||||
|
column = "0" # 0-indexed column for the timestamp
|
||||||
|
# Format can be "nanoseconds_since_epoch", "seconds_since_epoch",
|
||||||
|
# or a custom strftime string (e.g., "%Y-%m-%d %H:%M:%S")
|
||||||
|
format = "%Y-%m-%d %H:%M:%S"
|
||||||
|
|
||||||
|
[[sources.signals]]
|
||||||
|
id = "temp_1"
|
||||||
|
label = "Temperature 1"
|
||||||
|
unit = "°C"
|
||||||
|
column = "1" # Mapped to the second column
|
||||||
|
scale = 1.0
|
||||||
|
offset = 0.0
|
||||||
|
path = ["sensors"]
|
||||||
|
|
||||||
|
[[sources.signals]]
|
||||||
|
id = "pressure"
|
||||||
|
label = "System Pressure"
|
||||||
|
unit = "bar"
|
||||||
|
column = "2" # Mapped to the third column
|
||||||
|
scale = 0.1
|
||||||
|
offset = 0.0
|
||||||
|
path = ["sensors"]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Shell Source
|
||||||
|
Highly flexible — run any command and parse the output with Regex.
|
||||||
|
```toml
|
||||||
|
[[sources]]
|
||||||
|
type = "shell"
|
||||||
|
name = "system_status"
|
||||||
|
command = "echo \"cpu_temp: $(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null | awk '{print $1/1000}' || echo 0)\""
|
||||||
|
poll_interval = "1s"
|
||||||
|
|
||||||
|
[sources.timestamp]
|
||||||
|
format = "none" # Use agent arrival time
|
||||||
|
|
||||||
|
[[sources.signals]]
|
||||||
|
id = "cpu_temp"
|
||||||
|
label = "CPU Temperature"
|
||||||
|
unit = "°C"
|
||||||
|
scale = 1.0
|
||||||
|
regex = "cpu_temp: ([0-9.]+)"
|
||||||
|
path = ["system", "thermal"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RMon UI
|
||||||
|
|
||||||
|
The UI is the central control point for RMon.
|
||||||
|
|
||||||
|
### Connection and Deployment
|
||||||
|
1. **SSH Host**: Enter the hostname or alias (from `~/.ssh/config`) of the remote machine.
|
||||||
|
2. **Agent Port**: Default is `7891`.
|
||||||
|
3. **Connect**: Click 🚀 to connect. If the agent is not running, the UI will automatically deploy the pre-built binary and start it as a persistent background process.
|
||||||
|
4. **Disconnect**: Clicking 🔌 closes the UI's connection, but the agent continues to collect and store data on the remote machine.
|
||||||
|
5. **Kill Agent**: Clicking 💀 will terminate the remote agent process.
|
||||||
|
|
||||||
|
### Visualization
|
||||||
|
- **Signals Tree**: All signals are grouped into a hierarchical tree based on their `path` configuration.
|
||||||
|
- **Plots**: Check a signal's box to add it to the real-time plotting area.
|
||||||
|
- **Scroll & Zoom**: The plots are interactive. Use the mouse wheel to zoom and drag to pan.
|
||||||
@@ -125,6 +125,13 @@ offset = 0.0
|
|||||||
path = ["vacuum"]
|
path = ["vacuum"]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Remote Configuration
|
||||||
|
|
||||||
|
The agent allows the UI to read and update its configuration file (`~/.rmon/config.toml`) over the protocol.
|
||||||
|
|
||||||
|
- `ClientMessage::GetConfig`: Agent reads the file from disk and sends `AgentMessage::ConfigReport { config_toml }`.
|
||||||
|
- `ClientMessage::UpdateConfig { config_toml }`: Agent writes the provided string to `~/.rmon/config.toml`. The agent's file watcher detects this change and reloads the sources.
|
||||||
|
|
||||||
## Source Adapter Trait
|
## Source Adapter Trait
|
||||||
|
|
||||||
All data sources implement:
|
All data sources implement:
|
||||||
|
|||||||
+35
-147
@@ -3,162 +3,50 @@
|
|||||||
## Technology
|
## Technology
|
||||||
|
|
||||||
- **Framework**: `egui` + `eframe` (immediate-mode Rust GUI)
|
- **Framework**: `egui` + `eframe` (immediate-mode Rust GUI)
|
||||||
- **Render backend**: `glow` (OpenGL 2.1+) — chosen for maximum Linux compatibility
|
- **Render backend**: `glow` (OpenGL 2.1+)
|
||||||
- **Plotting**: `egui_plot` for interactive panels; custom `egui` painter (line strip primitives) for high-throughput paths (>100k points/frame)
|
- **Plotting**: `egui_plot` for interactive panels.
|
||||||
- **Async runtime**: `tokio` (runs in a background thread; communicates with egui via `std::sync::mpsc`)
|
- **Async runtime**: `tokio` (runs in a background thread).
|
||||||
- **SSH tunnel management**: spawns system `ssh` subprocess
|
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Single binary. On Linux, dynamically links only:
|
Single binary embedding the statically-linked Agent binary.
|
||||||
- `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).
|
## UI Layout
|
||||||
|
|
||||||
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.
|
### Top Toolbar
|
||||||
|
- **🚀 Connect...**: Opens the connection dialog.
|
||||||
|
- **🔌 Disconnect**: Closes current session.
|
||||||
|
- **💀 Kill Agent**: Terminates the remote agent process.
|
||||||
|
- **⚙ Configure Agent**: Opens the remote configuration editor.
|
||||||
|
|
||||||
## Layout
|
### Connection Dialog (Modal)
|
||||||
|
Allows configuring:
|
||||||
|
- **SSH Host**: alias from `~/.ssh/config`.
|
||||||
|
- **Agent Port**: remote port (default 7891).
|
||||||
|
- **Local TCP Addr**: local tunnel entry point.
|
||||||
|
|
||||||
```
|
### Remote Configuration Editor (Modal)
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
- Full-screen TOML editor for the agent's `config.toml`.
|
||||||
│ [Connect ▼] [host: myserver] [⏺ Record] [⏹ Stop] [Status: OK] │ ← Toolbar
|
- **Save & Apply**: Pushes configuration to the agent.
|
||||||
├────────────────┬────────────────────────────────────────────────────┤
|
- **Refresh**: Pulls the latest configuration from the agent.
|
||||||
│ 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
|
### Status Bar (Bottom)
|
||||||
|
- Displays current connection state (✔ Connected / ✘ Disconnected).
|
||||||
|
- Shows host and port details when connected.
|
||||||
|
|
||||||
Shown on startup (or via toolbar "Connect" button) when not connected.
|
### Signal Tree (Left Panel)
|
||||||
|
- Displays all available signals from the agent.
|
||||||
|
- Checkboxes to subscribe/unsubscribe from live data.
|
||||||
|
|
||||||
Fields:
|
### Plot Area (Central Panel)
|
||||||
- **SSH Host**: free text, matched against `~/.ssh/config` Host entries (autocomplete offered)
|
- Vertical scroll area containing live plots.
|
||||||
- **Agent Port**: numeric, default `7891`
|
|
||||||
- **Agent Config**: path on remote machine, default `~/.rmon/config.toml`
|
|
||||||
|
|
||||||
On connect:
|
## Connection Flow
|
||||||
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.
|
1. User clicks **🚀 Connect...** and enters details.
|
||||||
|
2. UI checks if agent is running on remote host via SSH.
|
||||||
## Signal Tree Panel
|
3. UI deploys agent binary if missing.
|
||||||
|
4. UI starts agent process (`nohup`).
|
||||||
- Collapsible tree mirroring the `path` field of each `SignalInfo`
|
5. UI establishes SSH local port forward tunnel.
|
||||||
- Each leaf shows: label, unit, last value (live-updated), colored dot (source status)
|
6. UI connects TCP client to local tunnel end.
|
||||||
- **Drag-and-drop**: drag a signal leaf onto a plot panel to add it
|
7. Protocol handshake and initial signal list fetch.
|
||||||
- **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
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
[package]
|
||||||
|
name = "rmon-agent"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tokio-util.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
|
bincode.workspace = true
|
||||||
|
regex.workspace = true
|
||||||
|
notify.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
itertools.workspace = true
|
||||||
|
byteorder.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
rmon-common.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
futures.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
tempfile.workspace = true
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub storage: StorageConfig,
|
||||||
|
pub sources: Vec<SourceConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct StorageConfig {
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
pub default_mode: String, // "continuous" or "circular"
|
||||||
|
pub default_window: String, // e.g. "2h"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct SourceConfig {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub source_type: String,
|
||||||
|
pub name: String,
|
||||||
|
pub bind: Option<String>,
|
||||||
|
pub path: Option<PathBuf>,
|
||||||
|
pub addr: Option<String>,
|
||||||
|
pub command: Option<String>,
|
||||||
|
pub poll_interval: Option<String>,
|
||||||
|
pub timestamp: TimestampConfig,
|
||||||
|
pub signals: Vec<SignalSourceConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct TimestampConfig {
|
||||||
|
pub byte_offset: Option<usize>,
|
||||||
|
pub byte_type: Option<String>,
|
||||||
|
pub column: Option<String>,
|
||||||
|
pub format: String,
|
||||||
|
pub regex: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct SignalSourceConfig {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub unit: String,
|
||||||
|
pub scale: f64,
|
||||||
|
pub offset: f64,
|
||||||
|
pub byte_offset: Option<usize>,
|
||||||
|
pub byte_type: Option<String>,
|
||||||
|
pub column: Option<String>,
|
||||||
|
pub pv: Option<String>,
|
||||||
|
pub regex: Option<String>,
|
||||||
|
pub path: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_parsing() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[storage]
|
||||||
|
data_dir = "/tmp/data"
|
||||||
|
default_mode = "circular"
|
||||||
|
default_window = "1h"
|
||||||
|
|
||||||
|
[[sources]]
|
||||||
|
type = "udp"
|
||||||
|
name = "test"
|
||||||
|
bind = "127.0.0.1:9000"
|
||||||
|
[sources.timestamp]
|
||||||
|
format = "none"
|
||||||
|
[[sources.signals]]
|
||||||
|
id = "s1"
|
||||||
|
label = "L1"
|
||||||
|
unit = "V"
|
||||||
|
scale = 1.0
|
||||||
|
offset = 0.0
|
||||||
|
path = ["p1"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
assert_eq!(config.storage.data_dir.to_str().unwrap(), "/tmp/data");
|
||||||
|
assert_eq!(config.sources.len(), 1);
|
||||||
|
assert_eq!(config.sources[0].source_type, "udp");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod sources;
|
||||||
|
pub mod storage;
|
||||||
|
pub mod server;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::{broadcast, mpsc, RwLock};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::sources::{DataSource, UdpSource, CsvSource, ShellSource};
|
||||||
|
use crate::storage::CircularBuffer;
|
||||||
|
use crate::server::SharedState;
|
||||||
|
use std::time::Duration;
|
||||||
|
use rmon_common::signal::{SignalId, SignalInfo, Sample};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use notify::{Watcher, RecursiveMode, Config as NotifyConfig};
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
pub async fn run_agent(initial_config: Config, addr: &str, config_path: PathBuf) -> Result<()> {
|
||||||
|
tracing::info!("Starting RMon Agent core on {}...", addr);
|
||||||
|
|
||||||
|
let (reload_tx, _reload_rx) = broadcast::channel::<()>(10);
|
||||||
|
let (tx, _rx) = broadcast::channel(10000);
|
||||||
|
let (sample_tx, mut sample_rx) = mpsc::channel::<(SignalId, Sample)>(1000);
|
||||||
|
|
||||||
|
let signals_lock = Arc::new(RwLock::new(HashMap::<SignalId, SignalInfo>::new()));
|
||||||
|
let storage_lock = Arc::new(RwLock::new(HashMap::<SignalId, Arc<CircularBuffer>>::new()));
|
||||||
|
|
||||||
|
let shared_state = Arc::new(SharedState {
|
||||||
|
signals: signals_lock.clone(),
|
||||||
|
storage: storage_lock.clone(),
|
||||||
|
tx: tx.clone(),
|
||||||
|
reload_tx: reload_tx.clone(),
|
||||||
|
config_path: config_path.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let config_path_clone = config_path.clone();
|
||||||
|
let reload_tx_clone = reload_tx.clone();
|
||||||
|
|
||||||
|
// File watcher for hot-reload
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
if let Ok(mut watcher) = notify::RecommendedWatcher::new(tx, NotifyConfig::default()) {
|
||||||
|
let _ = watcher.watch(&config_path_clone, RecursiveMode::NonRecursive);
|
||||||
|
for res in rx {
|
||||||
|
if let Ok(_) = res {
|
||||||
|
tracing::info!("File watcher detected config change");
|
||||||
|
let _ = reload_tx_clone.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start dispatcher task
|
||||||
|
let storage_dispatch = storage_lock.clone();
|
||||||
|
let tx_dispatch = tx.clone();
|
||||||
|
let data_dir = initial_config.storage.data_dir.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut continuous_writers: HashMap<SignalId, crate::storage::ContinuousStorage> = HashMap::new();
|
||||||
|
// Move session_start to be determined per signal or source if needed,
|
||||||
|
// but keep a process-level one as a default for consistent grouping.
|
||||||
|
let session_start = chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0);
|
||||||
|
|
||||||
|
while let Some((id, sample)) = sample_rx.recv().await {
|
||||||
|
let buf = {
|
||||||
|
let storage = storage_dispatch.read().await;
|
||||||
|
storage.get(&id).cloned()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(buf) = buf {
|
||||||
|
buf.push(sample).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !continuous_writers.contains_key(&id) {
|
||||||
|
let source_name = id.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or("unknown");
|
||||||
|
if let Ok(writer) = crate::storage::ContinuousStorage::create(
|
||||||
|
data_dir.clone(),
|
||||||
|
source_name,
|
||||||
|
&id,
|
||||||
|
session_start
|
||||||
|
) {
|
||||||
|
continuous_writers.insert(id.clone(), writer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(writer) = continuous_writers.get_mut(&id) {
|
||||||
|
let _ = writer.append(sample);
|
||||||
|
let _ = writer.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = tx_dispatch.send((id, sample));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start TCP server
|
||||||
|
let server_state = shared_state.clone();
|
||||||
|
let server_addr = addr.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match TcpListener::bind(&server_addr).await {
|
||||||
|
Ok(listener) => {
|
||||||
|
tracing::info!("Server listening on {}", server_addr);
|
||||||
|
loop {
|
||||||
|
if let Ok((stream, _)) = listener.accept().await {
|
||||||
|
let state = server_state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = server::handle_session(stream, state).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::error!("Failed to bind server: {}", e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut current_config = initial_config;
|
||||||
|
let mut reload_rx = reload_tx.subscribe();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tracing::info!("Initializing data sources...");
|
||||||
|
let mut new_signals = HashMap::new();
|
||||||
|
let mut new_storage = HashMap::new();
|
||||||
|
let mut sources: Vec<Box<dyn DataSource>> = Vec::new();
|
||||||
|
|
||||||
|
for source_config in ¤t_config.sources {
|
||||||
|
let source: Box<dyn DataSource> = match source_config.source_type.as_str() {
|
||||||
|
"udp" => Box::new(UdpSource::new(source_config.clone())),
|
||||||
|
"csv" => Box::new(CsvSource::new(source_config.clone())),
|
||||||
|
"shell" => Box::new(ShellSource::new(source_config.clone())),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
for sig in source.signals() {
|
||||||
|
new_signals.insert(sig.id.clone(), sig.clone());
|
||||||
|
new_storage.insert(sig.id.clone(), Arc::new(CircularBuffer::new(Duration::from_secs(3600))));
|
||||||
|
}
|
||||||
|
sources.push(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Updating shared signal map ({} signals)", new_signals.len());
|
||||||
|
{
|
||||||
|
let mut sigs = signals_lock.write().await;
|
||||||
|
*sigs = new_signals;
|
||||||
|
let mut stors = storage_lock.write().await;
|
||||||
|
*stors = new_storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (stop_tx, _) = broadcast::channel::<()>(1);
|
||||||
|
|
||||||
|
for source in sources {
|
||||||
|
let s_tx = sample_tx.clone();
|
||||||
|
let mut s_stop = stop_tx.subscribe();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::select! {
|
||||||
|
res = source.run(s_tx) => {
|
||||||
|
if let Err(e) = res {
|
||||||
|
tracing::error!("Source failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = s_stop.recv() => {
|
||||||
|
tracing::debug!("Source stopped by reload");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = reload_rx.recv().await;
|
||||||
|
tracing::info!("Reload signal received, refreshing config...");
|
||||||
|
drop(stop_tx); // Stop sources
|
||||||
|
|
||||||
|
sleep(Duration::from_millis(200)).await;
|
||||||
|
if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||||||
|
match toml::from_str::<Config>(&content) {
|
||||||
|
Ok(new_cfg) => {
|
||||||
|
tracing::info!("Config reloaded successfully");
|
||||||
|
current_config = new_cfg;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to parse updated config: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::StorageConfig;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_agent_config_struct() {
|
||||||
|
let c = Config {
|
||||||
|
storage: StorageConfig {
|
||||||
|
data_dir: PathBuf::from("/tmp"),
|
||||||
|
default_mode: "c".to_string(),
|
||||||
|
default_window: "1".to_string(),
|
||||||
|
},
|
||||||
|
sources: vec![],
|
||||||
|
};
|
||||||
|
assert_eq!(c.sources.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_dispatcher_routing() {
|
||||||
|
let (tx, mut rx) = broadcast::channel::<(SignalId, Sample)>(10);
|
||||||
|
let (_stx, _srx) = mpsc::channel::<(SignalId, Sample)>(10);
|
||||||
|
let msg = ("s1".to_string(), Sample { timestamp: 1, value: 1.0 });
|
||||||
|
tx.send(msg.clone()).unwrap();
|
||||||
|
let received = rx.recv().await.unwrap();
|
||||||
|
assert_eq!(received.0, "s1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_source_factory_logic() {
|
||||||
|
let t = "udp";
|
||||||
|
assert_eq!(t, "udp");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use rmon_agent::config::Config;
|
||||||
|
use rmon_agent::run_agent;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::fs;
|
||||||
|
use clap::Parser;
|
||||||
|
use tracing_subscriber::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Args {
|
||||||
|
/// Bind address
|
||||||
|
#[arg(short, long, default_value = "127.0.0.1")]
|
||||||
|
bind: String,
|
||||||
|
|
||||||
|
/// Port to listen on
|
||||||
|
#[arg(short, long, default_value_t = 7891)]
|
||||||
|
port: u16,
|
||||||
|
|
||||||
|
/// Path to config file
|
||||||
|
#[arg(short, long)]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||||
|
let rmon_dir = PathBuf::from(&home).join(".rmon");
|
||||||
|
let _ = fs::create_dir_all(&rmon_dir);
|
||||||
|
|
||||||
|
let log_path = rmon_dir.join("agent.log");
|
||||||
|
|
||||||
|
// Open in append mode
|
||||||
|
let file = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&log_path)?;
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(tracing_subscriber::EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into()))
|
||||||
|
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
|
||||||
|
.with(tracing_subscriber::fmt::layer().with_writer(file).with_ansi(false))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
tracing::info!("--- RMon Agent Starting ---");
|
||||||
|
tracing::info!("Log file: {:?}", log_path);
|
||||||
|
|
||||||
|
let args = Args::parse();
|
||||||
|
let config_path = args.config.unwrap_or_else(|| rmon_dir.join("config.toml"));
|
||||||
|
|
||||||
|
let config: Config = if config_path.exists() {
|
||||||
|
tracing::info!("Loading config from {:?}", config_path);
|
||||||
|
let content = fs::read_to_string(&config_path)?;
|
||||||
|
toml::from_str(&content)?
|
||||||
|
} else {
|
||||||
|
tracing::info!("Using default configuration");
|
||||||
|
let default_config = r#"
|
||||||
|
[storage]
|
||||||
|
data_dir = "/tmp/rmon-data"
|
||||||
|
default_mode = "circular"
|
||||||
|
default_window = "1h"
|
||||||
|
|
||||||
|
[[sources]]
|
||||||
|
type = "shell"
|
||||||
|
name = "system"
|
||||||
|
command = "echo \"cpu_temp: $(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null | awk '{print $1/1000}' || echo 0)\""
|
||||||
|
poll_interval = "1s"
|
||||||
|
[sources.timestamp]
|
||||||
|
format = "none"
|
||||||
|
[[sources.signals]]
|
||||||
|
id = "cpu_temp"
|
||||||
|
label = "CPU Temperature"
|
||||||
|
unit = "C"
|
||||||
|
scale = 1.0
|
||||||
|
offset = 0.0
|
||||||
|
regex = "cpu_temp: ([0-9.]+)"
|
||||||
|
path = ["system", "thermal"]
|
||||||
|
"#;
|
||||||
|
if let Some(parent) = config_path.parent() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let _ = fs::write(&config_path, default_config);
|
||||||
|
toml::from_str(default_config)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let addr = format!("{}:{}", args.bind, args.port);
|
||||||
|
tracing::info!("Binding to {}", addr);
|
||||||
|
run_agent(config, &addr, config_path).await
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod session;
|
||||||
|
pub use self::session::{handle_session, SharedState};
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio_util::codec::Framed;
|
||||||
|
use futures::{StreamExt, SinkExt};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{broadcast, RwLock};
|
||||||
|
use rmon_common::protocol::{ClientMessage, AgentMessage, BincodeCodec, PROTOCOL_VERSION, SignalBatch};
|
||||||
|
use rmon_common::signal::{SignalId, SignalInfo, Sample};
|
||||||
|
use crate::storage::CircularBuffer;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use bytes::BytesMut;
|
||||||
|
use tokio_util::codec::{Decoder, Encoder};
|
||||||
|
use std::io;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// A custom codec that decodes ClientMessage and encodes AgentMessage
|
||||||
|
pub struct AgentCodec;
|
||||||
|
|
||||||
|
impl Decoder for AgentCodec {
|
||||||
|
type Item = ClientMessage;
|
||||||
|
type Error = io::Error;
|
||||||
|
|
||||||
|
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
|
||||||
|
let mut codec = BincodeCodec::<ClientMessage>::default();
|
||||||
|
codec.decode(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Encoder<AgentMessage> for AgentCodec {
|
||||||
|
type Error = io::Error;
|
||||||
|
|
||||||
|
fn encode(&mut self, item: AgentMessage, dst: &mut BytesMut) -> Result<(), Self::Error> {
|
||||||
|
let mut codec = BincodeCodec::<AgentMessage>::default();
|
||||||
|
codec.encode(item, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SharedState {
|
||||||
|
pub signals: Arc<RwLock<HashMap<SignalId, SignalInfo>>>,
|
||||||
|
pub storage: Arc<RwLock<HashMap<SignalId, Arc<CircularBuffer>>>>,
|
||||||
|
pub tx: broadcast::Sender<(SignalId, Sample)>,
|
||||||
|
pub reload_tx: broadcast::Sender<()>,
|
||||||
|
pub config_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_session(
|
||||||
|
stream: TcpStream,
|
||||||
|
state: Arc<SharedState>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let addr = stream.peer_addr().map(|a| a.to_string()).unwrap_or_else(|_| "unknown".to_string());
|
||||||
|
tracing::info!("[{}] Session established", addr);
|
||||||
|
|
||||||
|
let mut framed = Framed::new(stream, AgentCodec);
|
||||||
|
|
||||||
|
// 1. Handshake
|
||||||
|
match framed.next().await {
|
||||||
|
Some(Ok(ClientMessage::Handshake { protocol_version, client_version })) => {
|
||||||
|
if protocol_version == PROTOCOL_VERSION {
|
||||||
|
tracing::info!("[{}] Handshake ok (client: {})", addr, client_version);
|
||||||
|
framed.send(AgentMessage::HandshakeAck {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
agent_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
accepted: true,
|
||||||
|
reject_reason: None,
|
||||||
|
}).await?;
|
||||||
|
} else {
|
||||||
|
tracing::warn!("[{}] Handshake failed: version mismatch", addr);
|
||||||
|
let _ = framed.send(AgentMessage::HandshakeAck {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
agent_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
accepted: false,
|
||||||
|
reject_reason: Some("Version mismatch".to_string()),
|
||||||
|
}).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(msg)) => {
|
||||||
|
tracing::warn!("[{}] Expected handshake, got {:?}", addr, msg);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::warn!("[{}] Handshake failed or connection closed", addr);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rx = state.tx.subscribe();
|
||||||
|
let mut subscriptions = Vec::<SignalId>::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg_res = framed.next() => {
|
||||||
|
match msg_res {
|
||||||
|
Some(Ok(msg)) => {
|
||||||
|
match msg {
|
||||||
|
ClientMessage::ListSignals => {
|
||||||
|
let signals: Vec<SignalInfo> = {
|
||||||
|
let sigs = state.signals.read().await;
|
||||||
|
sigs.values().cloned().collect()
|
||||||
|
};
|
||||||
|
tracing::info!("[{}] Sending signal list ({} signals)", addr, signals.len());
|
||||||
|
framed.send(AgentMessage::SignalList { signals }).await?;
|
||||||
|
}
|
||||||
|
ClientMessage::GetConfig => {
|
||||||
|
tracing::info!("[{}] Fetching config...", addr);
|
||||||
|
match std::fs::read_to_string(&state.config_path) {
|
||||||
|
Ok(config_toml) => {
|
||||||
|
framed.send(AgentMessage::ConfigReport { config_toml }).await?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[{}] Config read failed: {}", addr, e);
|
||||||
|
framed.send(AgentMessage::Error { context: "GetConfig".to_string(), message: e.to_string() }).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClientMessage::UpdateConfig { config_toml } => {
|
||||||
|
tracing::info!("[{}] Updating config...", addr);
|
||||||
|
if let Err(e) = std::fs::write(&state.config_path, &config_toml) {
|
||||||
|
tracing::error!("[{}] Config write failed: {}", addr, e);
|
||||||
|
framed.send(AgentMessage::Error { context: "UpdateConfig".to_string(), message: e.to_string() }).await?;
|
||||||
|
} else {
|
||||||
|
let _ = state.reload_tx.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClientMessage::Subscribe { ids, .. } => {
|
||||||
|
tracing::info!("[{}] Subscribing to {:?}", addr, ids);
|
||||||
|
subscriptions.extend(ids);
|
||||||
|
}
|
||||||
|
ClientMessage::Unsubscribe { ids } => {
|
||||||
|
tracing::info!("[{}] Unsubscribing from {:?}", addr, ids);
|
||||||
|
subscriptions.retain(|id| !ids.contains(id));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::debug!("[{}] Unhandled client message: {:?}", addr, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Err(e)) => {
|
||||||
|
tracing::error!("[{}] Protocol error: {}", addr, e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::info!("[{}] Connection closed by client", addr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sample_res = rx.recv() => {
|
||||||
|
if let Ok((id, sample)) = sample_res {
|
||||||
|
if subscriptions.contains(&id) {
|
||||||
|
if let Err(e) = framed.send(AgentMessage::DataBatch {
|
||||||
|
batches: vec![SignalBatch { id, samples: vec![sample] }]
|
||||||
|
}).await {
|
||||||
|
tracing::error!("[{}] Failed to send data: {}", addr, e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("[{}] Session terminated", addr);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use rmon_common::protocol::PROTOCOL_VERSION;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_shared_state_init() {
|
||||||
|
let (tx, _) = broadcast::channel(10);
|
||||||
|
let (rtx, _) = broadcast::channel(1);
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let config_path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
let state = SharedState {
|
||||||
|
signals: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
storage: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
tx,
|
||||||
|
reload_tx: rtx,
|
||||||
|
config_path,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(state.signals.read().await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_codec_defaults() {
|
||||||
|
let _ = AgentCodec;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_subscription_list() {
|
||||||
|
let mut subs = Vec::<SignalId>::new();
|
||||||
|
subs.push("s1".to_string());
|
||||||
|
subs.push("s2".to_string());
|
||||||
|
assert!(subs.contains(&"s1".to_string()));
|
||||||
|
subs.retain(|id| id != "s1");
|
||||||
|
assert!(!subs.contains(&"s1".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shared_state_fields() {
|
||||||
|
let (tx, _) = broadcast::channel(1);
|
||||||
|
let (rtx, _) = broadcast::channel(1);
|
||||||
|
let s = SharedState {
|
||||||
|
signals: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
storage: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
tx, reload_tx: rtx, config_path: PathBuf::from("."),
|
||||||
|
};
|
||||||
|
assert!(s.config_path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_agent_message_variants() {
|
||||||
|
let m1 = AgentMessage::HandshakeAck {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
agent_version: "1".to_string(),
|
||||||
|
accepted: true,
|
||||||
|
reject_reason: None,
|
||||||
|
};
|
||||||
|
let _ = bincode::serialize(&m1).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_status_report_serialization() {
|
||||||
|
use rmon_common::protocol::SourceStatus;
|
||||||
|
use rmon_common::signal::SourceType;
|
||||||
|
let report = AgentMessage::StatusReport {
|
||||||
|
agent_version: "1".to_string(),
|
||||||
|
hostname: "h".to_string(),
|
||||||
|
uptime_secs: 10,
|
||||||
|
sources: vec![SourceStatus {
|
||||||
|
name: "s".to_string(),
|
||||||
|
source_type: SourceType::Udp,
|
||||||
|
connected: true,
|
||||||
|
last_sample_ns: None,
|
||||||
|
error: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let _ = bincode::serialize(&report).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use rmon_common::signal::{SignalId, SignalInfo, Sample, SourceType};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use crate::sources::datasource::DataSource;
|
||||||
|
use crate::config::SourceConfig;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||||
|
use chrono::{DateTime, Utc, NaiveDateTime};
|
||||||
|
|
||||||
|
pub struct CsvSource {
|
||||||
|
name: String,
|
||||||
|
signals: Vec<SignalInfo>,
|
||||||
|
config: SourceConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CsvSource {
|
||||||
|
pub fn new(config: SourceConfig) -> Self {
|
||||||
|
let signals = config.signals.iter().map(|s| {
|
||||||
|
SignalInfo {
|
||||||
|
id: format!("csv://{}/{}", config.name, s.id),
|
||||||
|
label: s.label.clone(),
|
||||||
|
unit: s.unit.clone(),
|
||||||
|
scale: s.scale,
|
||||||
|
offset: s.offset,
|
||||||
|
sampling_period: None,
|
||||||
|
source_type: SourceType::Csv,
|
||||||
|
path: s.path.clone(),
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
name: config.name.clone(),
|
||||||
|
signals,
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_timestamp(&self, value: &str) -> i64 {
|
||||||
|
let value = value.trim();
|
||||||
|
match self.config.timestamp.format.as_str() {
|
||||||
|
"nanoseconds_since_epoch" => value.parse::<i64>().unwrap_or(0),
|
||||||
|
"milliseconds_since_epoch" => value.parse::<i64>().unwrap_or(0) * 1_000_000,
|
||||||
|
"seconds_since_epoch" => value.parse::<f64>().map(|v| (v * 1e9) as i64).unwrap_or(0),
|
||||||
|
"none" => Utc::now().timestamp_nanos_opt().unwrap_or(0),
|
||||||
|
format_str => {
|
||||||
|
if let Ok(naive) = NaiveDateTime::parse_from_str(value, format_str) {
|
||||||
|
naive.and_utc().timestamp_nanos_opt().unwrap_or(0)
|
||||||
|
} else if let Ok(dt) = DateTime::parse_from_rfc3339(value) {
|
||||||
|
dt.timestamp_nanos_opt().unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
Utc::now().timestamp_nanos_opt().unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DataSource for CsvSource {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signals(&self) -> Vec<SignalInfo> {
|
||||||
|
self.signals.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(self: Box<Self>, tx: mpsc::Sender<(SignalId, Sample)>) -> Result<()> {
|
||||||
|
let path = self.config.path.clone().ok_or_else(|| anyhow::anyhow!("CSV path missing"))?;
|
||||||
|
let poll_interval = if let Some(ref interval_str) = self.config.poll_interval {
|
||||||
|
if interval_str.ends_with("ms") {
|
||||||
|
Duration::from_millis(interval_str.trim_end_matches("ms").parse().unwrap_or(100))
|
||||||
|
} else {
|
||||||
|
Duration::from_secs(interval_str.trim_end_matches("s").parse().unwrap_or(1))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Duration::from_millis(500)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut pos = 0u64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Ok(mut file) = File::open(&path) {
|
||||||
|
let len = file.metadata()?.len();
|
||||||
|
|
||||||
|
if len < pos {
|
||||||
|
tracing::info!("CSV file truncated or rotated, resetting position");
|
||||||
|
pos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if len > pos {
|
||||||
|
file.seek(SeekFrom::Start(pos))?;
|
||||||
|
let mut reader = BufReader::new(file);
|
||||||
|
let mut line = String::new();
|
||||||
|
|
||||||
|
while reader.read_line(&mut line)? > 0 {
|
||||||
|
let bytes_read = line.len() as u64;
|
||||||
|
let trimmed = line.trim();
|
||||||
|
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
// If we are at start of file, skip the header
|
||||||
|
if pos == 0 {
|
||||||
|
pos += bytes_read;
|
||||||
|
line.clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = trimmed.split(',').collect();
|
||||||
|
|
||||||
|
// 1. Get timestamp
|
||||||
|
let timestamp = if let Some(col_idx) = self.config.timestamp.column.as_ref().and_then(|c| c.parse::<usize>().ok()) {
|
||||||
|
if let Some(val) = parts.get(col_idx) {
|
||||||
|
self.parse_timestamp(val)
|
||||||
|
} else {
|
||||||
|
Utc::now().timestamp_nanos_opt().unwrap_or(0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Utc::now().timestamp_nanos_opt().unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Map signals
|
||||||
|
for signal_config in &self.config.signals {
|
||||||
|
if let Some(col_idx) = signal_config.column.as_ref().and_then(|c| c.parse::<usize>().ok()) {
|
||||||
|
if let Some(raw_str) = parts.get(col_idx) {
|
||||||
|
if let Ok(raw_val) = raw_str.trim().parse::<f64>() {
|
||||||
|
let value = raw_val * signal_config.scale + signal_config.offset;
|
||||||
|
let id = format!("csv://{}/{}", self.name, signal_config.id);
|
||||||
|
let _ = tx.send((id, Sample { timestamp, value })).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos += bytes_read;
|
||||||
|
line.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sleep(poll_interval).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::{SourceConfig, TimestampConfig, SignalSourceConfig};
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_csv_metadata() {
|
||||||
|
let config = SourceConfig {
|
||||||
|
source_type: "csv".to_string(),
|
||||||
|
name: "tc".to_string(),
|
||||||
|
bind: None, path: Some(PathBuf::from("test.csv")), addr: None, command: None, poll_interval: None,
|
||||||
|
timestamp: TimestampConfig { byte_offset: None, byte_type: None, column: None, format: "none".to_string(), regex: None },
|
||||||
|
signals: vec![SignalSourceConfig {
|
||||||
|
id: "s1".to_string(), label: "L1".to_string(), unit: "V".to_string(), scale: 1.0, offset: 0.0,
|
||||||
|
byte_offset: None, byte_type: None, column: None, pv: None, regex: None, path: vec![],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let source = CsvSource::new(config);
|
||||||
|
assert_eq!(source.name(), "tc");
|
||||||
|
assert_eq!(source.signals()[0].id, "csv://tc/s1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_csv_batch_parsing() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let file_path = dir.path().join("test_batch.csv");
|
||||||
|
let mut file = std::fs::File::create(&file_path).unwrap();
|
||||||
|
|
||||||
|
// Write header and two lines immediately
|
||||||
|
writeln!(file, "time,val1").unwrap();
|
||||||
|
writeln!(file, "100,10.0").unwrap();
|
||||||
|
writeln!(file, "200,20.0").unwrap();
|
||||||
|
file.sync_all().unwrap();
|
||||||
|
|
||||||
|
let config = SourceConfig {
|
||||||
|
source_type: "csv".to_string(),
|
||||||
|
name: "test_batch".to_string(),
|
||||||
|
bind: None,
|
||||||
|
path: Some(file_path),
|
||||||
|
addr: None,
|
||||||
|
command: None,
|
||||||
|
poll_interval: Some("10ms".to_string()),
|
||||||
|
timestamp: TimestampConfig {
|
||||||
|
byte_offset: None, byte_type: None,
|
||||||
|
column: Some("0".to_string()),
|
||||||
|
format: "nanoseconds_since_epoch".to_string(),
|
||||||
|
regex: None
|
||||||
|
},
|
||||||
|
signals: vec![
|
||||||
|
SignalSourceConfig {
|
||||||
|
id: "s1".to_string(), label: "L1".to_string(), unit: "V".to_string(), scale: 1.0, offset: 0.0,
|
||||||
|
byte_offset: None, byte_type: None, column: Some("1".to_string()), pv: None, regex: None, path: vec![],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let source = Box::new(CsvSource::new(config));
|
||||||
|
let (tx, mut rx) = mpsc::channel(10);
|
||||||
|
tokio::spawn(async move { let _ = source.run(tx).await; });
|
||||||
|
|
||||||
|
// Should receive BOTH lines from initial load
|
||||||
|
let msg1 = tokio::time::timeout(Duration::from_secs(1), rx.recv()).await.unwrap().unwrap();
|
||||||
|
assert_eq!(msg1.1.value, 10.0);
|
||||||
|
|
||||||
|
let msg2 = tokio::time::timeout(Duration::from_secs(1), rx.recv()).await.unwrap().unwrap();
|
||||||
|
assert_eq!(msg2.1.value, 20.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use rmon_common::signal::{SignalId, SignalInfo, Sample};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub 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: Box<Self>, tx: mpsc::Sender<(SignalId, Sample)>) -> Result<()>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
pub mod datasource;
|
||||||
|
pub mod udp;
|
||||||
|
pub mod csv;
|
||||||
|
pub mod shell;
|
||||||
|
|
||||||
|
pub use self::datasource::DataSource;
|
||||||
|
pub use self::udp::UdpSource;
|
||||||
|
pub use self::csv::CsvSource;
|
||||||
|
pub use self::shell::ShellSource;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::config::{SourceConfig, TimestampConfig};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_source_types() {
|
||||||
|
let sc = SourceConfig {
|
||||||
|
source_type: "csv".to_string(),
|
||||||
|
name: "n".to_string(),
|
||||||
|
bind: None, path: None, addr: None, command: None, poll_interval: None,
|
||||||
|
timestamp: TimestampConfig { byte_offset: None, byte_type: None, column: None, format: "n".to_string(), regex: None },
|
||||||
|
signals: vec![],
|
||||||
|
};
|
||||||
|
assert_eq!(sc.source_type, "csv");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use rmon_common::signal::{SignalId, SignalInfo, Sample, SourceType};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use crate::sources::datasource::DataSource;
|
||||||
|
use crate::config::SourceConfig;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use tokio::process::Command;
|
||||||
|
use regex::Regex;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
pub struct ShellSource {
|
||||||
|
name: String,
|
||||||
|
signals: Vec<SignalInfo>,
|
||||||
|
config: SourceConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShellSource {
|
||||||
|
pub fn new(config: SourceConfig) -> Self {
|
||||||
|
let signals = config.signals.iter().map(|s| {
|
||||||
|
SignalInfo {
|
||||||
|
id: format!("shell://{}/{}", config.name, s.id),
|
||||||
|
label: s.label.clone(),
|
||||||
|
unit: s.unit.clone(),
|
||||||
|
scale: s.scale,
|
||||||
|
offset: s.offset,
|
||||||
|
sampling_period: None,
|
||||||
|
source_type: SourceType::Custom("shell".to_string()),
|
||||||
|
path: s.path.clone(),
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
name: config.name.clone(),
|
||||||
|
signals,
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DataSource for ShellSource {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signals(&self) -> Vec<SignalInfo> {
|
||||||
|
self.signals.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(self: Box<Self>, tx: mpsc::Sender<(SignalId, Sample)>) -> Result<()> {
|
||||||
|
let command_str = self.config.command.clone().ok_or_else(|| anyhow::anyhow!("Shell command missing"))?;
|
||||||
|
let poll_interval = if let Some(ref interval_str) = self.config.poll_interval {
|
||||||
|
if interval_str.ends_with("ms") {
|
||||||
|
Duration::from_millis(interval_str.trim_end_matches("ms").parse()?)
|
||||||
|
} else if interval_str.ends_with("s") {
|
||||||
|
Duration::from_secs(interval_str.trim_end_matches("s").parse()?)
|
||||||
|
} else {
|
||||||
|
Duration::from_secs(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Duration::from_secs(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
let signal_regexes: Vec<(String, Regex)> = self.config.signals.iter()
|
||||||
|
.filter_map(|s| {
|
||||||
|
s.regex.as_ref().and_then(|r| Regex::new(r).ok().map(|re| (s.id.clone(), re)))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
tracing::info!("Shell source '{}' starting with {} regexes", self.name, signal_regexes.len());
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match Command::new("sh").arg("-c").arg(&command_str).output().await {
|
||||||
|
Ok(output) => {
|
||||||
|
if output.status.success() {
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() as i64;
|
||||||
|
let mut matched = false;
|
||||||
|
|
||||||
|
for (id_local, regex) in &signal_regexes {
|
||||||
|
if let Some(caps) = regex.captures(&stdout) {
|
||||||
|
if let Some(m) = caps.get(1) {
|
||||||
|
if let Ok(raw_val) = m.as_str().parse::<f64>() {
|
||||||
|
if let Some(sig_config) = self.config.signals.iter().find(|s| s.id == *id_local) {
|
||||||
|
matched = true;
|
||||||
|
let value = raw_val * sig_config.scale + sig_config.offset;
|
||||||
|
let id = format!("shell://{}/{}", self.name, id_local);
|
||||||
|
let _ = tx.send((id, Sample { timestamp, value })).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
tracing::warn!("Shell command '{}' succeeded but output didn't match any regex: '{}'", self.name, stdout.trim());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::error!("Shell command '{}' failed: {}", self.name, String::from_utf8_lossy(&output.stderr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::error!("Failed to execute shell command '{}': {}", self.name, e),
|
||||||
|
}
|
||||||
|
sleep(poll_interval).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::{SourceConfig, TimestampConfig, SignalSourceConfig};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shell_metadata() {
|
||||||
|
let config = SourceConfig {
|
||||||
|
source_type: "shell".to_string(),
|
||||||
|
name: "ts".to_string(),
|
||||||
|
bind: None, path: None, addr: None,
|
||||||
|
command: Some("echo 42".to_string()),
|
||||||
|
poll_interval: Some("1s".to_string()),
|
||||||
|
timestamp: TimestampConfig { byte_offset: None, byte_type: None, column: None, format: "none".to_string(), regex: None },
|
||||||
|
signals: vec![SignalSourceConfig {
|
||||||
|
id: "s1".to_string(), label: "L1".to_string(), unit: "V".to_string(), scale: 1.0, offset: 0.0,
|
||||||
|
byte_offset: None, byte_type: None, column: None, pv: None,
|
||||||
|
regex: Some("([0-9]+)".to_string()),
|
||||||
|
path: vec![],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let source = ShellSource::new(config);
|
||||||
|
assert_eq!(source.name(), "ts");
|
||||||
|
assert_eq!(source.signals()[0].id, "shell://ts/s1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_duration_parsing_logic() {
|
||||||
|
let s = "100ms";
|
||||||
|
let ms = s.trim_end_matches("ms").parse::<u64>().unwrap();
|
||||||
|
assert_eq!(ms, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shell_regex_logic() {
|
||||||
|
let re = Regex::new("val: ([0-9]+)").unwrap();
|
||||||
|
let caps = re.captures("val: 123").unwrap();
|
||||||
|
assert_eq!(caps.get(1).unwrap().as_str(), "123");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use rmon_common::signal::{SignalId, SignalInfo, Sample, SourceType};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
use crate::sources::datasource::DataSource;
|
||||||
|
use crate::config::SourceConfig;
|
||||||
|
use byteorder::{ByteOrder, LittleEndian, BigEndian};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
pub struct UdpSource {
|
||||||
|
name: String,
|
||||||
|
pub(crate) bind_addr: String,
|
||||||
|
signals: Vec<SignalInfo>,
|
||||||
|
config: SourceConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UdpSource {
|
||||||
|
pub fn new(config: SourceConfig) -> Self {
|
||||||
|
let signals = config.signals.iter().map(|s| {
|
||||||
|
SignalInfo {
|
||||||
|
id: format!("udp://{}/{}", config.name, s.id),
|
||||||
|
label: s.label.clone(),
|
||||||
|
unit: s.unit.clone(),
|
||||||
|
scale: s.scale,
|
||||||
|
offset: s.offset,
|
||||||
|
sampling_period: None,
|
||||||
|
source_type: SourceType::Udp,
|
||||||
|
path: s.path.clone(),
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
name: config.name.clone(),
|
||||||
|
bind_addr: config.bind.clone().unwrap_or_else(|| "0.0.0.0:0".to_string()),
|
||||||
|
signals,
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DataSource for UdpSource {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signals(&self) -> Vec<SignalInfo> {
|
||||||
|
self.signals.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(self: Box<Self>, tx: mpsc::Sender<(SignalId, Sample)>) -> Result<()> {
|
||||||
|
let socket = UdpSocket::bind(&self.bind_addr).await?;
|
||||||
|
let mut buf = [0u8; 65535];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (len, _addr) = socket.recv_from(&mut buf).await?;
|
||||||
|
let payload = &buf[..len];
|
||||||
|
|
||||||
|
let timestamp = if let Some(ts_offset) = self.config.timestamp.byte_offset {
|
||||||
|
match self.config.timestamp.byte_type.as_deref() {
|
||||||
|
Some("u64_le") => {
|
||||||
|
let ts = LittleEndian::read_u64(&payload[ts_offset..ts_offset+8]);
|
||||||
|
match self.config.timestamp.format.as_str() {
|
||||||
|
"microseconds_since_epoch" => (ts * 1000) as i64,
|
||||||
|
"nanoseconds_since_epoch" => ts as i64,
|
||||||
|
"seconds_since_epoch" => (ts * 1_000_000_000) as i64,
|
||||||
|
_ => SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() as i64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() as i64,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() as i64
|
||||||
|
};
|
||||||
|
|
||||||
|
for signal_config in &self.config.signals {
|
||||||
|
if let Some(offset) = signal_config.byte_offset {
|
||||||
|
if payload.len() < offset + 4 { continue; }
|
||||||
|
let raw_val = match signal_config.byte_type.as_deref() {
|
||||||
|
Some("f32_le") => LittleEndian::read_f32(&payload[offset..offset+4]) as f64,
|
||||||
|
Some("f32_be") => BigEndian::read_f32(&payload[offset..offset+4]) as f64,
|
||||||
|
Some("f64_le") => if payload.len() >= offset+8 { LittleEndian::read_f64(&payload[offset..offset+8]) } else { 0.0 },
|
||||||
|
Some("f64_be") => if payload.len() >= offset+8 { BigEndian::read_f64(&payload[offset..offset+8]) } else { 0.0 },
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = raw_val * signal_config.scale + signal_config.offset;
|
||||||
|
let id = format!("udp://{}/{}", self.name, signal_config.id);
|
||||||
|
let _ = tx.send((id, Sample { timestamp, value })).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::{SourceConfig, TimestampConfig, SignalSourceConfig};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_udp_metadata() {
|
||||||
|
let config = SourceConfig {
|
||||||
|
source_type: "udp".to_string(),
|
||||||
|
name: "tu".to_string(),
|
||||||
|
bind: None, path: None, addr: None, command: None, poll_interval: None,
|
||||||
|
timestamp: TimestampConfig { byte_offset: None, byte_type: None, column: None, format: "none".to_string(), regex: None },
|
||||||
|
signals: vec![SignalSourceConfig {
|
||||||
|
id: "s1".to_string(), label: "L1".to_string(), unit: "V".to_string(), scale: 1.0, offset: 0.0,
|
||||||
|
byte_offset: Some(0), byte_type: Some("f32_le".to_string()), column: None, pv: None, regex: None, path: vec![],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let source = UdpSource::new(config);
|
||||||
|
assert_eq!(source.name(), "tu");
|
||||||
|
assert_eq!(source.signals()[0].id, "udp://tu/s1");
|
||||||
|
assert_eq!(source.bind_addr, "0.0.0.0:0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_udp_parsing_logic() {
|
||||||
|
use byteorder::{ByteOrder, LittleEndian};
|
||||||
|
let mut buf = [0u8; 4];
|
||||||
|
LittleEndian::write_f32(&mut buf, 1.23);
|
||||||
|
let val = LittleEndian::read_f32(&buf) as f64;
|
||||||
|
assert!((val - 1.23).abs() < 0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use rmon_common::signal::Sample;
|
||||||
|
|
||||||
|
pub struct CircularBuffer {
|
||||||
|
samples: RwLock<VecDeque<Sample>>,
|
||||||
|
window: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CircularBuffer {
|
||||||
|
pub fn new(window: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
samples: RwLock::new(VecDeque::new()),
|
||||||
|
window,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn push(&self, sample: Sample) {
|
||||||
|
let mut samples = self.samples.write().await;
|
||||||
|
samples.push_back(sample);
|
||||||
|
|
||||||
|
let cutoff = sample.timestamp - self.window.as_nanos() as i64;
|
||||||
|
while let Some(oldest) = samples.front() {
|
||||||
|
if oldest.timestamp < cutoff {
|
||||||
|
samples.pop_front();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_samples(&self, from_ns: Option<i64>) -> Vec<Sample> {
|
||||||
|
let samples = self.samples.read().await;
|
||||||
|
if let Some(from) = from_ns {
|
||||||
|
samples.iter().filter(|s| s.timestamp >= from).cloned().collect()
|
||||||
|
} else {
|
||||||
|
samples.iter().cloned().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_circular_trimming() {
|
||||||
|
let window = Duration::from_nanos(100);
|
||||||
|
let buf = CircularBuffer::new(window);
|
||||||
|
|
||||||
|
// Push sample at t=1000
|
||||||
|
buf.push(Sample { timestamp: 1000, value: 1.0 }).await;
|
||||||
|
// Push sample at t=1050 (within window)
|
||||||
|
buf.push(Sample { timestamp: 1050, value: 2.0 }).await;
|
||||||
|
// Push sample at t=1110 (cutoff is now 1110-100 = 1010, so t=1000 should be dropped)
|
||||||
|
buf.push(Sample { timestamp: 1110, value: 3.0 }).await;
|
||||||
|
|
||||||
|
let samples = buf.get_samples(None).await;
|
||||||
|
assert_eq!(samples.len(), 2);
|
||||||
|
assert_eq!(samples[0].timestamp, 1050);
|
||||||
|
assert_eq!(samples[1].timestamp, 1110);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_circular_query_range() {
|
||||||
|
let buf = CircularBuffer::new(Duration::from_nanos(1000));
|
||||||
|
buf.push(Sample { timestamp: 100, value: 1.0 }).await;
|
||||||
|
buf.push(Sample { timestamp: 200, value: 2.0 }).await;
|
||||||
|
buf.push(Sample { timestamp: 300, value: 3.0 }).await;
|
||||||
|
|
||||||
|
let samples = buf.get_samples(Some(200)).await;
|
||||||
|
assert_eq!(samples.len(), 2);
|
||||||
|
assert_eq!(samples[0].timestamp, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::io::{Write, BufWriter};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use anyhow::Result;
|
||||||
|
use rmon_common::signal::Sample;
|
||||||
|
use byteorder::{LittleEndian, WriteBytesExt};
|
||||||
|
|
||||||
|
pub struct ContinuousStorage {
|
||||||
|
writer: BufWriter<File>,
|
||||||
|
_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContinuousStorage {
|
||||||
|
pub fn create(data_dir: PathBuf, source_name: &str, signal_id: &str, session_start_ns: i64) -> Result<Self> {
|
||||||
|
let mut path = data_dir;
|
||||||
|
path.push(source_name);
|
||||||
|
path.push(signal_id.replace(":", "_").replace("/", "_")); // Escape signal ID for filesystem
|
||||||
|
std::fs::create_dir_all(&path)?;
|
||||||
|
|
||||||
|
path.push(format!("{}.bin", session_start_ns));
|
||||||
|
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
writer: BufWriter::new(file),
|
||||||
|
_path: path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append(&mut self, sample: Sample) -> Result<()> {
|
||||||
|
self.writer.write_i64::<LittleEndian>(sample.timestamp)?;
|
||||||
|
self.writer.write_f64::<LittleEndian>(sample.value)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flush(&mut self) -> Result<()> {
|
||||||
|
self.writer.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_continuous_storage_creation() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let res = ContinuousStorage::create(
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
"src",
|
||||||
|
"sig1",
|
||||||
|
12345
|
||||||
|
);
|
||||||
|
assert!(res.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_continuous_append() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut storage = ContinuousStorage::create(
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
"src",
|
||||||
|
"sig1",
|
||||||
|
12345
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
let sample = Sample { timestamp: 100, value: 42.0 };
|
||||||
|
storage.append(sample).unwrap();
|
||||||
|
storage.flush().unwrap();
|
||||||
|
|
||||||
|
// Verify file exists and has size 16 bytes (i64 + f64)
|
||||||
|
let path = dir.path().join("src/sig1/12345.bin");
|
||||||
|
assert!(path.exists());
|
||||||
|
assert_eq!(fs::metadata(path).unwrap().len(), 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod circular;
|
||||||
|
pub mod continuous;
|
||||||
|
|
||||||
|
pub use self::circular::CircularBuffer;
|
||||||
|
pub use self::continuous::ContinuousStorage;
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::{TcpStream, UdpSocket};
|
||||||
|
use tokio_util::codec::Framed;
|
||||||
|
use futures::{StreamExt, SinkExt};
|
||||||
|
use rmon_common::protocol::{ClientMessage, AgentMessage, BincodeCodec, PROTOCOL_VERSION};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use bytes::BytesMut;
|
||||||
|
use tokio_util::codec::{Decoder, Encoder};
|
||||||
|
use std::io;
|
||||||
|
use rmon_agent::config::Config;
|
||||||
|
use rmon_agent::run_agent;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
/// Client side codec
|
||||||
|
pub struct ClientCodec;
|
||||||
|
|
||||||
|
impl Decoder for ClientCodec {
|
||||||
|
type Item = AgentMessage;
|
||||||
|
type Error = io::Error;
|
||||||
|
|
||||||
|
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
|
||||||
|
let mut codec = BincodeCodec::<AgentMessage>::default();
|
||||||
|
codec.decode(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Encoder<ClientMessage> for ClientCodec {
|
||||||
|
type Error = io::Error;
|
||||||
|
|
||||||
|
fn encode(&mut self, item: ClientMessage, dst: &mut BytesMut) -> Result<(), Self::Error> {
|
||||||
|
let mut codec = BincodeCodec::<ClientMessage>::default();
|
||||||
|
codec.encode(item, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup_agent(port: u16, udp_port: u16, config_path: PathBuf, data_dir: &str) -> Result<()> {
|
||||||
|
let config_str = format!(r#"
|
||||||
|
[storage]
|
||||||
|
data_dir = "{}"
|
||||||
|
default_mode = "circular"
|
||||||
|
default_window = "1h"
|
||||||
|
|
||||||
|
[[sources]]
|
||||||
|
type = "udp"
|
||||||
|
name = "test_source"
|
||||||
|
bind = "127.0.0.1:{}"
|
||||||
|
[sources.timestamp]
|
||||||
|
format = "none"
|
||||||
|
[[sources.signals]]
|
||||||
|
id = "sig1"
|
||||||
|
label = "Sig 1"
|
||||||
|
unit = "V"
|
||||||
|
scale = 1.0
|
||||||
|
offset = 0.0
|
||||||
|
byte_offset = 0
|
||||||
|
byte_type = "f32_le"
|
||||||
|
path = ["test"]
|
||||||
|
"#, data_dir, udp_port);
|
||||||
|
|
||||||
|
if let Some(parent) = config_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::write(&config_path, config_str.clone())?;
|
||||||
|
let config: Config = toml::from_str(&config_str)?;
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = run_agent(config, &format!("127.0.0.1:{}", port), config_path).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(Duration::from_millis(300)).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_agent_full_flow() -> Result<()> {
|
||||||
|
tokio::time::timeout(Duration::from_secs(20), async {
|
||||||
|
let port = 7892;
|
||||||
|
let udp_port = 9001;
|
||||||
|
let data_dir = "/tmp/rmon-test-data-full";
|
||||||
|
let config_path = PathBuf::from("/tmp/rmon-test/config_full.toml");
|
||||||
|
let _ = fs::remove_dir_all(data_dir);
|
||||||
|
|
||||||
|
setup_agent(port, udp_port, config_path, data_dir).await?;
|
||||||
|
|
||||||
|
let stream = TcpStream::connect(format!("127.0.0.1:{}", port)).await?;
|
||||||
|
let mut framed = Framed::new(stream, ClientCodec);
|
||||||
|
|
||||||
|
framed.send(ClientMessage::Handshake {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
client_version: "0.1.0".to_string(),
|
||||||
|
}).await?;
|
||||||
|
|
||||||
|
let ack = framed.next().await.ok_or_else(|| anyhow::anyhow!("Disconnected"))??;
|
||||||
|
if let AgentMessage::HandshakeAck { accepted, .. } = ack {
|
||||||
|
assert!(accepted);
|
||||||
|
} else {
|
||||||
|
panic!("Expected HandshakeAck, got {:?}", ack);
|
||||||
|
}
|
||||||
|
|
||||||
|
framed.send(ClientMessage::Subscribe {
|
||||||
|
ids: vec!["udp://test_source/sig1".to_string()],
|
||||||
|
from_ns: None,
|
||||||
|
}).await?;
|
||||||
|
|
||||||
|
let udp_socket = UdpSocket::bind("127.0.0.1:0").await?;
|
||||||
|
let val: f32 = 42.0;
|
||||||
|
udp_socket.send_to(&val.to_le_bytes(), format!("127.0.0.1:{}", udp_port)).await?;
|
||||||
|
|
||||||
|
let msg = framed.next().await.ok_or_else(|| anyhow::anyhow!("Disconnected"))??;
|
||||||
|
if let AgentMessage::DataBatch { batches } = msg {
|
||||||
|
assert_eq!(batches[0].id, "udp://test_source/sig1");
|
||||||
|
assert_eq!(batches[0].samples[0].value, 42.0);
|
||||||
|
} else {
|
||||||
|
panic!("Expected DataBatch, got {:?}", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check continuous storage
|
||||||
|
sleep(Duration::from_millis(200)).await;
|
||||||
|
let signal_dir = format!("{}/test_source/udp___test_source_sig1", data_dir);
|
||||||
|
let data_files = fs::read_dir(&signal_dir).map_err(|e| anyhow::anyhow!("Failed to read {}: {}", signal_dir, e))?;
|
||||||
|
assert!(data_files.count() > 0);
|
||||||
|
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
}).await.map_err(|_| anyhow::anyhow!("Test timed out"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_agent_config_remote() -> Result<()> {
|
||||||
|
tokio::time::timeout(Duration::from_secs(20), async {
|
||||||
|
let port = 7894;
|
||||||
|
let udp_port = 9002;
|
||||||
|
let data_dir = "/tmp/rmon-test-data-remote";
|
||||||
|
let config_path = PathBuf::from("/tmp/rmon-test/config_remote.toml");
|
||||||
|
let _ = fs::remove_dir_all(data_dir);
|
||||||
|
|
||||||
|
setup_agent(port, udp_port, config_path.clone(), data_dir).await?;
|
||||||
|
|
||||||
|
let stream = TcpStream::connect(format!("127.0.0.1:{}", port)).await?;
|
||||||
|
let mut framed = Framed::new(stream, ClientCodec);
|
||||||
|
|
||||||
|
framed.send(ClientMessage::Handshake {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
client_version: "0.1.0".to_string(),
|
||||||
|
}).await?;
|
||||||
|
let _ = framed.next().await.unwrap()?;
|
||||||
|
|
||||||
|
// 1. Get Config
|
||||||
|
framed.send(ClientMessage::GetConfig).await?;
|
||||||
|
let msg = framed.next().await.ok_or_else(|| anyhow::anyhow!("Disconnected"))??;
|
||||||
|
if let AgentMessage::ConfigReport { config_toml } = msg {
|
||||||
|
assert!(config_toml.contains("test_source"));
|
||||||
|
} else {
|
||||||
|
panic!("Expected ConfigReport, got {:?}", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update Config
|
||||||
|
let new_config = format!(r#"
|
||||||
|
[storage]
|
||||||
|
data_dir = "{}"
|
||||||
|
default_mode = "circular"
|
||||||
|
default_window = "1h"
|
||||||
|
|
||||||
|
[[sources]]
|
||||||
|
type = "shell"
|
||||||
|
name = "new_source"
|
||||||
|
command = "echo \"val: 100\""
|
||||||
|
poll_interval = "100ms"
|
||||||
|
[sources.timestamp]
|
||||||
|
format = "none"
|
||||||
|
[[sources.signals]]
|
||||||
|
id = "sig_new"
|
||||||
|
label = "New Sig"
|
||||||
|
unit = "X"
|
||||||
|
scale = 1.0
|
||||||
|
offset = 0.0
|
||||||
|
regex = "val: ([0-9]+)"
|
||||||
|
path = ["new"]
|
||||||
|
"#, data_dir);
|
||||||
|
framed.send(ClientMessage::UpdateConfig { config_toml: new_config.to_string() }).await?;
|
||||||
|
|
||||||
|
// Wait for hot-reload
|
||||||
|
sleep(Duration::from_millis(2000)).await;
|
||||||
|
|
||||||
|
// 3. Check if signals changed
|
||||||
|
framed.send(ClientMessage::ListSignals).await?;
|
||||||
|
let msg = tokio::time::timeout(Duration::from_secs(5), framed.next()).await
|
||||||
|
.map_err(|_| anyhow::anyhow!("Timeout waiting for SignalList"))?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Disconnected"))??;
|
||||||
|
|
||||||
|
if let AgentMessage::SignalList { signals } = msg {
|
||||||
|
assert!(signals.iter().any(|s| s.id == "shell://new_source/sig_new"), "New signal not found in {:?}", signals);
|
||||||
|
} else {
|
||||||
|
panic!("Expected SignalList, got {:?}", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
}).await.map_err(|_| anyhow::anyhow!("Test timed out"))?
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "rmon-common"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
bincode.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
tokio-util.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod signal;
|
||||||
|
pub mod protocol;
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::signal::{SignalId, SignalInfo, Sample, StorageMode, SourceType};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum ClientMessage {
|
||||||
|
Handshake {
|
||||||
|
protocol_version: u16,
|
||||||
|
client_version: String,
|
||||||
|
},
|
||||||
|
ListSignals,
|
||||||
|
Subscribe {
|
||||||
|
ids: Vec<SignalId>,
|
||||||
|
from_ns: Option<i64>,
|
||||||
|
},
|
||||||
|
Unsubscribe {
|
||||||
|
ids: Vec<SignalId>,
|
||||||
|
},
|
||||||
|
QueryHistory {
|
||||||
|
id: SignalId,
|
||||||
|
from_ns: i64,
|
||||||
|
to_ns: i64,
|
||||||
|
max_points: u32,
|
||||||
|
},
|
||||||
|
StartRecording {
|
||||||
|
ids: Vec<SignalId>,
|
||||||
|
mode: StorageMode,
|
||||||
|
},
|
||||||
|
StopRecording {
|
||||||
|
ids: Vec<SignalId>,
|
||||||
|
},
|
||||||
|
GetStatus,
|
||||||
|
GetConfig,
|
||||||
|
UpdateConfig {
|
||||||
|
config_toml: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum AgentMessage {
|
||||||
|
HandshakeAck {
|
||||||
|
protocol_version: u16,
|
||||||
|
agent_version: String,
|
||||||
|
accepted: bool,
|
||||||
|
reject_reason: Option<String>,
|
||||||
|
},
|
||||||
|
SignalList {
|
||||||
|
signals: Vec<SignalInfo>,
|
||||||
|
},
|
||||||
|
DataBatch {
|
||||||
|
batches: Vec<SignalBatch>,
|
||||||
|
},
|
||||||
|
HistoryChunk {
|
||||||
|
id: SignalId,
|
||||||
|
samples: Vec<Sample>,
|
||||||
|
done: bool,
|
||||||
|
},
|
||||||
|
RecordingStatus {
|
||||||
|
id: SignalId,
|
||||||
|
recording: bool,
|
||||||
|
mode: Option<StorageMode>,
|
||||||
|
},
|
||||||
|
StatusReport {
|
||||||
|
agent_version: String,
|
||||||
|
hostname: String,
|
||||||
|
uptime_secs: u64,
|
||||||
|
sources: Vec<SourceStatus>,
|
||||||
|
},
|
||||||
|
ConfigReport {
|
||||||
|
config_toml: String,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
context: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SignalBatch {
|
||||||
|
pub id: SignalId,
|
||||||
|
pub samples: Vec<Sample>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SourceStatus {
|
||||||
|
pub name: String,
|
||||||
|
pub source_type: SourceType,
|
||||||
|
pub connected: bool,
|
||||||
|
pub last_sample_ns: Option<i64>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const PROTOCOL_VERSION: u16 = 1;
|
||||||
|
|
||||||
|
use tokio_util::codec::{Decoder, Encoder};
|
||||||
|
use bytes::{BytesMut, Buf, BufMut};
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
pub struct BincodeCodec<T> {
|
||||||
|
_phantom: std::marker::PhantomData<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Default for BincodeCodec<T> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { _phantom: std::marker::PhantomData }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Decoder for BincodeCodec<T>
|
||||||
|
where
|
||||||
|
for<'a> T: Deserialize<'a>,
|
||||||
|
{
|
||||||
|
type Item = T;
|
||||||
|
type Error = io::Error;
|
||||||
|
|
||||||
|
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
|
||||||
|
if src.len() < 4 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut length_bytes = [0u8; 4];
|
||||||
|
length_bytes.copy_from_slice(&src[..4]);
|
||||||
|
let length = u32::from_le_bytes(length_bytes) as usize;
|
||||||
|
|
||||||
|
if length > 16 * 1024 * 1024 {
|
||||||
|
return Err(io::Error::new(io::ErrorKind::InvalidData, "Frame too large"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if src.len() < 4 + length {
|
||||||
|
src.reserve(4 + length - src.len());
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
src.advance(4);
|
||||||
|
let payload = src.split_to(length);
|
||||||
|
let item = bincode::deserialize(&payload)
|
||||||
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||||
|
|
||||||
|
Ok(Some(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Encoder<T> for BincodeCodec<T>
|
||||||
|
where
|
||||||
|
T: Serialize,
|
||||||
|
{
|
||||||
|
type Error = io::Error;
|
||||||
|
|
||||||
|
fn encode(&mut self, item: T, dst: &mut BytesMut) -> Result<(), Self::Error> {
|
||||||
|
let payload = bincode::serialize(&item)
|
||||||
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||||
|
|
||||||
|
let length = payload.len() as u32;
|
||||||
|
dst.reserve(4 + payload.len());
|
||||||
|
dst.put_u32_le(length);
|
||||||
|
dst.put_slice(&payload);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use bytes::BytesMut;
|
||||||
|
use tokio_util::codec::{Encoder, Decoder};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_codec_handshake_roundtrip() {
|
||||||
|
let mut codec = BincodeCodec::<ClientMessage>::default();
|
||||||
|
let msg = ClientMessage::Handshake {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
client_version: "1.0.0".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut buf = BytesMut::new();
|
||||||
|
codec.encode(msg.clone(), &mut buf).unwrap();
|
||||||
|
|
||||||
|
let mut decoder = BincodeCodec::<ClientMessage>::default();
|
||||||
|
let decoded = decoder.decode(&mut buf).unwrap().unwrap();
|
||||||
|
|
||||||
|
if let (ClientMessage::Handshake { protocol_version: v1, .. },
|
||||||
|
ClientMessage::Handshake { protocol_version: v2, .. }) = (msg, decoded) {
|
||||||
|
assert_eq!(v1, v2);
|
||||||
|
} else {
|
||||||
|
panic!("Decoded message mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_codec_frame_limit() {
|
||||||
|
let mut buf = BytesMut::new();
|
||||||
|
buf.put_u32_le(20 * 1024 * 1024); // Exceeds 16MB limit
|
||||||
|
buf.put_bytes(0, 10);
|
||||||
|
|
||||||
|
let mut decoder = BincodeCodec::<ClientMessage>::default();
|
||||||
|
let res = decoder.decode(&mut buf);
|
||||||
|
assert!(res.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_message_variants() {
|
||||||
|
// Just ensure they can be serialized
|
||||||
|
let m1 = ClientMessage::ListSignals;
|
||||||
|
let m2 = ClientMessage::GetStatus;
|
||||||
|
let m3 = ClientMessage::GetConfig;
|
||||||
|
let _ = bincode::serialize(&m1).unwrap();
|
||||||
|
let _ = bincode::serialize(&m2).unwrap();
|
||||||
|
let _ = bincode::serialize(&m3).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub type SignalId = String;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct SignalInfo {
|
||||||
|
pub id: SignalId,
|
||||||
|
pub label: String,
|
||||||
|
pub unit: String,
|
||||||
|
pub scale: f64,
|
||||||
|
pub offset: f64,
|
||||||
|
pub sampling_period: Option<Duration>,
|
||||||
|
pub source_type: SourceType,
|
||||||
|
pub path: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum SourceType {
|
||||||
|
Csv,
|
||||||
|
Udp,
|
||||||
|
Epics,
|
||||||
|
Custom(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Sample {
|
||||||
|
/// Nanoseconds since Unix epoch (UTC).
|
||||||
|
pub timestamp: i64,
|
||||||
|
pub value: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum StorageMode {
|
||||||
|
Continuous,
|
||||||
|
Circular { window: Duration },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sample_equality() {
|
||||||
|
let s1 = Sample { timestamp: 100, value: 42.0 };
|
||||||
|
let s2 = Sample { timestamp: 100, value: 42.0 };
|
||||||
|
let s3 = Sample { timestamp: 101, value: 42.0 };
|
||||||
|
assert_eq!(s1, s2);
|
||||||
|
assert_ne!(s1, s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_signal_info_creation() {
|
||||||
|
let info = SignalInfo {
|
||||||
|
id: "test://sig".to_string(),
|
||||||
|
label: "Test".to_string(),
|
||||||
|
unit: "V".to_string(),
|
||||||
|
scale: 1.0,
|
||||||
|
offset: 0.0,
|
||||||
|
sampling_period: None,
|
||||||
|
source_type: SourceType::Csv,
|
||||||
|
path: vec!["a".to_string()],
|
||||||
|
};
|
||||||
|
assert_eq!(info.id, "test://sig");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "rmon-ui"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
eframe = "0.24"
|
||||||
|
egui = "0.24"
|
||||||
|
egui_plot = "0.24"
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
bincode.workspace = true
|
||||||
|
rmon-common.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
tokio-util.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
itertools.workspace = true
|
||||||
|
libc.workspace = true
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
use crate::connection::ProtocolClient;
|
||||||
|
use crate::state::SessionState;
|
||||||
|
use crate::state::SignalHistory;
|
||||||
|
use rmon_common::protocol::{ClientMessage, AgentMessage};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::{mpsc, oneshot};
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use egui_plot::{Line, Plot, PlotPoints};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum UiMessage {
|
||||||
|
Agent(AgentMessage),
|
||||||
|
Connected(Arc<ProtocolClient>),
|
||||||
|
Tunnel(Arc<Mutex<Option<std::process::Child>>>),
|
||||||
|
Disconnected,
|
||||||
|
Error(String),
|
||||||
|
Log(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RMonApp {
|
||||||
|
pub(crate) ssh_host: String,
|
||||||
|
pub(crate) agent_port: u16,
|
||||||
|
pub(crate) addr: String,
|
||||||
|
pub(crate) show_connect_dialog: bool,
|
||||||
|
pub(crate) show_config_editor: bool,
|
||||||
|
pub(crate) pending_input_request: Option<(String, oneshot::Sender<String>)>,
|
||||||
|
pub(crate) input_value: String,
|
||||||
|
pub(crate) config_text: String,
|
||||||
|
pub(crate) last_error: Option<String>,
|
||||||
|
pub(crate) logs: VecDeque<String>,
|
||||||
|
pub(crate) state: Arc<Mutex<SessionState>>,
|
||||||
|
pub(crate) client: Option<Arc<ProtocolClient>>,
|
||||||
|
pub(crate) tunnel_child: Option<Arc<Mutex<Option<std::process::Child>>>>,
|
||||||
|
pub(crate) ui_rx: mpsc::Receiver<UiMessage>,
|
||||||
|
pub(crate) ui_tx: mpsc::Sender<UiMessage>,
|
||||||
|
pub(crate) selected_signals: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RMonApp {
|
||||||
|
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
|
||||||
|
let (tx, rx) = mpsc::channel(100);
|
||||||
|
let app = Self {
|
||||||
|
ssh_host: "localhost".to_string(),
|
||||||
|
agent_port: 7891,
|
||||||
|
addr: "127.0.0.1:7891".to_string(),
|
||||||
|
show_connect_dialog: false,
|
||||||
|
show_config_editor: false,
|
||||||
|
pending_input_request: None,
|
||||||
|
input_value: String::new(),
|
||||||
|
config_text: String::new(),
|
||||||
|
last_error: None,
|
||||||
|
logs: VecDeque::with_capacity(100),
|
||||||
|
state: Arc::new(Mutex::new(SessionState::new())),
|
||||||
|
client: None,
|
||||||
|
tunnel_child: None,
|
||||||
|
ui_rx: rx,
|
||||||
|
ui_tx: tx,
|
||||||
|
selected_signals: Vec::new(),
|
||||||
|
};
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let _ = std::fs::remove_file(temp_dir.join(format!("rmon_auth_req_{}", app.agent_port)));
|
||||||
|
let _ = std::fs::remove_file(temp_dir.join(format!("rmon_auth_res_{}", app.agent_port)));
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_log(&mut self, msg: String) {
|
||||||
|
if self.logs.len() >= 100 { self.logs.pop_front(); }
|
||||||
|
self.logs.push_back(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_from_agent(&mut self, ctx: &egui::Context) {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let req_path = temp_dir.join(format!("rmon_auth_req_{}", self.agent_port));
|
||||||
|
if req_path.exists() && self.pending_input_request.is_none() {
|
||||||
|
if let Ok(prompt) = std::fs::read_to_string(&req_path) {
|
||||||
|
// Delete immediately so we don't re-trigger on next frame
|
||||||
|
let _ = std::fs::remove_file(&req_path);
|
||||||
|
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
self.pending_input_request = Some((prompt, tx));
|
||||||
|
self.input_value.clear();
|
||||||
|
|
||||||
|
let port = self.agent_port;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Ok(val) = rx.await {
|
||||||
|
let res_path = std::env::temp_dir().join(format!("rmon_auth_res_{}", port));
|
||||||
|
let _ = std::fs::write(&res_path, val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while let Ok(msg) = self.ui_rx.try_recv() {
|
||||||
|
ctx.request_repaint();
|
||||||
|
self.process_ui_message(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_ui_message(&mut self, msg: UiMessage) {
|
||||||
|
match msg {
|
||||||
|
UiMessage::Log(log) => self.add_log(log),
|
||||||
|
UiMessage::Tunnel(child) => {
|
||||||
|
self.add_log("SSH tunnel established".to_string());
|
||||||
|
self.tunnel_child = Some(child);
|
||||||
|
}
|
||||||
|
UiMessage::Connected(client) => {
|
||||||
|
self.add_log("Agent connected".to_string());
|
||||||
|
self.client = Some(client);
|
||||||
|
self.state.lock().unwrap().connected = true;
|
||||||
|
self.show_connect_dialog = false;
|
||||||
|
self.last_error = None;
|
||||||
|
let client_clone = self.client.as_ref().unwrap().clone();
|
||||||
|
tokio::spawn(async move { let _ = client_clone.send(ClientMessage::GetConfig).await; });
|
||||||
|
}
|
||||||
|
UiMessage::Disconnected => {
|
||||||
|
self.add_log("Session ended".to_string());
|
||||||
|
self.client = None;
|
||||||
|
self.state.lock().unwrap().connected = false;
|
||||||
|
if let Some(child_lock) = self.tunnel_child.take() {
|
||||||
|
if let Ok(mut child) = child_lock.lock() {
|
||||||
|
if let Some(mut c) = child.take() { let _ = c.kill(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UiMessage::Error(err) => {
|
||||||
|
self.add_log(format!("ERROR: {}", err));
|
||||||
|
self.last_error = Some(err);
|
||||||
|
}
|
||||||
|
UiMessage::Agent(AgentMessage::SignalList { signals }) => {
|
||||||
|
self.add_log(format!("Received signal list ({} signals)", signals.len()));
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
for sig in signals {
|
||||||
|
state.signals.entry(sig.id.clone()).or_insert_with(|| Arc::new(Mutex::new(SignalHistory::new(sig))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UiMessage::Agent(AgentMessage::ConfigReport { config_toml }) => { self.config_text = config_toml; }
|
||||||
|
UiMessage::Agent(AgentMessage::DataBatch { batches }) => {
|
||||||
|
let mut new_ids = Vec::new();
|
||||||
|
{
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
for batch in batches {
|
||||||
|
if let Some(history) = state.signals.get(&batch.id) {
|
||||||
|
let mut history = history.lock().unwrap();
|
||||||
|
let is_first = history.samples.is_empty();
|
||||||
|
for sample in batch.samples { history.push(sample); }
|
||||||
|
if is_first { new_ids.push(batch.id.clone()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in new_ids { self.add_log(format!("Data start for {}", id)); }
|
||||||
|
}
|
||||||
|
UiMessage::Agent(AgentMessage::Error { context, message }) => { self.add_log(format!("AGENT ERROR ({}): {}", context, message)); }
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_auth_dialog(&mut self, ctx: &egui::Context) {
|
||||||
|
if self.pending_input_request.is_some() {
|
||||||
|
let prompt = self.pending_input_request.as_ref().unwrap().0.clone();
|
||||||
|
let mut open = true;
|
||||||
|
egui::Window::new("SSH Authentication Required").open(&mut open).collapsible(false).resizable(false).show(ctx, |ui| {
|
||||||
|
ui.label(egui::RichText::new(&prompt).strong());
|
||||||
|
ui.add_space(8.0);
|
||||||
|
let is_password = prompt.to_lowercase().contains("password");
|
||||||
|
let text_edit = egui::TextEdit::singleline(&mut self.input_value).password(is_password);
|
||||||
|
let response = ui.add(text_edit);
|
||||||
|
response.request_focus();
|
||||||
|
ui.add_space(10.0);
|
||||||
|
if ui.button("Confirm").clicked() || (response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
|
||||||
|
if let Some((_, tx)) = self.pending_input_request.take() { let _ = tx.send(self.input_value.clone()); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if !open { self.pending_input_request = None; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_connect_dialog(&mut self, ctx: &egui::Context) {
|
||||||
|
let mut open = self.show_connect_dialog;
|
||||||
|
egui::Window::new("Connect to Agent").open(&mut open).show(ctx, |ui| {
|
||||||
|
if let Some(err) = &self.last_error { ui.colored_label(egui::Color32::RED, format!("❌ {}", err)); }
|
||||||
|
egui::Grid::new("connect_grid").num_columns(2).show(ui, |ui| {
|
||||||
|
ui.label("SSH Host:"); ui.text_edit_singleline(&mut self.ssh_host); ui.end_row();
|
||||||
|
ui.label("Agent Port:"); ui.add(egui::DragValue::new(&mut self.agent_port)); ui.end_row();
|
||||||
|
ui.label("Local TCP Addr:"); ui.text_edit_singleline(&mut self.addr); ui.end_row();
|
||||||
|
});
|
||||||
|
if ui.button("🚀 Connect").clicked() {
|
||||||
|
let (ssh_host, agent_port, addr, ui_tx) = (self.ssh_host.clone(), self.agent_port, self.addr.clone(), self.ui_tx.clone());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
use crate::connection::SshConnection;
|
||||||
|
let ssh = SshConnection::new(ssh_host.clone(), agent_port);
|
||||||
|
let is_localhost = ssh_host == "localhost" || ssh_host == "127.0.0.1";
|
||||||
|
let local_port = addr.split(':').last().and_then(|p| p.parse::<u16>().ok()).unwrap_or(7891);
|
||||||
|
let skip_tunnel = is_localhost && local_port == agent_port;
|
||||||
|
let _ = ui_tx.send(UiMessage::Log("Preparing...".to_string())).await;
|
||||||
|
let _ = ssh.kill_local_orphan_tunnels();
|
||||||
|
if let Err(e) = ssh.establish_master() { let _ = ui_tx.send(UiMessage::Error(format!("SSH Auth failed: {}", e))).await; return; }
|
||||||
|
match ssh.check_running() {
|
||||||
|
Ok(false) => {
|
||||||
|
let _ = ui_tx.send(UiMessage::Log("Deploying and starting agent...".to_string())).await;
|
||||||
|
if let Err(e) = ssh.start_agent() { let _ = ui_tx.send(UiMessage::Error(format!("Start failed: {}", e))).await; return; }
|
||||||
|
sleep(std::time::Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
_ => { let _ = ui_tx.send(UiMessage::Log("Agent already running".to_string())).await; }
|
||||||
|
}
|
||||||
|
if !skip_tunnel {
|
||||||
|
let _ = ui_tx.send(UiMessage::Log("Establishing SSH tunnel...".to_string())).await;
|
||||||
|
match ssh.create_tunnel() {
|
||||||
|
Ok(child) => { let _ = ui_tx.send(UiMessage::Tunnel(Arc::new(Mutex::new(Some(child))))).await; sleep(std::time::Duration::from_secs(1)).await; }
|
||||||
|
Err(e) => { let _ = ui_tx.send(UiMessage::Error(format!("Tunnel failed: {}", e))).await; return; }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _ = ui_tx.send(UiMessage::Log("Direct local connection".to_string())).await;
|
||||||
|
}
|
||||||
|
match ProtocolClient::connect(&addr, ui_tx.clone()).await {
|
||||||
|
Ok(client) => { let _ = ui_tx.send(UiMessage::Connected(Arc::new(client))).await; }
|
||||||
|
Err(e) => {
|
||||||
|
let _ = ui_tx.send(UiMessage::Error(format!("Handshake failed: {}", e))).await;
|
||||||
|
let _ = ssh.close_master();
|
||||||
|
let _ = ui_tx.send(UiMessage::Disconnected).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.show_connect_dialog = open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for RMonApp {
|
||||||
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
self.update_from_agent(ctx);
|
||||||
|
if self.pending_input_request.is_some() { self.draw_auth_dialog(ctx); }
|
||||||
|
if self.show_connect_dialog { self.draw_connect_dialog(ctx); }
|
||||||
|
if self.show_config_editor {
|
||||||
|
let mut open = self.show_config_editor;
|
||||||
|
egui::Window::new("Agent Configuration").open(&mut open).show(ctx, |ui| {
|
||||||
|
ui.add(egui::TextEdit::multiline(&mut self.config_text).font(egui::TextStyle::Monospace).desired_width(f32::INFINITY));
|
||||||
|
if ui.button("💾 Save & Apply").clicked() {
|
||||||
|
if let Some(client) = &self.client {
|
||||||
|
let client = client.clone();
|
||||||
|
let config_toml = self.config_text.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = client.send(ClientMessage::UpdateConfig { config_toml }).await;
|
||||||
|
sleep(std::time::Duration::from_millis(1500)).await;
|
||||||
|
let _ = client.send(ClientMessage::ListSignals).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ui.button("🔄 Refresh").clicked() {
|
||||||
|
if let Some(client) = &self.client {
|
||||||
|
let client = client.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = client.send(ClientMessage::GetConfig).await;
|
||||||
|
let _ = client.send(ClientMessage::ListSignals).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.show_config_editor = open;
|
||||||
|
}
|
||||||
|
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
|
||||||
|
egui::menu::bar(ui, |ui| {
|
||||||
|
ui.menu_button("File", |ui| { if ui.button("Quit").clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } });
|
||||||
|
ui.separator();
|
||||||
|
if self.client.is_none() {
|
||||||
|
if ui.button("🚀 Connect...").clicked() { self.last_error = None; self.show_connect_dialog = true; }
|
||||||
|
} else {
|
||||||
|
if ui.button("🔌 Disconnect").clicked() { let _ = self.ui_tx.try_send(UiMessage::Disconnected); }
|
||||||
|
if ui.button("💀 Kill Agent").clicked() {
|
||||||
|
let (host, port, tx) = (self.ssh_host.clone(), self.agent_port, self.ui_tx.clone());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
use crate::connection::SshConnection;
|
||||||
|
let ssh = SshConnection::new(host, port);
|
||||||
|
let _ = tx.send(UiMessage::Log("Killing agent...".to_string())).await;
|
||||||
|
let _ = ssh.kill_agent();
|
||||||
|
});
|
||||||
|
let _ = self.ui_tx.try_send(UiMessage::Disconnected);
|
||||||
|
}
|
||||||
|
if ui.button("⚙ Configure Agent").clicked() { self.show_config_editor = true; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
egui::TopBottomPanel::bottom("log_panel").resizable(true).default_height(100.0).show(ctx, |ui| {
|
||||||
|
ui.heading("Logs");
|
||||||
|
egui::ScrollArea::vertical().stick_to_bottom(true).show(ui, |ui| {
|
||||||
|
for log in &self.logs { ui.label(egui::RichText::new(log).monospace()); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
egui::SidePanel::left("left_panel").show(ctx, |ui| {
|
||||||
|
ui.heading("Signals");
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
let mut ids: Vec<_> = state.signals.keys().cloned().collect();
|
||||||
|
ids.sort();
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
for id in ids {
|
||||||
|
if let Some(history) = state.signals.get(&id) {
|
||||||
|
let history = history.lock().unwrap();
|
||||||
|
let mut checked = self.selected_signals.contains(&id);
|
||||||
|
if ui.checkbox(&mut checked, &history.info.label).changed() {
|
||||||
|
if checked {
|
||||||
|
self.selected_signals.push(id.clone());
|
||||||
|
if let Some(client) = &self.client {
|
||||||
|
let client = client.clone();
|
||||||
|
tokio::spawn(async move { let _ = client.send(ClientMessage::Subscribe { ids: vec![id], from_ns: None }).await; });
|
||||||
|
}
|
||||||
|
} else { self.selected_signals.retain(|s| s != &id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
ui.heading("Plots");
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
for id in &self.selected_signals {
|
||||||
|
if let Some(history) = state.signals.get(id) {
|
||||||
|
let history = history.lock().unwrap();
|
||||||
|
ui.label(format!("{} [{}]", history.info.label, history.samples.len()));
|
||||||
|
if !history.samples.is_empty() {
|
||||||
|
let first_ts = history.samples[0].timestamp as f64 / 1e9;
|
||||||
|
let points: PlotPoints = history.samples.iter().map(|s| [(s.timestamp as f64 / 1e9) - first_ts, s.value]).collect();
|
||||||
|
Plot::new(id).view_aspect(2.5).auto_bounds_x().auto_bounds_y().show(ui, |plot_ui| plot_ui.line(Line::new(points)));
|
||||||
|
} else { ui.label("Waiting for data..."); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rmon_common::signal::{SignalInfo, SourceType};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_app_init() {
|
||||||
|
let (tx, rx) = mpsc::channel(100);
|
||||||
|
let app = RMonApp {
|
||||||
|
ssh_host: "h".to_string(),
|
||||||
|
agent_port: 1,
|
||||||
|
addr: "a".to_string(),
|
||||||
|
show_connect_dialog: false,
|
||||||
|
show_config_editor: false,
|
||||||
|
pending_input_request: None,
|
||||||
|
input_value: "".to_string(),
|
||||||
|
config_text: "".to_string(),
|
||||||
|
last_error: None,
|
||||||
|
logs: VecDeque::new(),
|
||||||
|
state: Arc::new(Mutex::new(SessionState::new())),
|
||||||
|
client: None,
|
||||||
|
tunnel_child: None,
|
||||||
|
ui_rx: rx,
|
||||||
|
ui_tx: tx,
|
||||||
|
selected_signals: vec![],
|
||||||
|
};
|
||||||
|
assert_eq!(app.ssh_host, "h");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_process_ui_message_logic() {
|
||||||
|
let (tx, rx) = mpsc::channel(100);
|
||||||
|
let mut app = RMonApp {
|
||||||
|
ssh_host: "h".to_string(),
|
||||||
|
agent_port: 1,
|
||||||
|
addr: "a".to_string(),
|
||||||
|
show_connect_dialog: false,
|
||||||
|
show_config_editor: false,
|
||||||
|
pending_input_request: None,
|
||||||
|
input_value: "".to_string(),
|
||||||
|
config_text: "".to_string(),
|
||||||
|
last_error: None,
|
||||||
|
logs: VecDeque::new(),
|
||||||
|
state: Arc::new(Mutex::new(SessionState::new())),
|
||||||
|
client: None,
|
||||||
|
tunnel_child: None,
|
||||||
|
ui_rx: rx,
|
||||||
|
ui_tx: tx,
|
||||||
|
selected_signals: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
app.process_ui_message(UiMessage::Log("test log".to_string()));
|
||||||
|
assert_eq!(app.logs.len(), 1);
|
||||||
|
assert_eq!(app.logs[0], "test log");
|
||||||
|
|
||||||
|
app.process_ui_message(UiMessage::Error("test err".to_string()));
|
||||||
|
assert_eq!(app.last_error.as_ref().unwrap(), "test err");
|
||||||
|
|
||||||
|
let sig = SignalInfo {
|
||||||
|
id: "s1".to_string(), label: "L".to_string(), unit: "V".to_string(), scale: 1.0, offset: 0.0,
|
||||||
|
sampling_period: None, source_type: SourceType::Csv, path: vec![],
|
||||||
|
};
|
||||||
|
app.process_ui_message(UiMessage::Agent(AgentMessage::SignalList { signals: vec![sig] }));
|
||||||
|
assert_eq!(app.state.lock().unwrap().signals.len(), 1);
|
||||||
|
|
||||||
|
app.process_ui_message(UiMessage::Agent(AgentMessage::ConfigReport { config_toml: "cfg".to_string() }));
|
||||||
|
assert_eq!(app.config_text, "cfg");
|
||||||
|
|
||||||
|
app.process_ui_message(UiMessage::Disconnected);
|
||||||
|
assert!(!app.state.lock().unwrap().connected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio_util::codec::{Framed, Decoder, Encoder};
|
||||||
|
use futures::{StreamExt, SinkExt};
|
||||||
|
use rmon_common::protocol::{ClientMessage, AgentMessage, BincodeCodec, PROTOCOL_VERSION};
|
||||||
|
use bytes::BytesMut;
|
||||||
|
use std::io;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
pub mod ssh;
|
||||||
|
pub use self::ssh::SshConnection;
|
||||||
|
|
||||||
|
pub struct ClientCodec;
|
||||||
|
|
||||||
|
impl Decoder for ClientCodec {
|
||||||
|
type Item = AgentMessage;
|
||||||
|
type Error = io::Error;
|
||||||
|
|
||||||
|
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
|
||||||
|
let mut codec = BincodeCodec::<AgentMessage>::default();
|
||||||
|
codec.decode(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Encoder<ClientMessage> for ClientCodec {
|
||||||
|
type Error = io::Error;
|
||||||
|
|
||||||
|
fn encode(&mut self, item: ClientMessage, dst: &mut BytesMut) -> Result<(), Self::Error> {
|
||||||
|
let mut codec = BincodeCodec::<ClientMessage>::default();
|
||||||
|
codec.encode(item, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProtocolClient {
|
||||||
|
tx: mpsc::Sender<ClientMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtocolClient {
|
||||||
|
pub async fn connect(addr: &str, ui_tx: mpsc::Sender<crate::app::UiMessage>) -> Result<Self> {
|
||||||
|
tracing::info!("Connecting to agent at {}...", addr);
|
||||||
|
let stream = TcpStream::connect(addr).await?;
|
||||||
|
let mut framed = Framed::new(stream, ClientCodec);
|
||||||
|
|
||||||
|
// 1. Handshake
|
||||||
|
framed.send(ClientMessage::Handshake {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
client_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
}).await?;
|
||||||
|
|
||||||
|
match framed.next().await {
|
||||||
|
Some(Ok(AgentMessage::HandshakeAck { accepted: true, .. })) => {
|
||||||
|
tracing::info!("Handshake accepted");
|
||||||
|
}
|
||||||
|
Some(Ok(AgentMessage::HandshakeAck { accepted: false, reject_reason, .. })) => {
|
||||||
|
return Err(anyhow::anyhow!("Handshake rejected: {}", reject_reason.unwrap_or_default()));
|
||||||
|
}
|
||||||
|
_ => return Err(anyhow::anyhow!("Handshake failed")),
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, mut rx) = mpsc::channel::<ClientMessage>(100);
|
||||||
|
let ui_tx_clone = ui_tx.clone();
|
||||||
|
|
||||||
|
// Start message pump BEFORE sending ListSignals to ensure response is caught
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tracing::debug!("UI message pump started");
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg_res = framed.next() => {
|
||||||
|
match msg_res {
|
||||||
|
Some(Ok(msg)) => {
|
||||||
|
if let Err(_) = ui_tx_clone.send(crate::app::UiMessage::Agent(msg)).await { break; }
|
||||||
|
}
|
||||||
|
Some(Err(e)) => {
|
||||||
|
let _ = ui_tx_clone.send(crate::app::UiMessage::Error(format!("Protocol error: {}", e))).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let _ = ui_tx_clone.send(crate::app::UiMessage::Log("Agent closed connection".to_string())).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd_opt = rx.recv() => {
|
||||||
|
match cmd_opt {
|
||||||
|
Some(cmd) => {
|
||||||
|
if let Err(e) = framed.send(cmd).await {
|
||||||
|
let _ = ui_tx_clone.send(crate::app::UiMessage::Error(format!("Send failed: {}", e))).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => break, // ProtocolClient handle dropped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = ui_tx_clone.send(crate::app::UiMessage::Disconnected).await;
|
||||||
|
tracing::info!("UI message pump stopped");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now request signals
|
||||||
|
tx.send(ClientMessage::ListSignals).await?;
|
||||||
|
|
||||||
|
Ok(Self { tx })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send(&self, msg: ClientMessage) -> Result<()> {
|
||||||
|
self.tx.send(msg).await.map_err(|_| anyhow::anyhow!("Disconnected"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use std::process::{Command, Stdio, Child};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::fs;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Embedded agent binaries for different architectures.
|
||||||
|
const AGENT_X86_64: &[u8] = include_bytes!("../../../target/x86_64-unknown-linux-musl/release/rmon-agent");
|
||||||
|
|
||||||
|
pub struct SshConnection {
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
control_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshConnection {
|
||||||
|
pub fn new(host: String, port: u16) -> Self {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
// Use a unique socket path per host/port
|
||||||
|
let control_path = temp_dir.join(format!("rmon_ssh_sock_{}_{}", host.replace(".", "_"), port));
|
||||||
|
Self { host, port, control_path }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_askpass(&self) -> Result<PathBuf> {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let askpass_path = temp_dir.join(format!("rmon_askpass_{}", self.port));
|
||||||
|
let binary_path = std::env::current_exe()?;
|
||||||
|
let script = format!(
|
||||||
|
"#!/bin/sh\n\"{}\" --askpass-prompt \"$1\" --askpass-port {}\n",
|
||||||
|
binary_path.display(),
|
||||||
|
self.port
|
||||||
|
);
|
||||||
|
fs::write(&askpass_path, script)?;
|
||||||
|
let mut perms = fs::metadata(&askpass_path)?.permissions();
|
||||||
|
perms.set_mode(0o700);
|
||||||
|
fs::set_permissions(&askpass_path, perms)?;
|
||||||
|
Ok(askpass_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ssh_base(&self) -> Command {
|
||||||
|
let mut cmd = Command::new("ssh");
|
||||||
|
cmd.arg("-o").arg("RemoteCommand=none");
|
||||||
|
cmd.arg("-o").arg("StrictHostKeyChecking=no");
|
||||||
|
cmd.arg("-o").arg("ConnectTimeout=15");
|
||||||
|
cmd.arg("-o").arg(format!("ControlPath={}", self.control_path.display()));
|
||||||
|
cmd.arg(&self.host);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn establish_master(&self) -> Result<()> {
|
||||||
|
if self.control_path.exists() {
|
||||||
|
let check = Command::new("ssh")
|
||||||
|
.arg("-o").arg(format!("ControlPath={}", self.control_path.display()))
|
||||||
|
.arg("-O").arg("check")
|
||||||
|
.arg(&self.host)
|
||||||
|
.output();
|
||||||
|
if let Ok(out) = check {
|
||||||
|
if out.status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&self.control_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let askpass = self.setup_askpass()?;
|
||||||
|
tracing::info!("Establishing SSH master connection to {}...", self.host);
|
||||||
|
|
||||||
|
let mut cmd = Command::new("ssh");
|
||||||
|
cmd.arg("-o").arg("RemoteCommand=none");
|
||||||
|
cmd.arg("-o").arg("StrictHostKeyChecking=no");
|
||||||
|
cmd.arg("-o").arg("ControlMaster=yes");
|
||||||
|
cmd.arg("-o").arg("ControlPersist=30m");
|
||||||
|
cmd.arg("-o").arg(format!("ControlPath={}", self.control_path.display()));
|
||||||
|
cmd.arg("-o").arg("BatchMode=no");
|
||||||
|
cmd.env("SSH_ASKPASS", askpass);
|
||||||
|
cmd.env("SSH_ASKPASS_REQUIRE", "force");
|
||||||
|
cmd.env("DISPLAY", ":0");
|
||||||
|
cmd.arg("-T");
|
||||||
|
cmd.arg(&self.host);
|
||||||
|
cmd.arg("true");
|
||||||
|
cmd.stdin(Stdio::null());
|
||||||
|
|
||||||
|
unsafe { cmd.pre_exec(|| { libc::setsid(); Ok(()) }); }
|
||||||
|
|
||||||
|
let mut child = cmd.spawn()?;
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
while !self.control_path.exists() && count < 3000 {
|
||||||
|
if let Ok(Some(status)) = child.try_wait() {
|
||||||
|
if !self.control_path.exists() {
|
||||||
|
return Err(anyhow::anyhow!("SSH authentication failed (status: {})", status));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.control_path.exists() {
|
||||||
|
let _ = child.kill();
|
||||||
|
return Err(anyhow::anyhow!("Authentication timed out (5m limit reached)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kill_local_orphan_tunnels(&self) -> Result<()> {
|
||||||
|
let pattern = format!("rmon_ssh_sock_{}_{}", self.host.replace(".", "_"), self.port);
|
||||||
|
let _ = Command::new("pkill").arg("-f").arg(&pattern).output();
|
||||||
|
let _ = fs::remove_file(&self.control_path);
|
||||||
|
let fwd_pattern = format!("-L {}:127.0.0.1:{}", self.port, self.port);
|
||||||
|
let _ = Command::new("pkill").arg("-f").arg(&fwd_pattern).output();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deploy_agent(&self) -> Result<()> {
|
||||||
|
let mut child = self.ssh_base()
|
||||||
|
.arg("mkdir -p ~/.rmon && cat > ~/.rmon/agent && chmod +x ~/.rmon/agent")
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
let mut stdin = child.stdin.take().ok_or_else(|| anyhow::anyhow!("Failed to open stdin"))?;
|
||||||
|
stdin.write_all(AGENT_X86_64)?;
|
||||||
|
drop(stdin);
|
||||||
|
|
||||||
|
let status = child.wait()?;
|
||||||
|
if !status.success() { return Err(anyhow::anyhow!("Failed to upload agent binary")); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_running(&self) -> Result<bool> {
|
||||||
|
// More robust check: use ss or netstat to see if anything is listening on that port
|
||||||
|
// We look for the port followed by a space to avoid partial matches
|
||||||
|
let cmd = format!("(ss -tln || netstat -tln || true) | grep -q ':{} '", self.port);
|
||||||
|
let output = self.ssh_base()
|
||||||
|
.arg(cmd)
|
||||||
|
.output()?;
|
||||||
|
Ok(output.status.success())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_agent(&self) -> Result<()> {
|
||||||
|
self.deploy_agent()?;
|
||||||
|
// Use absolute path for log file in home directory
|
||||||
|
let cmd = format!("nohup ~/.rmon/agent --bind 0.0.0.0 --port {} >> ~/.rmon/agent.log 2>&1 &", self.port);
|
||||||
|
let output = self.ssh_base()
|
||||||
|
.arg(cmd)
|
||||||
|
.output()?;
|
||||||
|
if !output.status.success() { return Err(anyhow::anyhow!("Failed to start agent")); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_tunnel(&self) -> Result<Child> {
|
||||||
|
let child = self.ssh_base()
|
||||||
|
.arg("-o").arg("ExitOnForwardFailure=yes")
|
||||||
|
.arg("-N")
|
||||||
|
.arg("-L").arg(format!("{}:127.0.0.1:{}", self.port, self.port))
|
||||||
|
.spawn()?;
|
||||||
|
Ok(child)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kill_agent(&self) -> Result<()> {
|
||||||
|
let _ = self.ssh_base().arg(format!("pkill -f \"~/.rmon/agent --port {}\"", self.port)).output();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close_master(&self) -> Result<()> {
|
||||||
|
let _ = Command::new("ssh")
|
||||||
|
.arg("-o").arg(format!("ControlPath={}", self.control_path.display()))
|
||||||
|
.arg("-O").arg("exit")
|
||||||
|
.arg(&self.host)
|
||||||
|
.output();
|
||||||
|
let _ = fs::remove_file(&self.control_path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ssh_connection_init() {
|
||||||
|
let conn = SshConnection::new("myhost".to_string(), 1234);
|
||||||
|
assert_eq!(conn.host, "myhost");
|
||||||
|
assert_eq!(conn.port, 1234);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod connection;
|
||||||
|
mod state;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use crate::app::RMonApp;
|
||||||
|
|
||||||
|
fn main() -> eframe::Result<()> {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
||||||
|
// Check if we are being called as an askpass helper
|
||||||
|
if let Some(pos) = args.iter().position(|a| a == "--askpass-prompt") {
|
||||||
|
let prompt = args.get(pos + 1).cloned().unwrap_or_default();
|
||||||
|
let port = args.get(pos + 3).and_then(|p| p.parse::<u16>().ok()).unwrap_or(7891);
|
||||||
|
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let request_path = temp_dir.join(format!("rmon_auth_req_{}", port));
|
||||||
|
let response_path = temp_dir.join(format!("rmon_auth_res_{}", port));
|
||||||
|
|
||||||
|
// Signal the UI that we need input
|
||||||
|
let _ = std::fs::write(&request_path, &prompt);
|
||||||
|
|
||||||
|
// Wait for response
|
||||||
|
let mut count = 0;
|
||||||
|
while !response_path.exists() && count < 600 { // 60s timeout
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(res) = std::fs::read_to_string(&response_path) {
|
||||||
|
let _ = std::fs::remove_file(&request_path);
|
||||||
|
let _ = std::fs::remove_file(&response_path);
|
||||||
|
println!("{}", res.trim());
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// Create a tokio runtime in a separate thread for async communication
|
||||||
|
let rt = Runtime::new().expect("Unable to create Runtime");
|
||||||
|
let _enter = rt.enter();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
rt.block_on(async {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let native_options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default()
|
||||||
|
.with_inner_size([1280.0, 720.0]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
eframe::run_native(
|
||||||
|
"RMon",
|
||||||
|
native_options,
|
||||||
|
Box::new(|cc| Box::new(RMonApp::new(cc))),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
use rmon_common::signal::{SignalId, SignalInfo, Sample};
|
||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
pub struct SignalHistory {
|
||||||
|
pub info: SignalInfo,
|
||||||
|
pub samples: VecDeque<Sample>,
|
||||||
|
pub max_samples: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignalHistory {
|
||||||
|
pub fn new(info: SignalInfo) -> Self {
|
||||||
|
Self {
|
||||||
|
info,
|
||||||
|
samples: VecDeque::new(),
|
||||||
|
max_samples: 1000, // Default for display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, sample: Sample) {
|
||||||
|
self.samples.push_back(sample);
|
||||||
|
if self.samples.len() > self.max_samples {
|
||||||
|
self.samples.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SessionState {
|
||||||
|
pub connected: bool,
|
||||||
|
pub signals: HashMap<SignalId, Arc<Mutex<SignalHistory>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
connected: false,
|
||||||
|
signals: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rmon_common::signal::SourceType;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_signal_history_push() {
|
||||||
|
let info = SignalInfo {
|
||||||
|
id: "test://1".to_string(),
|
||||||
|
label: "L".to_string(),
|
||||||
|
unit: "U".to_string(),
|
||||||
|
scale: 1.0,
|
||||||
|
offset: 0.0,
|
||||||
|
sampling_period: None,
|
||||||
|
source_type: SourceType::Csv,
|
||||||
|
path: vec![],
|
||||||
|
};
|
||||||
|
let mut history = SignalHistory::new(info);
|
||||||
|
history.max_samples = 2;
|
||||||
|
|
||||||
|
history.push(Sample { timestamp: 1, value: 1.0 });
|
||||||
|
history.push(Sample { timestamp: 2, value: 2.0 });
|
||||||
|
history.push(Sample { timestamp: 3, value: 3.0 });
|
||||||
|
|
||||||
|
assert_eq!(history.samples.len(), 2);
|
||||||
|
assert_eq!(history.samples[0].timestamp, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_state_init() {
|
||||||
|
let state = SessionState::new();
|
||||||
|
assert!(!state.connected);
|
||||||
|
assert!(state.signals.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// Placeholder for UI components
|
||||||
Reference in New Issue
Block a user