feat: initialize SysPulse-rs profiler project with Tauri v2 and React
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# Node.js
|
||||
node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
*.local
|
||||
.npm
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
.node_repl_history
|
||||
package-lock.json
|
||||
|
||||
# Rust
|
||||
target/
|
||||
src-tauri/target/
|
||||
Cargo.lock
|
||||
src-tauri/Cargo.lock
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Tauri v2
|
||||
src-tauri/gen/
|
||||
src-tauri/target/
|
||||
src-tauri/icons/*.ico
|
||||
src-tauri/icons/*.icns
|
||||
src-tauri/icons/*.png
|
||||
!src-tauri/icons/icon.png
|
||||
!src-tauri/icons/32x32.png
|
||||
!src-tauri/icons/128x128.png
|
||||
!src-tauri/icons/128x128@2x.png
|
||||
|
||||
# Reports (SysPulse specific)
|
||||
syspulse_report_*.json
|
||||
79
README.md
Normal file
79
README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# ⚡ SysPulse
|
||||
|
||||
A professional, high-performance Linux system profiler built with **Rust** and **Tauri v2**. Monitor your Wayland system's performance, identify resource-hungry processes, and generate detailed profiling reports with a beautiful **Catppuccin Mocha** interface.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
1. **Live Dashboard**: Monitor real-time CPU and Memory load. Use the "Hide SysPulse" toggle to exclude the profiler's own overhead from the results.
|
||||
2. **Recording**: Click **Record Profile** to start a session. The app automatically switches to a **Minimal Footprint Mode** to ensure the most accurate results by reducing UI overhead.
|
||||
3. **Analysis**: Stop the recording to view a comprehensive **Profiling Report**.
|
||||
4. **Inspection**: Click any process in the report matrix to open the **Process Inspector**, showing a dedicated time-series graph of that application's resource consumption throughout the session.
|
||||
5. **Admin Control**: Hover over any process and click the **Shield** icon to terminate it (uses `pkexec` for secure sudo authentication).
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
- **Backend**: Rust (Tauri v2)
|
||||
- Uses the `sysinfo` crate for low-level system data retrieval.
|
||||
- Implements an asynchronous snapshot system to capture performance data without blocking.
|
||||
- Provides secure process management via Linux `pkexec`.
|
||||
- **Frontend**: React + TypeScript + Tailwind CSS
|
||||
- **Recharts**: For high-performance time-series visualization.
|
||||
- **Lucide-React**: For a clean, modern icon system.
|
||||
- **Framer Motion**: For smooth, native-feeling transitions.
|
||||
- **Styling**: Catppuccin Mocha theme for a professional, low-strain developer experience.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Ensure you have the following system dependencies installed (Ubuntu/Debian example):
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
```
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository**:
|
||||
```bash
|
||||
git clone https://github.com/your-username/syspulse-rs.git
|
||||
cd syspulse-rs
|
||||
```
|
||||
|
||||
2. **Install Node dependencies**:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Start Development Mode**:
|
||||
```bash
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Build Instructions
|
||||
|
||||
To generate a production-ready binary and system packages (Debian, RPM, AppImage):
|
||||
|
||||
```bash
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
The output will be located in:
|
||||
`src-tauri/target/release/bundle/`
|
||||
|
||||
*Note: Building an AppImage requires `appimagetool` and `squashfs-tools` to be installed on your system.*
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ License
|
||||
|
||||
This project is open-source. See the [LICENSE](LICENSE) file for details.
|
||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SysPulse</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "syspulse",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.1.0",
|
||||
"lucide-react": "^0.300.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"recharts": "^2.10.0",
|
||||
"framer-motion": "^10.16.0",
|
||||
"clsx": "^2.0.0",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"typescript": "^5.2.0",
|
||||
"vite": "^5.0.0",
|
||||
"@tauri-apps/cli": "2.1.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
19
src-tauri/Cargo.toml
Normal file
19
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "syspulse-rs"
|
||||
version = "0.1.0"
|
||||
description = "A professional Linux system profiler"
|
||||
authors = ["narl"]
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sysinfo = "0.30"
|
||||
chrono = "0.4"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.10"
|
||||
3
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
BIN
src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
BIN
src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/icon.png
Normal file
BIN
src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
343
src-tauri/src/main.rs
Normal file
343
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,343 @@
|
||||
#![cfg_attr(
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
use sysinfo::System;
|
||||
use std::sync::Mutex;
|
||||
use std::process::Command;
|
||||
use tauri::State;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::collections::HashMap;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
// --- Data Structures ---
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct SystemStats {
|
||||
cpu_usage: Vec<f32>,
|
||||
total_memory: u64,
|
||||
used_memory: u64,
|
||||
processes: Vec<ProcessStats>,
|
||||
is_recording: bool,
|
||||
recording_duration: u64, // seconds
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
struct ProcessStats {
|
||||
pid: u32,
|
||||
name: String,
|
||||
cpu_usage: f32,
|
||||
memory: u64,
|
||||
status: String,
|
||||
user_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Snapshot {
|
||||
timestamp: DateTime<Utc>,
|
||||
cpu_usage: Vec<f32>,
|
||||
used_memory: u64,
|
||||
processes: Vec<ProcessStats>,
|
||||
}
|
||||
|
||||
struct ProfilingSession {
|
||||
is_active: bool,
|
||||
start_time: Option<DateTime<Utc>>,
|
||||
snapshots: Vec<Snapshot>,
|
||||
}
|
||||
|
||||
struct AppState {
|
||||
sys: Mutex<System>,
|
||||
profiling: Mutex<ProfilingSession>,
|
||||
}
|
||||
|
||||
// --- Report Structures ---
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Report {
|
||||
start_time: String,
|
||||
end_time: String,
|
||||
duration_seconds: i64,
|
||||
timeline: Vec<TimelinePoint>,
|
||||
aggregated_processes: Vec<AggregatedProcess>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct TimelinePoint {
|
||||
time: String,
|
||||
avg_cpu: f32,
|
||||
memory_gb: f32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ProcessHistoryPoint {
|
||||
time: String,
|
||||
cpu_usage: f32,
|
||||
memory_mb: f32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct AggregatedProcess {
|
||||
name: String,
|
||||
avg_cpu: f32,
|
||||
peak_cpu: f32,
|
||||
avg_memory_mb: f32,
|
||||
peak_memory_mb: f32,
|
||||
instance_count: usize,
|
||||
warnings: Vec<String>,
|
||||
history: Vec<ProcessHistoryPoint>,
|
||||
}
|
||||
|
||||
// --- Commands ---
|
||||
|
||||
#[tauri::command]
|
||||
fn get_system_stats(
|
||||
state: State<AppState>,
|
||||
exclude_self: bool,
|
||||
minimal: bool
|
||||
) -> SystemStats {
|
||||
let mut sys = state.sys.lock().unwrap();
|
||||
let mut profiling = state.profiling.lock().unwrap();
|
||||
|
||||
if minimal {
|
||||
sys.refresh_cpu();
|
||||
sys.refresh_memory();
|
||||
} else {
|
||||
sys.refresh_all();
|
||||
}
|
||||
|
||||
let self_pid = std::process::id();
|
||||
let cpu_usage: Vec<f32> = sys.cpus().iter().map(|cpu| cpu.cpu_usage()).collect();
|
||||
let total_memory = sys.total_memory();
|
||||
let used_memory = sys.used_memory();
|
||||
|
||||
let mut processes: Vec<ProcessStats> = if minimal && !profiling.is_active {
|
||||
Vec::new()
|
||||
} else {
|
||||
sys.processes().iter()
|
||||
.filter(|(pid, _)| !exclude_self || pid.as_u32() != self_pid)
|
||||
.map(|(pid, process)| {
|
||||
ProcessStats {
|
||||
pid: pid.as_u32(),
|
||||
name: process.name().to_string(),
|
||||
cpu_usage: process.cpu_usage(),
|
||||
memory: process.memory(),
|
||||
status: format!("{:?}", process.status()),
|
||||
user_id: process.user_id().map(|uid| uid.to_string()),
|
||||
}
|
||||
}).collect()
|
||||
};
|
||||
|
||||
if profiling.is_active {
|
||||
// Even in minimal mode, if recording we need the processes for the report
|
||||
if minimal {
|
||||
sys.refresh_processes();
|
||||
processes = sys.processes().iter()
|
||||
.filter(|(pid, _)| !exclude_self || pid.as_u32() != self_pid)
|
||||
.map(|(pid, process)| {
|
||||
ProcessStats {
|
||||
pid: pid.as_u32(),
|
||||
name: process.name().to_string(),
|
||||
cpu_usage: process.cpu_usage(),
|
||||
memory: process.memory(),
|
||||
status: format!("{:?}", process.status()),
|
||||
user_id: process.user_id().map(|uid| uid.to_string()),
|
||||
}
|
||||
}).collect();
|
||||
}
|
||||
|
||||
profiling.snapshots.push(Snapshot {
|
||||
timestamp: Utc::now(),
|
||||
cpu_usage: cpu_usage.clone(),
|
||||
used_memory,
|
||||
processes: processes.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let recording_duration = if let Some(start) = profiling.start_time {
|
||||
(Utc::now() - start).num_seconds() as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if !minimal {
|
||||
processes.sort_by(|a, b| b.cpu_usage.partial_cmp(&a.cpu_usage).unwrap_or(std::cmp::Ordering::Equal));
|
||||
processes.truncate(50);
|
||||
} else if !profiling.is_active {
|
||||
processes.clear();
|
||||
}
|
||||
|
||||
SystemStats {
|
||||
cpu_usage,
|
||||
total_memory,
|
||||
used_memory,
|
||||
processes,
|
||||
is_recording: profiling.is_active,
|
||||
recording_duration,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_report(report: Report) -> Result<String, String> {
|
||||
let json = serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?;
|
||||
let path = format!("syspulse_report_{}.json", Utc::now().format("%Y%m%d_%H%M%S"));
|
||||
std::fs::write(&path, json).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_profiling(state: State<AppState>) {
|
||||
let mut profiling = state.profiling.lock().unwrap();
|
||||
profiling.is_active = true;
|
||||
profiling.start_time = Some(Utc::now());
|
||||
profiling.snapshots.clear();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_profiling(state: State<AppState>) -> Report {
|
||||
let mut profiling = state.profiling.lock().unwrap();
|
||||
profiling.is_active = false;
|
||||
|
||||
let start = profiling.start_time.unwrap_or(Utc::now());
|
||||
let end = Utc::now();
|
||||
let duration = (end - start).num_seconds();
|
||||
|
||||
// 1. Generate Session Timeline
|
||||
let timeline: Vec<TimelinePoint> = profiling.snapshots.iter().map(|s| {
|
||||
let avg_cpu = s.cpu_usage.iter().sum::<f32>() / s.cpu_usage.len() as f32;
|
||||
TimelinePoint {
|
||||
time: s.timestamp.format("%H:%M:%S").to_string(),
|
||||
avg_cpu,
|
||||
memory_gb: s.used_memory as f32 / 1024.0 / 1024.0 / 1024.0,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
// 2. Aggregate Processes over Time
|
||||
let mut process_map: HashMap<String, Vec<ProcessHistoryPoint>> = HashMap::new();
|
||||
let mut peak_stats: HashMap<String, (f32, f32)> = HashMap::new(); // (Peak CPU, Peak Mem)
|
||||
let mut unique_pids: HashMap<String, std::collections::HashSet<u32>> = HashMap::new();
|
||||
let mut status_flags: HashMap<String, bool> = HashMap::new(); // Zombie check
|
||||
|
||||
for snapshot in &profiling.snapshots {
|
||||
let mut snapshot_procs: HashMap<String, (f32, u64)> = HashMap::new();
|
||||
|
||||
for proc in &snapshot.processes {
|
||||
let entry = snapshot_procs.entry(proc.name.clone()).or_default();
|
||||
entry.0 += proc.cpu_usage;
|
||||
entry.1 += proc.memory;
|
||||
|
||||
unique_pids.entry(proc.name.clone()).or_default().insert(proc.pid);
|
||||
if proc.status.contains("Zombie") {
|
||||
status_flags.insert(proc.name.clone(), true);
|
||||
}
|
||||
}
|
||||
|
||||
// Record history for all processes seen in this snapshot
|
||||
for (name, (cpu, mem)) in snapshot_procs {
|
||||
let hist_entry = process_map.entry(name.clone()).or_default();
|
||||
let mem_mb = mem as f32 / 1024.0 / 1024.0;
|
||||
|
||||
hist_entry.push(ProcessHistoryPoint {
|
||||
time: snapshot.timestamp.format("%H:%M:%S").to_string(),
|
||||
cpu_usage: cpu,
|
||||
memory_mb: mem_mb,
|
||||
});
|
||||
|
||||
let peaks = peak_stats.entry(name).or_insert((0.0, 0.0));
|
||||
if cpu > peaks.0 { peaks.0 = cpu; }
|
||||
if mem_mb > peaks.1 { peaks.1 = mem_mb; }
|
||||
}
|
||||
}
|
||||
|
||||
let mut aggregated_processes: Vec<AggregatedProcess> = Vec::new();
|
||||
let num_snapshots = profiling.snapshots.len() as f32;
|
||||
|
||||
for (name, history) in process_map {
|
||||
let (peak_cpu, peak_mem) = peak_stats.get(&name).cloned().unwrap_or((0.0, 0.0));
|
||||
let count = unique_pids.get(&name).map(|s| s.len()).unwrap_or(0);
|
||||
|
||||
// Average over the whole SESSION (zeros for snapshots where not present)
|
||||
let total_cpu_sum: f32 = history.iter().map(|h| h.cpu_usage).sum();
|
||||
let total_mem_sum: f32 = history.iter().map(|h| h.memory_mb).sum();
|
||||
|
||||
let avg_cpu = if num_snapshots > 0.0 { total_cpu_sum / num_snapshots } else { 0.0 };
|
||||
let avg_mem = if num_snapshots > 0.0 { total_mem_sum / num_snapshots } else { 0.0 };
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
if status_flags.get(&name).cloned().unwrap_or(false) {
|
||||
warnings.push("Zombie Process Detected".to_string());
|
||||
}
|
||||
if peak_cpu > 80.0 {
|
||||
warnings.push("High Peak Load".to_string());
|
||||
}
|
||||
if peak_mem > 2048.0 {
|
||||
warnings.push("Heavy Memory usage".to_string());
|
||||
}
|
||||
|
||||
aggregated_processes.push(AggregatedProcess {
|
||||
name,
|
||||
avg_cpu,
|
||||
peak_cpu,
|
||||
avg_memory_mb: avg_mem,
|
||||
peak_memory_mb: peak_mem,
|
||||
instance_count: count,
|
||||
warnings,
|
||||
history,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by Average CPU descending
|
||||
aggregated_processes.sort_by(|a, b| b.avg_cpu.partial_cmp(&a.avg_cpu).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
Report {
|
||||
start_time: start.to_rfc3339(),
|
||||
end_time: end.to_rfc3339(),
|
||||
duration_seconds: duration,
|
||||
timeline,
|
||||
aggregated_processes,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn run_as_admin(command: String) -> Result<String, String> {
|
||||
// Uses pkexec to run a command as root.
|
||||
// CAUTION: This is a simple implementation. In production, validate inputs carefully.
|
||||
// Splitting command for safety is hard, so we assume 'command' is a simple executable name or safe string.
|
||||
|
||||
// Example usage from frontend: "kill -9 1234" -> pkexec kill -9 1234
|
||||
|
||||
let output = Command::new("pkexec")
|
||||
.arg("sh")
|
||||
.arg("-c")
|
||||
.arg(&command)
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.manage(AppState {
|
||||
sys: Mutex::new(System::new_all()),
|
||||
profiling: Mutex::new(ProfilingSession {
|
||||
is_active: false,
|
||||
start_time: None,
|
||||
snapshots: Vec::new(),
|
||||
})
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_system_stats,
|
||||
start_profiling,
|
||||
stop_profiling,
|
||||
run_as_admin,
|
||||
save_report
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
37
src-tauri/tauri.conf.json
Normal file
37
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"productName": "syspulse",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.syspulse.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "SysPulse",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"decorations": false,
|
||||
"transparent": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
536
src/App.tsx
Normal file
536
src/App.tsx
Normal file
@@ -0,0 +1,536 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
CartesianGrid
|
||||
} from 'recharts';
|
||||
import {
|
||||
Activity, Cpu, Server, Database, Play, Square,
|
||||
AlertTriangle, ArrowLeft, Shield, CheckSquare, Square as SquareIcon, Save, X
|
||||
} from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface ProcessStats {
|
||||
pid: number;
|
||||
name: string;
|
||||
cpu_usage: number;
|
||||
memory: number;
|
||||
status: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
interface SystemStats {
|
||||
cpu_usage: number[];
|
||||
total_memory: number;
|
||||
used_memory: number;
|
||||
processes: ProcessStats[];
|
||||
is_recording: boolean;
|
||||
recording_duration: number;
|
||||
}
|
||||
|
||||
interface TimelinePoint {
|
||||
time: string;
|
||||
avg_cpu: number;
|
||||
memory_gb: number;
|
||||
}
|
||||
|
||||
interface ProcessHistoryPoint {
|
||||
time: string;
|
||||
cpu_usage: number;
|
||||
memory_mb: number;
|
||||
}
|
||||
|
||||
interface AggregatedProcess {
|
||||
name: string;
|
||||
avg_cpu: number;
|
||||
peak_cpu: number;
|
||||
avg_memory_mb: number;
|
||||
peak_memory_mb: number;
|
||||
instance_count: number;
|
||||
warnings: string[];
|
||||
history: ProcessHistoryPoint[];
|
||||
}
|
||||
|
||||
interface ProfilingReport {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
duration_seconds: number;
|
||||
timeline: TimelinePoint[];
|
||||
aggregated_processes: AggregatedProcess[];
|
||||
}
|
||||
|
||||
function cn(...inputs: (string | undefined | null | false)[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [view, setView] = useState<'dashboard' | 'report'>('dashboard');
|
||||
const [stats, setStats] = useState<SystemStats | null>(null);
|
||||
const [history, setHistory] = useState<{ time: string; cpu: number }[]>([]);
|
||||
const [excludeSelf, setExcludeSelf] = useState(true);
|
||||
const [report, setReport] = useState<ProfilingReport | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const isRecording = stats?.is_recording ?? false;
|
||||
const data = await invoke<SystemStats>('get_system_stats', {
|
||||
excludeSelf,
|
||||
minimal: isRecording || view === 'report'
|
||||
});
|
||||
setStats(data);
|
||||
|
||||
const avgCpu = data.cpu_usage.reduce((a, b) => a + b, 0) / data.cpu_usage.length;
|
||||
|
||||
setHistory(prev => {
|
||||
const newHistory = [...prev, { time: new Date().toLocaleTimeString(), cpu: avgCpu }];
|
||||
if (newHistory.length > 60) newHistory.shift();
|
||||
return newHistory;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchStats, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [excludeSelf, view, stats?.is_recording]);
|
||||
|
||||
const toggleRecording = async () => {
|
||||
if (stats?.is_recording) {
|
||||
const reportData = await invoke<ProfilingReport>('stop_profiling');
|
||||
setReport(reportData);
|
||||
setView('report');
|
||||
} else {
|
||||
await invoke('start_profiling');
|
||||
}
|
||||
};
|
||||
|
||||
const killProcess = async (pid: number) => {
|
||||
try {
|
||||
await invoke('run_as_admin', { command: `kill -9 ${pid}` });
|
||||
} catch (e) {
|
||||
alert(`Failed to kill process: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (view === 'report' && report) {
|
||||
return (
|
||||
<ReportView
|
||||
report={report}
|
||||
onBack={() => setView('dashboard')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) return <div className="h-screen w-screen flex items-center justify-center text-text bg-base">Loading System Data...</div>;
|
||||
|
||||
const avgCpu = stats.cpu_usage.reduce((a, b) => a + b, 0) / stats.cpu_usage.length;
|
||||
const memoryPercent = (stats.used_memory / stats.total_memory) * 100;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-base text-text">
|
||||
<div data-tauri-drag-region className="titlebar shrink-0">
|
||||
<div className="titlebar-drag gap-2 px-4" data-tauri-drag-region>
|
||||
<Activity size={16} className="text-mauve" />
|
||||
<span className="font-bold tracking-tight">SysPulse</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 px-4">
|
||||
<button
|
||||
className="flex items-center gap-2 text-subtext0 hover:text-text transition-colors text-xs"
|
||||
onClick={() => setExcludeSelf(!excludeSelf)}
|
||||
>
|
||||
{excludeSelf ? <CheckSquare size={14} className="text-blue" /> : <SquareIcon size={14} />}
|
||||
<span>Hide SysPulse</span>
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-1.5 rounded-full text-xs font-bold transition-all shadow-lg",
|
||||
stats.is_recording
|
||||
? "bg-red text-base animate-pulse"
|
||||
: "bg-green text-base hover:scale-105 active:scale-95"
|
||||
)}
|
||||
onClick={toggleRecording}
|
||||
>
|
||||
{stats.is_recording ? <Square size={12} fill="currentColor" /> : <Play size={12} fill="currentColor" />}
|
||||
{stats.is_recording ? `STOP (${formatDuration(stats.recording_duration)})` : "RECORD PROFILE"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-hidden flex flex-col p-6 gap-6">
|
||||
{stats.is_recording ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-8 animate-in fade-in zoom-in duration-500">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-red/20 blur-3xl rounded-full scale-150 animate-pulse" />
|
||||
<div className="relative bg-surface0 p-12 rounded-full border-4 border-red/30 shadow-2xl">
|
||||
<Activity size={64} className="text-red animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-3xl font-black text-text tracking-tighter uppercase italic">Profiling Active</h2>
|
||||
<p className="text-subtext1 font-mono text-xl">{formatDuration(stats.recording_duration)}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 w-full max-w-md">
|
||||
<div className="bg-surface0 p-4 rounded-2xl border border-surface1">
|
||||
<div className="text-xs font-bold text-subtext0 mb-1 uppercase tracking-widest">CPU</div>
|
||||
<div className="text-2xl font-black text-blue">{avgCpu.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="bg-surface0 p-4 rounded-2xl border border-surface1">
|
||||
<div className="text-xs font-bold text-subtext0 mb-1 uppercase tracking-widest">RAM</div>
|
||||
<div className="text-2xl font-black text-mauve">{memoryPercent.toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-overlay1 text-sm italic">Minimal footprint mode enabled to ensure accurate results.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 shrink-0">
|
||||
<div className="card group">
|
||||
<div className="card-title"><Cpu size={16} className="text-blue" /> CPU Load</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="stat-value text-blue">{avgCpu.toFixed(1)}</span>
|
||||
<span className="text-subtext0 font-bold mb-2">%</span>
|
||||
</div>
|
||||
<div className="h-16 mt-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={history}>
|
||||
<defs>
|
||||
<linearGradient id="cpuDash" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--blue)" stopOpacity={0.5}/>
|
||||
<stop offset="95%" stopColor="var(--blue)" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area type="monotone" dataKey="cpu" stroke="var(--blue)" fill="url(#cpuDash)" strokeWidth={2} isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title"><Database size={16} className="text-mauve" /> Memory</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="stat-value text-mauve">{(stats.used_memory / 1024 / 1024 / 1024).toFixed(1)}</span>
|
||||
<span className="text-subtext0 font-bold mb-2">GB used</span>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="flex justify-between text-[10px] font-bold text-overlay2 uppercase mb-1">
|
||||
<span>{memoryPercent.toFixed(1)}%</span>
|
||||
<span>{(stats.total_memory / 1024 / 1024 / 1024).toFixed(0)} GB</span>
|
||||
</div>
|
||||
<div className="h-2 bg-surface1 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-mauve transition-all duration-700" style={{ width: `${memoryPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title"><Server size={16} className="text-green" /> Tasks</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="stat-value text-green">{stats.processes.length}</span>
|
||||
<span className="text-subtext0 font-bold mb-2">active</span>
|
||||
</div>
|
||||
<div className="mt-4 text-xs text-overlay2 font-medium leading-relaxed">
|
||||
Profiling will merge child processes into their parents for consolidated analysis.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card flex-1 flex flex-col min-h-0 overflow-hidden shadow-2xl border border-surface1/50">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-lg font-black tracking-tighter uppercase italic flex items-center gap-2">
|
||||
<Activity size={20} className="text-red" />
|
||||
Live Process Feed
|
||||
</h3>
|
||||
<div className="text-[10px] bg-surface1 px-2 py-1 rounded font-bold text-overlay2 uppercase">Top 50 Consumers</div>
|
||||
</div>
|
||||
|
||||
<div className="table-header grid grid-cols-[4fr_1fr_1.5fr_1.5fr_0.5fr] gap-4 px-4 py-3 bg-surface1/30 rounded-t-xl">
|
||||
<div>Name</div>
|
||||
<div>PID</div>
|
||||
<div>CPU</div>
|
||||
<div>Memory</div>
|
||||
<div className="text-center">Action</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 custom-scrollbar">
|
||||
{stats.processes.map((proc) => (
|
||||
<div key={proc.pid} className="table-row grid grid-cols-[4fr_1fr_1.5fr_1.5fr_0.5fr] gap-4 px-4 py-3 border-b border-surface1/30 group hover:bg-surface1/20 transition-all">
|
||||
<div className="font-bold text-text truncate text-sm" title={proc.name}>{proc.name}</div>
|
||||
<div className="font-mono text-overlay1 text-xs mt-1">{proc.pid}</div>
|
||||
<div className="text-blue font-black text-sm">{proc.cpu_usage.toFixed(1)}%</div>
|
||||
<div className="text-mauve font-black text-sm">{(proc.memory / 1024 / 1024).toFixed(0)} MB</div>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={() => killProcess(proc.pid)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-red/20 text-red rounded-lg transition-all"
|
||||
>
|
||||
<Shield size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SortField = 'name' | 'avg_cpu' | 'peak_cpu' | 'avg_memory_mb' | 'peak_memory_mb' | 'instance_count';
|
||||
|
||||
function ReportView({ report, onBack }: { report: ProfilingReport, onBack: () => void }) {
|
||||
const [sortField, setSortField] = useState<SortField>('avg_cpu');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [selectedProcess, setSelectedProcess] = useState<AggregatedProcess | null>(null);
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedProcesses = useMemo(() => {
|
||||
return [...report.aggregated_processes].sort((a, b) => {
|
||||
const valA = a[sortField as keyof AggregatedProcess];
|
||||
const valB = b[sortField as keyof AggregatedProcess];
|
||||
|
||||
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||
return sortOrder === 'asc' ? valA.localeCompare(valB) : valB.localeCompare(valA);
|
||||
}
|
||||
|
||||
const numA = (valA as number) ?? 0;
|
||||
const numB = (valB as number) ?? 0;
|
||||
|
||||
return sortOrder === 'asc' ? numA - numB : numB - numA;
|
||||
});
|
||||
}, [report, sortField, sortOrder]);
|
||||
|
||||
const saveReport = async () => {
|
||||
try {
|
||||
const path = await invoke<string>('save_report', { report });
|
||||
alert(`Report saved to: ${path}`);
|
||||
} catch (e) {
|
||||
alert(`Failed to save: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-base text-text">
|
||||
<div className="titlebar shrink-0">
|
||||
<div className="titlebar-drag gap-2 px-4">
|
||||
<Activity size={16} className="text-mauve" />
|
||||
<span className="font-bold uppercase italic tracking-tighter">Profiling Report</span>
|
||||
</div>
|
||||
<div className="flex gap-4 px-4">
|
||||
<button
|
||||
onClick={saveReport}
|
||||
className="flex items-center gap-2 px-3 py-1 bg-surface1 hover:bg-surface2 rounded text-xs font-bold transition-all text-text"
|
||||
>
|
||||
<Save size={14} className="text-blue" /> SAVE JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 px-3 py-1 bg-surface1 hover:bg-surface2 rounded text-xs font-bold transition-all text-text"
|
||||
>
|
||||
<ArrowLeft size={14} className="text-mauve" /> DASHBOARD
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-6 flex flex-col gap-6 custom-scrollbar relative">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 shrink-0">
|
||||
<div className="bg-surface0 p-6 rounded-2xl border border-surface1 shadow-xl text-center">
|
||||
<div className="text-[10px] font-black text-overlay2 uppercase tracking-widest mb-1">Session Time</div>
|
||||
<div className="text-2xl font-black text-text font-mono">{formatDuration(report.duration_seconds)}</div>
|
||||
</div>
|
||||
<div className="bg-surface0 p-6 rounded-2xl border border-surface1 shadow-xl text-center">
|
||||
<div className="text-[10px] font-black text-overlay2 uppercase tracking-widest mb-1">End of Session</div>
|
||||
<div className="text-xl font-bold text-text">{new Date(report.end_time).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
<div className="bg-surface0 p-6 rounded-2xl border border-surface1 shadow-xl text-center">
|
||||
<div className="text-[10px] font-black text-overlay2 uppercase tracking-widest mb-1">Uniques</div>
|
||||
<div className="text-2xl font-black text-blue">{report.aggregated_processes.length}</div>
|
||||
</div>
|
||||
<div className="bg-surface0 p-6 rounded-2xl border border-surface1 shadow-xl text-center">
|
||||
<div className="text-[10px] font-black text-overlay2 uppercase tracking-widest mb-1">Issue Alerts</div>
|
||||
<div className="text-2xl font-black text-red">
|
||||
{report.aggregated_processes.filter(p => p.warnings.length > 0).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card h-72 border border-surface1/50 shadow-2xl">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-sm font-black uppercase italic tracking-widest flex items-center gap-2">
|
||||
<Activity size={16} className="text-blue" /> Session Load Profile
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={report.timeline}>
|
||||
<defs>
|
||||
<linearGradient id="cpuReportGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--blue)" stopOpacity={0.6}/>
|
||||
<stop offset="95%" stopColor="var(--blue)" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--surface1)" vertical={false} opacity={0.5} />
|
||||
<XAxis dataKey="time" stroke="var(--subtext0)" tick={{fontSize: 9}} minTickGap={50} />
|
||||
<YAxis stroke="var(--subtext0)" tick={{fontSize: 9}} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--mantle)', border: '1px solid var(--surface1)', borderRadius: '12px', fontSize: '11px' }}
|
||||
itemStyle={{ color: 'var(--text)' }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="avg_cpu" name="CPU %" stroke="var(--blue)" fill="url(#cpuReportGradient)" strokeWidth={3} isAnimationActive={true} />
|
||||
<Area type="monotone" dataKey="memory_gb" name="MEM GB" stroke="var(--mauve)" fill="none" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card flex-1 flex flex-col min-h-0 border border-surface1/50 shadow-2xl overflow-hidden">
|
||||
<div className="flex justify-between items-center mb-6 px-2">
|
||||
<h3 className="text-lg font-black tracking-tighter uppercase italic flex items-center gap-2">
|
||||
<Server size={20} className="text-green" /> Analysis Matrix
|
||||
</h3>
|
||||
<span className="text-[10px] text-overlay1 font-bold">CLICK PROCESS TO INSPECT</span>
|
||||
</div>
|
||||
|
||||
<div className="table-header grid grid-cols-[2fr_0.8fr_1fr_1fr_1fr_1fr_2fr] gap-2 px-4 py-3 bg-surface1/30 rounded-t-xl font-bold uppercase tracking-widest text-[9px] text-overlay1">
|
||||
<div className="cursor-pointer hover:text-text transition-colors flex items-center gap-1" onClick={() => handleSort('name')}>
|
||||
Process {sortField === 'name' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
</div>
|
||||
<div className="cursor-pointer hover:text-text transition-colors" onClick={() => handleSort('instance_count')}>Units</div>
|
||||
<div className="cursor-pointer hover:text-text transition-colors" onClick={() => handleSort('avg_cpu')}>Avg CPU</div>
|
||||
<div className="cursor-pointer hover:text-text transition-colors" onClick={() => handleSort('peak_cpu')}>Peak CPU</div>
|
||||
<div className="cursor-pointer hover:text-text transition-colors" onClick={() => handleSort('avg_memory_mb')}>Avg Mem</div>
|
||||
<div className="cursor-pointer hover:text-text transition-colors" onClick={() => handleSort('peak_memory_mb')}>Peak Mem</div>
|
||||
<div>Insights</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 custom-scrollbar">
|
||||
{sortedProcesses.map((proc, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setSelectedProcess(proc)}
|
||||
className="table-row grid grid-cols-[2fr_0.8fr_1fr_1fr_1fr_1fr_2fr] gap-2 px-4 py-3 border-b border-surface1/20 hover:bg-surface1/30 cursor-pointer transition-colors group"
|
||||
>
|
||||
<div className="font-bold text-text truncate text-xs group-hover:text-blue" title={proc.name}>{proc.name}</div>
|
||||
<div className="text-overlay1 font-mono text-xs">{proc.instance_count}</div>
|
||||
<div className="text-blue font-black text-xs">{proc.avg_cpu.toFixed(1)}%</div>
|
||||
<div className="text-subtext0 text-[10px] font-bold">{proc.peak_cpu.toFixed(1)}%</div>
|
||||
<div className="text-mauve font-black text-xs">{proc.avg_memory_mb.toFixed(0)}MB</div>
|
||||
<div className="text-subtext0 text-[10px] font-bold">{proc.peak_memory_mb.toFixed(0)}MB</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{proc.warnings.map((w, idx) => (
|
||||
<span key={idx} className="bg-red/10 text-red text-[9px] font-black px-1.5 py-0.5 rounded-full border border-red/20 flex items-center gap-1 uppercase tracking-tighter">
|
||||
<AlertTriangle size={8} /> {w}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Process Detail Side Panel/Modal */}
|
||||
{selectedProcess && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-end bg-base/80 backdrop-blur-sm p-8 animate-in slide-in-from-right duration-300">
|
||||
<div className="w-full max-w-2xl h-full bg-mantle rounded-3xl border border-surface1 shadow-3xl flex flex-col overflow-hidden">
|
||||
<div className="p-8 border-b border-surface1 flex justify-between items-start shrink-0">
|
||||
<div>
|
||||
<div className="text-xs font-black text-blue uppercase tracking-widest mb-1">Process Inspector</div>
|
||||
<h2 className="text-3xl font-black text-text tracking-tighter uppercase italic truncate max-w-md">{selectedProcess.name}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedProcess(null)}
|
||||
className="p-2 hover:bg-surface1 rounded-full transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-8 flex flex-col gap-8 custom-scrollbar">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-surface0 p-6 rounded-2xl border border-surface1 shadow-lg">
|
||||
<div className="text-[10px] font-black text-overlay2 uppercase tracking-widest mb-2">Instances Detected</div>
|
||||
<div className="text-4xl font-black text-text">{selectedProcess.instance_count}</div>
|
||||
<p className="text-xs text-subtext1 mt-2">Maximum concurrent processes seen with this name.</p>
|
||||
</div>
|
||||
<div className="bg-surface0 p-6 rounded-2xl border border-surface1 shadow-lg">
|
||||
<div className="text-[10px] font-black text-overlay2 uppercase tracking-widest mb-2">Average Impact</div>
|
||||
<div className="text-4xl font-black text-blue">{selectedProcess.avg_cpu.toFixed(1)}%</div>
|
||||
<p className="text-xs text-subtext1 mt-2">Mean CPU usage across the entire session.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card h-64 shrink-0 bg-base border border-surface1">
|
||||
<div className="card-title mb-4">Resource History (Summed)</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={selectedProcess.history}>
|
||||
<defs>
|
||||
<linearGradient id="procCpuGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--blue)" stopOpacity={0.6}/>
|
||||
<stop offset="95%" stopColor="var(--blue)" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="procMemGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--mauve)" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="var(--mauve)" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--surface1)" vertical={false} opacity={0.3} />
|
||||
<XAxis dataKey="time" stroke="var(--subtext0)" tick={{fontSize: 9}} hide />
|
||||
<YAxis stroke="var(--subtext0)" tick={{fontSize: 9}} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--crust)', border: '1px solid var(--surface1)', borderRadius: '12px' }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="cpu_usage" name="Total CPU %" stroke="var(--blue)" fill="url(#procCpuGrad)" strokeWidth={3} />
|
||||
<Area type="monotone" dataKey="memory_mb" name="Total Mem (MB)" stroke="var(--mauve)" fill="url(#procMemGrad)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-black text-overlay2 uppercase tracking-widest">Profiling Insights</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{selectedProcess.warnings.length > 0 ? selectedProcess.warnings.map((w, idx) => (
|
||||
<div key={idx} className="bg-red/10 border border-red/20 p-4 rounded-xl flex items-center gap-3 text-red">
|
||||
<AlertTriangle size={20} />
|
||||
<span className="font-bold text-sm uppercase italic tracking-tight">{w}</span>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="bg-green/10 border border-green/20 p-4 rounded-xl flex items-center gap-3 text-green">
|
||||
<CheckSquare size={20} />
|
||||
<span className="font-bold text-sm uppercase italic tracking-tight">Optimal Performance Profile</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default App;
|
||||
144
src/index.css
Normal file
144
src/index.css
Normal file
@@ -0,0 +1,144 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* Catppuccin Mocha */
|
||||
--rosewater: #f5e0dc;
|
||||
--flamingo: #f2cdcd;
|
||||
--pink: #f5c2e7;
|
||||
--mauve: #cba6f7;
|
||||
--red: #f38ba8;
|
||||
--maroon: #eba0ac;
|
||||
--peach: #fab387;
|
||||
--yellow: #f9e2af;
|
||||
--green: #a6e3a1;
|
||||
--teal: #94e2d5;
|
||||
--sky: #89dceb;
|
||||
--sapphire: #74c7ec;
|
||||
--blue: #89b4fa;
|
||||
--lavender: #b4befe;
|
||||
--text: #cdd6f4;
|
||||
--subtext1: #bac2de;
|
||||
--subtext0: #a6adc8;
|
||||
--overlay2: #9399b2;
|
||||
--overlay1: #7f849c;
|
||||
--overlay0: #6c7086;
|
||||
--surface2: #585b70;
|
||||
--surface1: #45475a;
|
||||
--surface0: #313244;
|
||||
--base: #1e1e2e;
|
||||
--mantle: #181825;
|
||||
--crust: #11111b;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--base);
|
||||
color: var(--text);
|
||||
font-family: 'Geist', 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
height: 48px;
|
||||
background: var(--mantle);
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--surface0);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.titlebar-drag {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface0);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: var(--subtext0);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.05em;
|
||||
color: var(--text);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--surface1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--surface2);
|
||||
}
|
||||
|
||||
@keyframes pulse-soft {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.8; transform: scale(0.98); }
|
||||
}
|
||||
|
||||
.animate-pulse-soft {
|
||||
animation: pulse-soft 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.bg-red { background-color: var(--red); }
|
||||
.bg-green { background-color: var(--green); }
|
||||
.bg-blue { background-color: var(--blue); }
|
||||
.bg-mauve { background-color: var(--mauve); }
|
||||
.bg-surface0 { background-color: var(--surface0); }
|
||||
.bg-surface1 { background-color: var(--surface1); }
|
||||
.bg-base { background-color: var(--base); }
|
||||
|
||||
.text-red { color: var(--red); }
|
||||
.text-green { color: var(--green); }
|
||||
.text-blue { color: var(--blue); }
|
||||
.text-mauve { color: var(--mauve); }
|
||||
.text-text { color: var(--text); }
|
||||
.text-subtext0 { color: var(--subtext0); }
|
||||
.text-subtext1 { color: var(--subtext1); }
|
||||
.text-overlay1 { color: var(--overlay1); }
|
||||
.text-overlay2 { color: var(--overlay2); }
|
||||
|
||||
.border-surface1 { border-color: var(--surface1); }
|
||||
.border-red { border-color: var(--red); }
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
40
tailwind.config.js
Normal file
40
tailwind.config.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
base: "#1e1e2e",
|
||||
mantle: "#181825",
|
||||
crust: "#11111b",
|
||||
surface0: "#313244",
|
||||
surface1: "#45475a",
|
||||
surface2: "#585b70",
|
||||
overlay0: "#6c7086",
|
||||
overlay1: "#7f849c",
|
||||
overlay2: "#9399b2",
|
||||
subtext0: "#a6adc8",
|
||||
subtext1: "#bac2de",
|
||||
text: "#cdd6f4",
|
||||
lavender: "#b4befe",
|
||||
blue: "#89b4fa",
|
||||
sapphire: "#74c7ec",
|
||||
sky: "#89dceb",
|
||||
teal: "#94e2d5",
|
||||
green: "#a6e3a1",
|
||||
yellow: "#f9e2af",
|
||||
peach: "#fab387",
|
||||
maroon: "#eba0ac",
|
||||
red: "#f38ba8",
|
||||
mauve: "#cba6f7",
|
||||
pink: "#f5c2e7",
|
||||
flamingo: "#f2cdcd",
|
||||
rosewater: "#f5e0dc",
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
18
vite.config.ts
Normal file
18
vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
},
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
build: {
|
||||
target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13',
|
||||
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user