commit ec142b09bdd86e407f2a5653df64f5537799d40b Author: Nils Pukropp Date: Sun Feb 22 17:39:34 2026 +0100 feat: initialize SysPulse-rs profiler project with Tauri v2 and React diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a1209b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..52ff3cb --- /dev/null +++ b/README.md @@ -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. diff --git a/index.html b/index.html new file mode 100644 index 0000000..7908bb9 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + SysPulse + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..fc726ca --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..601914b --- /dev/null +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..795b9b7 --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..327b538 Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..327b538 Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..327b538 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000..327b538 Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..ec5505d --- /dev/null +++ b/src-tauri/src/main.rs @@ -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, + total_memory: u64, + used_memory: u64, + processes: Vec, + 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, +} + +#[derive(Clone)] +struct Snapshot { + timestamp: DateTime, + cpu_usage: Vec, + used_memory: u64, + processes: Vec, +} + +struct ProfilingSession { + is_active: bool, + start_time: Option>, + snapshots: Vec, +} + +struct AppState { + sys: Mutex, + profiling: Mutex, +} + +// --- Report Structures --- + +#[derive(Serialize, Deserialize)] +struct Report { + start_time: String, + end_time: String, + duration_seconds: i64, + timeline: Vec, + aggregated_processes: Vec, +} + +#[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, + history: Vec, +} + +// --- Commands --- + +#[tauri::command] +fn get_system_stats( + state: State, + 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 = 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 = 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 { + 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) { + 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) -> 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 = profiling.snapshots.iter().map(|s| { + let avg_cpu = s.cpu_usage.iter().sum::() / 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> = HashMap::new(); + let mut peak_stats: HashMap = HashMap::new(); // (Peak CPU, Peak Mem) + let mut unique_pids: HashMap> = HashMap::new(); + let mut status_flags: HashMap = HashMap::new(); // Zombie check + + for snapshot in &profiling.snapshots { + let mut snapshot_procs: HashMap = 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 = 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 { + // 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"); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..1f5b095 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -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" + ] + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..af8a315 --- /dev/null +++ b/src/App.tsx @@ -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(null); + const [history, setHistory] = useState<{ time: string; cpu: number }[]>([]); + const [excludeSelf, setExcludeSelf] = useState(true); + const [report, setReport] = useState(null); + + useEffect(() => { + const fetchStats = async () => { + try { + const isRecording = stats?.is_recording ?? false; + const data = await invoke('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('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 ( + setView('dashboard')} + /> + ); + } + + if (!stats) return
Loading System Data...
; + + 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 ( +
+
+
+ + SysPulse +
+
+ + +
+
+ +
+ {stats.is_recording ? ( +
+
+
+
+ +
+
+
+

Profiling Active

+

{formatDuration(stats.recording_duration)}

+
+
+
+
CPU
+
{avgCpu.toFixed(1)}%
+
+
+
RAM
+
{memoryPercent.toFixed(1)}%
+
+
+

Minimal footprint mode enabled to ensure accurate results.

+
+ ) : ( + <> +
+
+
CPU Load
+
+ {avgCpu.toFixed(1)} + % +
+
+ + + + + + + + + + + +
+
+ +
+
Memory
+
+ {(stats.used_memory / 1024 / 1024 / 1024).toFixed(1)} + GB used +
+
+
+ {memoryPercent.toFixed(1)}% + {(stats.total_memory / 1024 / 1024 / 1024).toFixed(0)} GB +
+
+
+
+
+
+ +
+
Tasks
+
+ {stats.processes.length} + active +
+
+ Profiling will merge child processes into their parents for consolidated analysis. +
+
+
+ +
+
+

+ + Live Process Feed +

+
Top 50 Consumers
+
+ +
+
Name
+
PID
+
CPU
+
Memory
+
Action
+
+ +
+ {stats.processes.map((proc) => ( +
+
{proc.name}
+
{proc.pid}
+
{proc.cpu_usage.toFixed(1)}%
+
{(proc.memory / 1024 / 1024).toFixed(0)} MB
+
+ +
+
+ ))} +
+
+ + )} +
+
+ ); +} + +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('avg_cpu'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + const [selectedProcess, setSelectedProcess] = useState(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('save_report', { report }); + alert(`Report saved to: ${path}`); + } catch (e) { + alert(`Failed to save: ${e}`); + } + }; + + return ( +
+
+
+ + Profiling Report +
+
+ + +
+
+ +
+
+
+
Session Time
+
{formatDuration(report.duration_seconds)}
+
+
+
End of Session
+
{new Date(report.end_time).toLocaleTimeString()}
+
+
+
Uniques
+
{report.aggregated_processes.length}
+
+
+
Issue Alerts
+
+ {report.aggregated_processes.filter(p => p.warnings.length > 0).length} +
+
+
+ +
+
+

+ Session Load Profile +

+
+
+ + + + + + + + + + + + + + + + +
+
+ +
+
+

+ Analysis Matrix +

+ CLICK PROCESS TO INSPECT +
+ +
+
handleSort('name')}> + Process {sortField === 'name' && (sortOrder === 'asc' ? '↑' : '↓')} +
+
handleSort('instance_count')}>Units
+
handleSort('avg_cpu')}>Avg CPU
+
handleSort('peak_cpu')}>Peak CPU
+
handleSort('avg_memory_mb')}>Avg Mem
+
handleSort('peak_memory_mb')}>Peak Mem
+
Insights
+
+ +
+ {sortedProcesses.map((proc, i) => ( +
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" + > +
{proc.name}
+
{proc.instance_count}
+
{proc.avg_cpu.toFixed(1)}%
+
{proc.peak_cpu.toFixed(1)}%
+
{proc.avg_memory_mb.toFixed(0)}MB
+
{proc.peak_memory_mb.toFixed(0)}MB
+
+ {proc.warnings.map((w, idx) => ( + + {w} + + ))} +
+
+ ))} +
+
+ + {/* Process Detail Side Panel/Modal */} + {selectedProcess && ( +
+
+
+
+
Process Inspector
+

{selectedProcess.name}

+
+ +
+ +
+
+
+
Instances Detected
+
{selectedProcess.instance_count}
+

Maximum concurrent processes seen with this name.

+
+
+
Average Impact
+
{selectedProcess.avg_cpu.toFixed(1)}%
+

Mean CPU usage across the entire session.

+
+
+ +
+
Resource History (Summed)
+
+ + + + + + + + + + + + + + + + + + + + +
+
+ +
+

Profiling Insights

+
+ {selectedProcess.warnings.length > 0 ? selectedProcess.warnings.map((w, idx) => ( +
+ + {w} +
+ )) : ( +
+ + Optimal Performance Profile +
+ )} +
+
+
+
+
+ )} +
+
+ ); +} + +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; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..6acb653 --- /dev/null +++ b/src/index.css @@ -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); } diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..3d7150d --- /dev/null +++ b/src/main.tsx @@ -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( + + + , +) diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..ac86235 --- /dev/null +++ b/tailwind.config.js @@ -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: [], +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3934b8f --- /dev/null +++ b/tsconfig.json @@ -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" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..44c1ae7 --- /dev/null +++ b/vite.config.ts @@ -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, + }, +})