Version: 0.1.0 Last Updated: October 2025
- System Overview
- Technology Stack
- High-Level Architecture
- Component Breakdown
- Data Flow
- Security Model
- Process Lifecycle
- State Management
- Error Handling
- Performance Considerations
- Design Decisions
Sentinel is a cross-platform desktop application built using Tauri 2.0, combining a Rust backend for system-level operations with a Svelte frontend for the user interface. The architecture prioritizes:
- Security: Memory-safe Rust, minimal permissions, input validation
- Performance: Native speed, low memory footprint (<50MB idle)
- Modularity: Clear separation of concerns, testable components
- Cross-platform: Single codebase for macOS, Linux, Windows
- Backend Does Heavy Lifting: Process management and system monitoring happen in Rust
- Frontend Stays Thin: Svelte UI focuses on rendering and user interaction
- Clear API Boundary: Tauri commands define a strict interface
- Fail-Safe: Errors are handled gracefully, never crash the app
| Layer | Technology | Version | Purpose |
|---|---|---|---|
| Desktop Framework | Tauri | 2.0 | Cross-platform app shell |
| Backend | Rust | 1.88+ | Process management, system monitoring |
| Frontend | Svelte | 5.0 | Reactive UI |
| Async Runtime | Tokio | 1.35+ | Async process handling |
| System Monitoring | sysinfo crate | 0.37 | CPU/RAM/disk metrics |
| Styling | TailwindCSS | 3.4 | Utility-first CSS |
| Build Tool | Vite | 6.0 | Fast frontend bundling |
Rust:
serde/serde_json/serde_yaml- Serializationanyhow/thiserror- Error handlingtracing- Loggingsubprocess- Process spawning
JavaScript:
@tauri-apps/api- Tauri bindings@testing-library/svelte- Component testingvitest- Test runner
┌─────────────────────────────────────────────────────────────────┐
│ Sentinel Desktop App │
│ │
│ ┌──────────────────────┐ ┌───────────────────────┐ │
│ │ Frontend (Svelte) │ │ Backend (Rust) │ │
│ │ │◄────────┤ │ │
│ │ Components: │ Tauri │ Modules: │ │
│ │ - ProcessList │ IPC │ - ProcessManager │ │
│ │ - SystemMonitor │ (JSON) │ - SystemMonitor │ │
│ │ - LogViewer │ │ - ConfigParser │ │
│ │ - Settings │ │ - LogAggregator │ │
│ │ │ │ │ │
│ │ Stores: │ │ State: │ │
│ │ - processes.js │ │ - AppState (Mutex) │ │
│ │ - systemStats.js │ │ │ │
│ └──────────────────────┘ └───────────────────────┘ │
│ ▲ │ │
│ │ ▼ │
│ │ ┌───────────────────────┐ │
│ │ │ OS Layer │ │
│ └────────────────────────┤ - Process API │ │
│ Events │ - sysinfo (metrics) │ │
│ │ - File System │ │
│ └───────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
External Components:
┌───────────────┐ ┌─────────────────┐ ┌──────────────┐
│ CLI Tool │────►│ Config File │ │ System Tray │
│ (sentinel) │ │ (sentinel.yaml) │ │ Integration │
└───────────────┘ └─────────────────┘ └──────────────┘
Location: src/
Responsibilities:
- Render UI components
- Handle user interactions
- Display real-time data (graphs, logs)
- Manage local UI state
- Invoke Tauri commands
Key Components:
| Component | File | Purpose |
|---|---|---|
| ProcessList | lib/components/process-list.svelte |
Display running processes with controls |
| ProcessCard | lib/components/process-card.svelte |
Individual process card with metrics |
| SystemMonitor | lib/components/system-monitor.svelte |
CPU/RAM/disk graphs |
| LogViewer | lib/components/log-viewer.svelte |
Combined log output with filtering |
| Settings | lib/components/settings.svelte |
App configuration UI |
State Management (Svelte Stores):
// stores/processes.js
import { writable } from 'svelte/store';
export const processes = writable([]);
export const systemStats = writable({
cpu: [],
memory: { used: 0, total: 0 },
disk: []
});Communication with Backend:
import { invoke } from '@tauri-apps/api/core';
// Call Rust function
const result = await invoke('start_process', { name: 'api-server' });
// Listen to events
import { listen } from '@tauri-apps/api/event';
listen('process-started', (event) => {
console.log('Process started:', event.payload);
});Location: src-tauri/src/
Responsibilities:
- Spawn and manage child processes
- Monitor system resources
- Parse and validate configuration files
- Aggregate logs from multiple processes
- Expose Tauri commands
Module Structure:
src-tauri/src/
├── main.rs # App entry point (minimal)
├── lib.rs # Public API exports
├── commands/ # Tauri command handlers
│ ├── mod.rs
│ ├── process.rs # start_process, stop_process, etc.
│ └── system.rs # get_system_stats
├── core/ # Business logic
│ ├── mod.rs
│ ├── process_manager.rs # ProcessManager struct
│ ├── system_monitor.rs # SystemMonitor struct
│ ├── config.rs # Config parsing
│ └── logger.rs # Log aggregation
├── models/ # Data structures
│ ├── mod.rs
│ ├── process.rs # ProcessInfo, ProcessState
│ ├── config.rs # ProcessConfig
│ └── system.rs # SystemStats
├── state.rs # AppState (shared state)
└── error.rs # SentinelError enum
Core Structs:
// AppState: Global application state (thread-safe)
pub struct AppState {
processes: Arc<Mutex<HashMap<String, ProcessInfo>>>,
config: Arc<RwLock<Config>>,
system: Arc<Mutex<System>>, // sysinfo::System
}
// ProcessManager: Handles process lifecycle
pub struct ProcessManager {
processes: HashMap<String, ProcessHandle>,
config: Config,
}
// SystemMonitor: Collects system metrics
pub struct SystemMonitor {
system: System, // sysinfo::System
update_interval: Duration,
}Location: src-tauri/src/commands/
Tauri commands are the public API exposed to the frontend. They act as a thin layer that:
- Validates input
- Calls core business logic
- Formats responses
- Handles errors
Example Command:
#[tauri::command]
pub async fn start_process(
name: String,
state: State<'_, AppState>
) -> Result<ProcessInfo, String> {
// Validate input
if name.is_empty() {
return Err("Process name cannot be empty".into());
}
// Get process manager from state
let mut processes = state.processes.lock().await;
// Call core logic
let info = processes.start(&name)
.map_err(|e| e.to_string())?;
Ok(info)
}Available Commands:
| Command | Parameters | Returns | Description |
|---|---|---|---|
start_process |
name: String |
ProcessInfo |
Start a process by name |
stop_process |
name: String |
() |
Stop a running process |
restart_process |
name: String |
ProcessInfo |
Restart a process |
list_processes |
- | Vec<ProcessInfo> |
Get all processes |
get_process |
name: String |
ProcessInfo |
Get single process info |
get_system_stats |
- | SystemStats |
Get CPU/RAM/disk metrics |
load_config |
path: String |
Config |
Load config from file |
save_config |
config: Config |
() |
Save config to file |
Location: src-tauri/src/core/process_manager.rs
Responsibilities:
- Spawn processes using
tokio::process::Command - Track process state (running, stopped, crashed)
- Capture stdout/stderr streams
- Implement auto-restart with exponential backoff
- Handle graceful shutdown
Process States:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ProcessState {
Stopped,
Starting,
Running,
Stopping,
Crashed { exit_code: i32 },
Failed { reason: String },
}Auto-Restart Logic:
async fn monitor_process(mut child: Child, config: ProcessConfig) {
let status = child.wait().await;
match status {
Ok(exit_status) if !exit_status.success() => {
// Process crashed
if should_restart(&config) {
let delay = calculate_backoff(config.restart_count);
sleep(delay).await;
spawn_process(config).await;
}
}
Err(e) => {
error!("Process monitoring error: {}", e);
}
_ => {}
}
}Location: src-tauri/src/core/system_monitor.rs
Responsibilities:
- Collect system metrics using
sysinfocrate - Track per-process resource usage
- Provide real-time updates to frontend
- Minimize overhead (<5% CPU)
Metrics Collected:
- CPU: Per-core usage, overall usage, per-process usage
- Memory: Total RAM, used RAM, swap, per-process memory
- Disk: Read/write bytes per second, per-disk metrics
Update Strategy:
impl SystemMonitor {
pub fn new() -> Self {
let mut system = System::new_all();
system.refresh_all();
Self { system }
}
pub fn update(&mut self) {
// Selective refresh for better performance
self.system.refresh_cpu();
self.system.refresh_memory();
self.system.refresh_processes();
}
pub fn get_stats(&self) -> SystemStats {
SystemStats {
cpu_usage: self.system.global_cpu_usage(),
memory: MemoryStats {
total: self.system.total_memory(),
used: self.system.used_memory(),
available: self.system.available_memory(),
},
// ...
}
}
}Location: src-tauri/src/core/config.rs
Responsibilities:
- Parse YAML/JSON configuration files
- Validate configuration schema
- Provide default values
- Support environment variable expansion
Config Structure:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub processes: Vec<ProcessConfig>,
pub settings: GlobalSettings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessConfig {
pub name: String,
pub command: String,
pub cwd: Option<PathBuf>,
pub env: HashMap<String, String>,
pub auto_restart: bool,
pub restart_limit: u32,
pub restart_delay: u64, // milliseconds
pub depends_on: Vec<String>,
}Validation:
pub fn validate_config(config: &Config) -> Result<()> {
// Check for duplicate names
let mut names = HashSet::new();
for process in &config.processes {
if !names.insert(&process.name) {
return Err(anyhow!("Duplicate process name: {}", process.name));
}
}
// Validate dependencies
for process in &config.processes {
for dep in &process.depends_on {
if !names.contains(dep) {
return Err(anyhow!("Unknown dependency: {}", dep));
}
}
}
Ok(())
}User clicks "Start" button in UI
│
▼
Svelte component calls invoke('start_process', { name })
│
▼
Tauri IPC layer serializes request → sends to Rust
│
▼
Rust command handler (commands/process.rs)
- Validates input
- Acquires lock on AppState.processes
│
▼
ProcessManager.start(name)
- Reads config for process
- Spawns tokio::process::Command
- Captures stdout/stderr streams
- Returns ProcessInfo
│
▼
Command handler emits event: 'process-started'
│
▼
Svelte component receives event via listen()
- Updates processes store
- Re-renders UI
│
▼
SystemMonitor starts tracking process (PID-based)
│
▼
UI displays process as "Running" with live metrics
Backend → Frontend Events:
// In Rust (emit events)
use tauri::Manager;
app.emit("process-started", ProcessInfo { name, pid, ... })?;
app.emit("process-crashed", ProcessCrash { name, exit_code })?;
app.emit("system-stats", SystemStats { cpu, memory, ... })?;// In Svelte (listen to events)
import { listen } from '@tauri-apps/api/event';
onMount(() => {
const unlisten = listen('process-started', (event) => {
processes.update(list => [...list, event.payload]);
});
return () => unlisten();
});Configuration: src-tauri/tauri.conf.json
{
"permissions": {
"fs": {
"scope": ["$APPCONFIG/sentinel/**", "$APPDATA/sentinel/**"],
"deny": ["$HOME/**", "/etc/**"]
},
"shell": {
"scope": {
"allowed": [
{ "name": "node", "args": true },
{ "name": "npm", "args": true },
{ "name": "cargo", "args": true }
]
}
}
}
}All Tauri commands validate inputs:
fn validate_process_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(anyhow!("Process name cannot be empty"));
}
if !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return Err(anyhow!("Invalid characters in process name"));
}
if name.len() > 64 {
return Err(anyhow!("Process name too long"));
}
Ok(())
}Sentinel runs with normal user permissions. No sudo required.
┌─────────┐
│ Stopped │
└────┬────┘
│ start()
▼
┌──────────┐
│ Starting │
└────┬─────┘
│ spawned
▼
┌─────────┐ stop() ┌──────────┐
│ Running │────────────────►│ Stopping │
└────┬────┘ └────┬─────┘
│ │
│ crashed │ exited
▼ ▼
┌─────────┐ ┌─────────┐
│ Crashed │ │ Stopped │
└────┬────┘ └─────────┘
│
│ auto_restart = true
▼
┌──────────────┐
│ Restarting │
│ (backoff) │
└──────────────┘
Shared across all Tauri commands:
pub struct AppState {
processes: Arc<Mutex<HashMap<String, ProcessInfo>>>,
config: Arc<RwLock<Config>>,
system: Arc<Mutex<System>>,
}Arc<Mutex<T>>- Thread-safe shared ownership with exclusive accessArc<RwLock<T>>- Thread-safe with multiple readers or one writer
Reactive stores:
// processes store
export const processes = writable([]);
// Derived store (auto-computed)
export const runningProcesses = derived(
processes,
$processes => $processes.filter(p => p.state === 'Running')
);
// Usage in components
$: cpuUsage = $processes.reduce((sum, p) => sum + p.cpu, 0);#[derive(Debug, thiserror::Error)]
pub enum SentinelError {
#[error("Process '{name}' not found")]
ProcessNotFound { name: String },
#[error("Failed to spawn process: {source}")]
SpawnFailed {
#[from]
source: std::io::Error,
},
#[error("Invalid configuration: {reason}")]
InvalidConfig { reason: String },
}#[tauri::command]
fn risky_operation() -> Result<String, String> {
do_something()
.map_err(|e| e.to_string()) // Convert to String for Tauri
}- Target: <50MB idle, <200MB with 10+ processes
- Strategy:
- Reuse
sysinfo::Systeminstance (avoid re-allocation) - Limit log buffer size (circular buffer)
- Use
Arcfor shared data (avoid cloning)
- Reuse
- Target: <5% CPU for monitoring
- Strategy:
- Poll system metrics every 1-2 seconds (not 60fps)
- Use selective refresh (
refresh_cpu()vsrefresh_all()) - Offload heavy work to background threads
- Target: <500ms cold start
- Strategy:
- Tauri (native WebView) vs Electron (bundled Chromium)
- Lazy load config (only when needed)
- Minimal dependencies
| Reason | Impact |
|---|---|
| Bundle size | 3-10MB vs 80-120MB |
| Memory usage | 30-40MB vs 100+MB |
| Security | Granular permissions, no Node.js in frontend |
| Performance | Native WebView, faster startup |
| Reason | Impact |
|---|---|
| Memory safety | No segfaults, no data races |
| Performance | Native speed, no GC pauses |
| Ecosystem | sysinfo, tokio battle-tested |
| Type safety | Catch errors at compile time |
| Reason | Impact |
|---|---|
| Bundle size | 1.6KB runtime vs React 40KB |
| Reactivity | Built-in, no hooks boilerplate |
| Performance | Compiled, no virtual DOM |
| Developer experience | Less code, clearer intent |
PM2 is excellent but:
- Node.js-only (Sentinel is language-agnostic)
- CLI-focused (Sentinel has GUI)
- No system monitoring (Sentinel combines both)
- Plugin System: Allow custom process handlers and monitors
- Historical Data: Store metrics in SQLite for trends
- Network Monitoring: Track network I/O per process
- Distributed Mode: Manage processes across multiple machines
End of Architecture Document
For implementation details, see: