From 2ee58204041329606a88a0377d91c983f23152d7 Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Thu, 14 May 2026 16:34:26 +0100 Subject: [PATCH 01/19] feat: implement network monitoring with real-time stats and history tracking --- src/components/charts/NetworkHistoryChart.tsx | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/components/charts/NetworkHistoryChart.tsx diff --git a/src/components/charts/NetworkHistoryChart.tsx b/src/components/charts/NetworkHistoryChart.tsx new file mode 100644 index 0000000..3b59eee --- /dev/null +++ b/src/components/charts/NetworkHistoryChart.tsx @@ -0,0 +1,101 @@ +import { useQuery } from "@tanstack/react-query"; +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"; +import { api } from "@/lib/api"; +import { Loader2, Globe } from "lucide-react"; +import { fmtBytes } from "@/lib/utils"; + +export function NetworkHistoryChart() { + const { data: history = [], isLoading } = useQuery({ + queryKey: ["network-history"], + queryFn: api.getNetworkHistory, + refetchInterval: 3000, + }); + + if (isLoading || !history.length) { + return ( +
+ + Warming up network history... +
+ ); + } + + const chartData = history.map((p) => ({ + ...p, + rx_speed_kb: p.rx_speed / 1024, + tx_speed_kb: p.tx_speed / 1024, + time: new Date(p.ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + })); + + return ( +
+
+
+ +

60s Network Activity

+
+
+
+
+ Download +
+
+
+ Upload +
+
+
+ +
+ + + + + + + + + + + + + + + `${(val/1024).toFixed(1)}M`} + /> + [fmtBytes(val * 1024) + "/s"]} + /> + + + + +
+
+ ); +} From bf3ea2e7a7143dfd0c89365edede02cbeb0761ea Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Thu, 14 May 2026 16:34:41 +0100 Subject: [PATCH 02/19] feat: add network monitoring tab with live bandwidth and interface statistics --- src/components/layout/Shell.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/layout/Shell.tsx b/src/components/layout/Shell.tsx index fc3b832..2592a6f 100644 --- a/src/components/layout/Shell.tsx +++ b/src/components/layout/Shell.tsx @@ -2,6 +2,7 @@ import { Sidebar } from "./Sidebar"; import { TopBar } from "./TopBar"; import { MemoryTab } from "@/components/tabs/MemoryTab"; import { ProcessesTab } from "@/components/tabs/ProcessesTab"; +import { NetworkTab } from "@/components/tabs/NetworkTab"; import { AppsTab } from "@/components/tabs/AppsTab"; import { DiskTab } from "@/components/tabs/DiskTab"; import { SystemInfoTab } from "@/components/tabs/SystemInfoTab"; @@ -19,6 +20,7 @@ export function Shell() {
{activeTab === "memory" && } {activeTab === "processes" && } + {activeTab === "network" && } {activeTab === "apps" && } {activeTab === "disk" && } {activeTab === "system-info" && } From 11d6c2f2abfb72dd14a94600dddee4e2b604f2de Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Thu, 14 May 2026 16:34:51 +0100 Subject: [PATCH 03/19] feat: add real-time network monitoring with backend stats collection and new NetworkTab dashboard --- src/components/layout/Sidebar.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 6166dd5..e4793bd 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,6 +1,6 @@ import { Activity, List, AppWindow, HardDrive, - Info, Settings, Cpu, + Info, Settings, Cpu, Globe } from "lucide-react"; import { useAppStore } from "@/store/app"; import type { TabId } from "@/types"; @@ -9,6 +9,7 @@ const NAV = [ { section: "Monitor" }, { id: "memory", label: "Memory", icon: Activity }, { id: "processes", label: "Processes", icon: List }, + { id: "network", label: "Network", icon: Globe }, { section: "Manage" }, { id: "apps", label: "Applications", icon: AppWindow }, { id: "disk", label: "Disk Scanner", icon: HardDrive }, @@ -66,7 +67,7 @@ export function Sidebar() { {/* Version footer */}
-

v0.2.0 · Phase 2 Stable

+

v0.3.0 · Phase 3

); From 22c29b1d66868acf67fa4ad0163c4222028d6812 Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Thu, 14 May 2026 16:34:58 +0100 Subject: [PATCH 04/19] feat: implement network monitoring tab with real-time interface statistics and history tracking --- src/components/tabs/NetworkTab.tsx | 138 +++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/components/tabs/NetworkTab.tsx diff --git a/src/components/tabs/NetworkTab.tsx b/src/components/tabs/NetworkTab.tsx new file mode 100644 index 0000000..961c41b --- /dev/null +++ b/src/components/tabs/NetworkTab.tsx @@ -0,0 +1,138 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { Globe, Download, Upload, Server, Activity, ArrowDownCircle, ArrowUpCircle } from "lucide-react"; +import { api } from "@/lib/api"; +import { fmtBytes } from "@/lib/utils"; +import { StatCard } from "@/components/layout/StatCard"; +import { NetworkHistoryChart } from "@/components/charts/NetworkHistoryChart"; + +export function NetworkTab() { + const queryClient = useQueryClient(); + + const { data: interfaces = [], isLoading } = useQuery({ + queryKey: ["network-stats"], + queryFn: api.getNetworkStats, + }); + + const { data: snapshot } = useQuery({ + queryKey: ["tray-snapshot"], + queryFn: api.getTraySnapshot, + }); + + // Listen for background updates + useEffect(() => { + const unlisten = listen("network-update", () => { + queryClient.invalidateQueries({ queryKey: ["network-stats"] }); + queryClient.invalidateQueries({ queryKey: ["network-history"] }); + queryClient.invalidateQueries({ queryKey: ["tray-snapshot"] }); + }); + return () => { unlisten.then((fn) => fn()); }; + }, [queryClient]); + + const totalRxSpeed = snapshot?.network?.[0] ?? 0; + const totalTxSpeed = snapshot?.network?.[1] ?? 0; + + // Calculate today's totals from interfaces + const totalRxToday = interfaces.reduce((a, b) => a + b.total_rx, 0); + const totalTxToday = interfaces.reduce((a, b) => a + b.total_tx, 0); + + return ( +
+ {/* Stat Cards */} +
+ } + colorFuncs={{ bar: () => "bg-sky-400", text: () => "text-sky-400" }} + /> + } + colorFuncs={{ bar: () => "bg-amber-400", text: () => "text-amber-400" }} + /> + } + colorFuncs={{ bar: () => "bg-white/10", text: () => "text-white/40" }} + /> + } + colorFuncs={{ bar: () => "bg-white/10", text: () => "text-white/40" }} + /> +
+ + {/* History Chart */} + + + {/* Interfaces Table */} +
+
+ +

Network Interfaces

+
+ +
+
+ Interface + IP Address + Download + Upload + Total Data +
+ +
+ {isLoading ? ( +
+ +

Analyzing network hardware...

+
+ ) : interfaces.map((iface, i) => ( +
+
+
+ +
+
+

{iface.name}

+

{iface.mac_address || "No MAC"}

+
+
+ +
+

{iface.ip_address || "Disconnected"}

+
+ +
+

{fmtBytes(iface.rx_speed)}/s

+
+ +
+

{fmtBytes(iface.tx_speed)}/s

+
+ +
+

RX: {fmtBytes(iface.total_rx)}

+

TX: {fmtBytes(iface.total_tx)}

+
+
+ ))} +
+
+
+
+ ); +} From 6864408d8ce980fa23301a3ea3537f4e6c3b1e7b Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Thu, 14 May 2026 16:37:10 +0100 Subject: [PATCH 05/19] feat: add system network monitoring with real-time stats and history tracking --- src/lib/api.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/api.ts b/src/lib/api.ts index 6256fdc..46c080b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -10,6 +10,8 @@ import type { DiskEntry, ScanProgress, HistoryPoint, + NetworkInterface, + NetworkHistoryPoint, } from "@/types"; export const api = { @@ -18,6 +20,8 @@ export const api = { getSystemInfo: () => invoke("get_system_info"), getDisks: () => invoke("get_disks"), getBattery: () => invoke("get_battery"), + getNetworkStats: () => invoke("get_network_stats"), + getNetworkHistory: () => invoke("get_network_history"), getTraySnapshot: () => invoke("get_tray_snapshot"), getInstalledApps: () => invoke("get_installed_apps"), uninstallApp: (id: string, path: string) => invoke("uninstall_app", { id, path }), From e80f17396a1dc45c05fb9e285509a70d6a5f8645 Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Thu, 14 May 2026 16:40:03 +0100 Subject: [PATCH 06/19] feat: implement network monitoring with real-time stats and history tracking --- src/types/index.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/types/index.ts b/src/types/index.ts index 25cc901..3b0cdb0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -50,6 +50,7 @@ export interface TraySnapshot { total_memory: number; disk_used_pct: number; battery: BatteryInfo; + network: [number, number]; } export interface AppInfo { @@ -76,6 +77,24 @@ export interface DiskEntry { is_dir: boolean; } +export interface NetworkInterface { + name: string; + rx_bytes: number; + tx_bytes: number; + rx_speed: number; + tx_speed: number; + total_rx: number; + total_tx: number; + mac_address: string; + ip_address: string; +} + +export interface NetworkHistoryPoint { + ts: number; + rx_speed: number; + tx_speed: number; +} + export interface ScanProgress { scanned: number; current_path: string; @@ -90,6 +109,7 @@ export interface HistoryPoint { export type TabId = | "memory" | "processes" + | "network" | "apps" | "disk" | "system-info" From 2050b22fb0bd99aaf70cfbd39b8ce9c512dec14b Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Thu, 14 May 2026 16:40:15 +0100 Subject: [PATCH 07/19] feat: add network monitoring state, history tracking, and Tauri commands for interface statistics --- src-tauri/src/lib.rs | 100 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c07b70c..b6f2e5e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -44,6 +44,8 @@ pub struct SysState { pub history: Mutex>, pub scan_results: Mutex>, pub is_scanning: Mutex, + pub networks: Mutex, + pub net_history: Mutex>, } // ─── Data types (serialized to JSON for the frontend) ─────────────────────── @@ -98,6 +100,26 @@ pub struct BatteryInfo { pub time_to_empty_mins: Option, } +#[derive(Debug, Serialize, Clone, Default)] +pub struct NetworkInterface { + pub name: String, + pub rx_bytes: u64, + pub tx_bytes: u64, + pub rx_speed: u64, + pub tx_speed: u64, + pub total_rx: u64, + pub total_tx: u64, + pub mac_address: String, + pub ip_address: String, +} + +#[derive(Debug, Serialize, Clone)] +pub struct NetworkHistory { + pub ts: u64, + pub rx_speed: u64, + pub tx_speed: u64, +} + #[derive(Debug, Serialize, Clone)] pub struct TraySnapshot { pub cpu_usage: f32, @@ -105,6 +127,7 @@ pub struct TraySnapshot { pub total_memory: u64, pub disk_used_pct: f32, pub battery: BatteryInfo, + pub network: (u64, u64), // (total_rx_speed, total_tx_speed) } #[derive(Debug, Serialize, Clone, Default)] @@ -469,6 +492,33 @@ fn get_disks() -> Vec { .collect() } +/// Returns all network interfaces and their current stats. +#[tauri::command] +fn get_network_stats(state: State) -> Vec { + let mut networks = state.networks.lock().unwrap(); + networks.refresh(true); + + networks.iter().map(|(name, data)| { + NetworkInterface { + name: name.clone(), + rx_bytes: data.received(), + tx_bytes: data.transmitted(), + rx_speed: data.received(), // Since we refresh every X seconds, this is raw for now + tx_speed: data.transmitted(), + total_rx: data.total_received(), + total_tx: data.total_transmitted(), + mac_address: data.mac_address().to_string(), + ip_address: data.ip_networks().iter().map(|n| n.to_string()).collect::>().join(", "), + } + }).collect() +} + +/// Returns the network history for sparklines. +#[tauri::command] +fn get_network_history(state: State) -> Vec { + state.net_history.lock().unwrap().iter().cloned().collect() +} + /// Returns battery info (health, charge, cycles). #[tauri::command] fn get_battery() -> BatteryInfo { @@ -649,6 +699,17 @@ fn get_tray_snapshot(state: State) -> TraySnapshot { total_memory: sys.total_memory(), battery: read_battery(), disk_used_pct, + network: { + let mut networks = state.networks.lock().unwrap(); + networks.refresh(true); + let mut rx = 0; + let mut tx = 0; + for (_, data) in networks.iter() { + rx += data.received(); + tx += data.transmitted(); + } + (rx, tx) + } } } @@ -1023,12 +1084,25 @@ async fn start_refresh_loop(app: AppHandle) { 0.0 }; + // Check Network usage + let mut total_rx = 0; + let mut total_tx = 0; + { + let mut networks = state.networks.lock().unwrap(); + networks.refresh(true); + for (_, data) in networks.iter() { + total_rx += data.received(); + total_tx += data.transmitted(); + } + } + // Record history point + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + { - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); let mut hist = state.history.lock().unwrap(); hist.push_back(SnapPoint { ts, @@ -1040,6 +1114,19 @@ async fn start_refresh_loop(app: AppHandle) { } } + // Record network history + { + let mut net_hist = state.net_history.lock().unwrap(); + net_hist.push_back(NetworkHistory { + ts, + rx_speed: total_rx, + tx_speed: total_tx, + }); + if net_hist.len() > 60 { + net_hist.pop_front(); + } + } + if last_notification.elapsed() > StdDuration::from_secs(60) { let mut triggered = false; if cpu_usage > cpu_threshold { @@ -1064,6 +1151,7 @@ async fn start_refresh_loop(app: AppHandle) { } let _ = app.emit("process-update", ()); + let _ = app.emit("network-update", ()); } } @@ -1120,6 +1208,8 @@ pub fn run() { history: Mutex::new(VecDeque::with_capacity(60)), scan_results: Mutex::new(Vec::new()), is_scanning: Mutex::new(false), + networks: Mutex::new(sysinfo::Networks::new_with_refreshed_list()), + net_history: Mutex::new(VecDeque::with_capacity(60)), }); // Force the window icon for Linux dock @@ -1212,6 +1302,8 @@ pub fn run() { get_system_info, get_disks, get_battery, + get_network_stats, + get_network_history, get_tray_snapshot, get_installed_apps, uninstall_app, From 72abfed2c2c9e78620b5778f76a5a081bceb879a Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Thu, 14 May 2026 17:01:42 +0100 Subject: [PATCH 08/19] feat: implement system information PDF report export functionality --- package-lock.json | 10 ++ package.json | 1 + src-tauri/Cargo.lock | 153 +++++++++++++++++++ src-tauri/Cargo.toml | 3 + src-tauri/capabilities/main.json | 4 +- src-tauri/src/lib.rs | 209 ++++++++++++++++++++++++-- src/components/tabs/SystemInfoTab.tsx | 190 ++++++++++++++--------- src/lib/api.ts | 1 + 8 files changed, 489 insertions(+), 82 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed4164c..ce2ab66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@tanstack/react-query": "^5.56.2", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-autostart": "^2.5.1", + "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-shell": "^2.0.0", "lucide-react": "^0.441.0", @@ -1935,6 +1936,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tauri-apps/plugin-notification": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", diff --git a/package.json b/package.json index d18ccaf..d131514 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@tanstack/react-query": "^5.56.2", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-autostart": "^2.5.1", + "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-shell": "^2.0.0", "lucide-react": "^0.441.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5d55532..11878b3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -335,6 +335,17 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -485,8 +496,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -2041,6 +2054,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2068,6 +2087,23 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lopdf" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a" +dependencies = [ + "encoding_rs", + "flate2", + "itoa", + "linked-hash-map", + "log", + "md5", + "pom", + "time", + "weezl", +] + [[package]] name = "mac-notification-sys" version = "0.6.12" @@ -2100,6 +2136,12 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -2515,6 +2557,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "owned_ttf_parser" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4" +dependencies = [ + "ttf-parser", +] + [[package]] name = "pango" version = "0.18.3" @@ -2710,6 +2761,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pom" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c972d8f86e943ad532d0b04e8965a749ad1d18bb981a9c7b3ae72fe7fd7744b" +dependencies = [ + "bstr", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2750,6 +2810,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "printpdf" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30a4cc87c3ca9a98f4970db158a7153f8d1ec8076e005751173c57836380b1d" +dependencies = [ + "js-sys", + "lopdf", + "owned_ttf_parser", + "time", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3026,6 +3098,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3561,13 +3657,16 @@ version = "0.2.0" dependencies = [ "base64 0.22.1", "battery", + "chrono", "plist", + "printpdf", "serde", "serde_json", "sysinfo", "tauri", "tauri-build", "tauri-plugin-autostart", + "tauri-plugin-dialog", "tauri-plugin-notification", "tauri-plugin-shell", "tokio", @@ -3789,6 +3888,48 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-notification" version = "2.3.3" @@ -4310,6 +4451,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" + [[package]] name = "typeid" version = "1.0.3" @@ -4730,6 +4877,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b5a252b..8ba5cba 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,12 +19,15 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["tray-icon", "image-ico", "image-png"] } tauri-plugin-shell = "2" +tauri-plugin-dialog = "2" tauri-plugin-autostart = "2" tauri-plugin-notification = "2" +chrono = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" sysinfo = "0.33" tokio = { version = "1", features = ["full"] } +printpdf = "0.7" walkdir = "2.5.0" base64 = "0.22" battery = "0.7.0" diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index b22c303..78a6fbe 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -19,6 +19,8 @@ "notification:allow-request-permission", "autostart:allow-enable", "autostart:allow-disable", - "autostart:allow-is-enabled" + "autostart:allow-is-enabled", + "dialog:allow-save", + "dialog:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b6f2e5e..f2df4da 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -157,20 +157,35 @@ pub struct ScanProgress { // ─── Helpers ───────────────────────────────────────────────────────────────── fn fmt_bytes(bytes: u64) -> String { - const KB: u64 = 1024; - const MB: u64 = KB * 1024; - const GB: u64 = MB * 1024; - if bytes >= GB { - format!("{:.2} GB", bytes as f64 / GB as f64) - } else if bytes >= MB { - format!("{:.1} MB", bytes as f64 / MB as f64) - } else if bytes >= KB { - format!("{:.0} KB", bytes as f64 / KB as f64) + if bytes > 1024 * 1024 * 1024 { + format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) + } else if bytes > 1024 * 1024 { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) } else { - format!("{} B", bytes) + format!("{:.1} KB", bytes as f64 / 1024.0) } } +fn fmt_uptime(secs: u64) -> String { + let days = secs / 86400; + let hours = (secs % 86400) / 3600; + let mins = (secs % 3600) / 60; + if days > 0 { + format!("{}d {}h {}m", days, hours, mins) + } else if hours > 0 { + format!("{}h {}m", hours, mins) + } else { + format!("{}m", mins) + } +} + +fn health_label(pct: f32) -> &'static str { + if pct >= 90.0 { "Excellent" } + else if pct >= 80.0 { "Good" } + else if pct >= 50.0 { "Degraded" } + else { "Replace Soon" } +} + fn read_battery() -> BatteryInfo { #[cfg(target_os = "linux")] { @@ -519,6 +534,178 @@ fn get_network_history(state: State) -> Vec { state.net_history.lock().unwrap().iter().cloned().collect() } +/// Exports a full system report as a PDF to the specified path. +#[tauri::command] +fn export_report(state: State, path: String) -> Result<(), String> { + use printpdf::*; + use std::fs::File; + use std::io::BufWriter; + + // 1. GATHER ALL DATA + let mut sys = state.sys.lock().unwrap(); + sys.refresh_all(); + + let hostname = System::host_name().unwrap_or_else(|| "Unknown".to_string()); + let os = format!("{} {}", System::name().unwrap_or_default(), System::os_version().unwrap_or_default()); + let kernel = System::kernel_version().unwrap_or_default(); + let uptime = fmt_uptime(System::uptime()); + + let cpu_brand = sys.cpus().first().map(|c| c.brand().to_string()).unwrap_or_default(); + let cpu_cores = sys.cpus().len(); + + let total_ram = sys.total_memory(); + let used_ram = sys.used_memory(); + + let battery = read_battery(); + + let mut disks_data = Vec::new(); + let disks = sysinfo::Disks::new_with_refreshed_list(); + for disk in &disks { + disks_data.push(( + disk.mount_point().to_string_lossy().to_string(), + disk.total_space(), + disk.available_space(), + )); + } + + let mut networks_data = Vec::new(); + { + let mut networks = state.networks.lock().unwrap(); + networks.refresh(true); + for (name, data) in networks.iter() { + networks_data.push((name.clone(), data.total_received(), data.total_transmitted())); + } + } + + let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + // 2. GENERATE PDF + let (doc, page1, layer1) = PdfDocument::new("Sysora Machine Report", Mm(210.0), Mm(297.0), "Base"); + let layer = doc.get_page(page1).get_layer(layer1); + + // Font selection (platform-specific fallbacks) + let font_paths = if cfg!(target_os = "windows") { + vec!["C:\\Windows\\Fonts\\arial.ttf", "C:\\Windows\\Fonts\\segoeui.ttf"] + } else if cfg!(target_os = "macos") { + vec!["/Library/Fonts/Arial.ttf", "/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/Cache/Avenir.ttc"] + } else { + vec![ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/freefont/FreeSans.ttf" + ] + }; + + let mut font_loaded = None; + for fp in font_paths { + if std::path::Path::new(fp).exists() { + if let Ok(f) = File::open(fp) { + if let Ok(f_ref) = doc.add_external_font(f) { + font_loaded = Some(f_ref); + break; + } + } + } + } + + let font = font_loaded.ok_or_else(|| "Could not find a suitable system font for PDF generation (DejaVuSans or Arial required).".to_string())?; + + let mut y_pos = 280.0; + + // Helper to draw text + let draw_text = |layer: &PdfLayerReference, text: &str, size: f32, x: f32, y: f32, font: &IndirectFontRef| { + layer.use_text(text, size, Mm(x), Mm(y), font); + }; + + // Header + layer.set_fill_color(Color::Rgb(Rgb::new(0.5, 0.2, 0.9, None))); // Brand purple + draw_text(&layer, "SYSORA MACHINE REPORT", 20.0, 20.0, y_pos, &font); + y_pos -= 10.0; + layer.set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None))); + draw_text(&layer, &format!("Generated on {} for {}", timestamp, hostname), 10.0, 20.0, y_pos, &font); + y_pos -= 20.0; + + // Sections + let draw_section_header = |layer: &PdfLayerReference, title: &str, y: &mut f32| { + layer.set_fill_color(Color::Rgb(Rgb::new(0.2, 0.2, 0.2, None))); + layer.use_text(title, 14.0, Mm(20.0), Mm(*y), &font); + *y -= 2.0; + // Line + layer.set_outline_color(Color::Rgb(Rgb::new(0.8, 0.8, 0.8, None))); + layer.set_outline_thickness(0.5); + let line = vec![(Point::new(Mm(20.0), Mm(*y)), false), (Point::new(Mm(190.0), Mm(*y)), false)]; + layer.add_line(Line { points: line, is_closed: false }); + *y -= 10.0; + }; + + let draw_row = |layer: &PdfLayerReference, label: &str, value: &str, y: &mut f32| { + layer.set_fill_color(Color::Rgb(Rgb::new(0.5, 0.5, 0.5, None))); + layer.use_text(label, 10.0, Mm(25.0), Mm(*y), &font); + layer.set_fill_color(Color::Rgb(Rgb::new(0.1, 0.1, 0.1, None))); + layer.use_text(value, 10.0, Mm(70.0), Mm(*y), &font); + *y -= 8.0; + }; + + // 1. System + draw_section_header(&layer, "SYSTEM SPECIFICATIONS", &mut y_pos); + draw_row(&layer, "Operating System", &os, &mut y_pos); + draw_row(&layer, "Kernel Version", &kernel, &mut y_pos); + draw_row(&layer, "Hostname", &hostname, &mut y_pos); + draw_row(&layer, "Uptime", &uptime, &mut y_pos); + y_pos -= 5.0; + + // 2. Processor + draw_section_header(&layer, "PROCESSOR (CPU)", &mut y_pos); + draw_row(&layer, "Model", &cpu_brand, &mut y_pos); + draw_row(&layer, "Cores", &format!("{} logical cores", cpu_cores), &mut y_pos); + y_pos -= 5.0; + + // 3. Memory + draw_section_header(&layer, "MEMORY (RAM)", &mut y_pos); + draw_row(&layer, "Total RAM", &fmt_bytes(total_ram), &mut y_pos); + draw_row(&layer, "Used RAM", &fmt_bytes(used_ram), &mut y_pos); + draw_row(&layer, "Free RAM", &fmt_bytes(total_ram - used_ram), &mut y_pos); + y_pos -= 5.0; + + // 4. Storage + draw_section_header(&layer, "STORAGE (DISKS)", &mut y_pos); + for (mount, total, avail) in disks_data { + let used = total - avail; + let pct = if total > 0 { (used as f64 / total as f64) * 100.0 } else { 0.0 }; + draw_row(&layer, &mount, &format!("{} used of {} ({:.1}%)", fmt_bytes(used), fmt_bytes(total), pct), &mut y_pos); + if y_pos < 30.0 { break; } + } + y_pos -= 5.0; + + // 5. Battery + if battery.present { + draw_section_header(&layer, "BATTERY HEALTH", &mut y_pos); + draw_row(&layer, "Health", &format!("{:.0}% — {}", battery.health_percent, health_label(battery.health_percent)), &mut y_pos); + draw_row(&layer, "Cycle Count", &format!("{} cycles", battery.cycle_count.unwrap_or(0)), &mut y_pos); + draw_row(&layer, "Status", &battery.status, &mut y_pos); + y_pos -= 5.0; + } + + // 6. Network + if y_pos > 40.0 { + draw_section_header(&layer, "NETWORK INTERFACES", &mut y_pos); + for (name, rx, tx) in networks_data.iter().take(5) { + draw_row(&layer, name, &format!("Total RX: {} | Total TX: {}", fmt_bytes(*rx), fmt_bytes(*tx)), &mut y_pos); + if y_pos < 30.0 { break; } + } + } + + // Footer + layer.set_fill_color(Color::Rgb(Rgb::new(0.7, 0.7, 0.7, None))); + layer.use_text("Generated by Sysora — Open Source System Manager", 8.0, Mm(20.0), Mm(10.0), &font); + + // Save + let file = File::create(path).map_err(|e| e.to_string())?; + doc.save(&mut BufWriter::new(file)).map_err(|e| e.to_string())?; + + Ok(()) +} + /// Returns battery info (health, charge, cycles). #[tauri::command] fn get_battery() -> BatteryInfo { @@ -1187,6 +1374,7 @@ fn get_app_icon_data_url(path: String) -> Result { pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_autostart::init(tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec!["--minimized"]))) .plugin(tauri_plugin_notification::init()) .on_window_event(|window, event| match event { @@ -1304,6 +1492,7 @@ pub fn run() { get_battery, get_network_stats, get_network_history, + export_report, get_tray_snapshot, get_installed_apps, uninstall_app, diff --git a/src/components/tabs/SystemInfoTab.tsx b/src/components/tabs/SystemInfoTab.tsx index 6bb13c2..15268be 100644 --- a/src/components/tabs/SystemInfoTab.tsx +++ b/src/components/tabs/SystemInfoTab.tsx @@ -1,7 +1,9 @@ +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Monitor, Cpu, MemoryStick, BatteryWarning, Clock } from "lucide-react"; +import { Monitor, Cpu, MemoryStick, BatteryWarning, Clock, FileText, Download, Loader2 } from "lucide-react"; import { api } from "@/lib/api"; import { fmtBytes, fmtUptime, healthColor, healthLabel } from "@/lib/utils"; +import { save } from "@tauri-apps/plugin-dialog"; function InfoRow({ label, value }: { label: string; value: string }) { return ( @@ -27,86 +29,132 @@ function Section({ icon, title, children }: { icon: React.ReactNode; title: stri export function SystemInfoTab() { const { data: sys } = useQuery({ queryKey: ["sysInfo"], queryFn: api.getSystemInfo }); const { data: bat } = useQuery({ queryKey: ["battery"], queryFn: api.getBattery }); + const [exporting, setExporting] = useState(false); + + const handleExport = async () => { + try { + const path = await save({ + title: "Export Machine Report", + filters: [{ name: "PDF Document", extensions: ["pdf"] }], + defaultPath: `sysora_report_${sys?.hostname || "machine"}.pdf` + }); + + if (path) { + setExporting(true); + await api.exportReport(path); + alert(`Success! Report exported to:\n${path}`); + } + } catch (err: any) { + console.error(err); + alert(`Export failed: ${err.toString()}`); + } finally { + setExporting(false); + } + }; const memPct = sys ? (sys.used_memory / sys.total_memory) * 100 : 0; return ( -
- {/* OS */} -
} title="Operating System"> - - - - -
+
+ {/* Header with Export Button */} +
+
+
+ +
+
+

System Information

+

Hardware & Software Specs

+
+
- {/* CPU */} -
} title="Processor"> - - - -
+ +
- {/* Memory */} -
} title="Memory"> - - - - - - -
+
+ {/* OS */} +
} title="Operating System"> + + + + +
- {/* Battery */} -
} title="Battery Health"> - {!bat?.present ? ( -

No battery detected — desktop machine or unsupported platform.

- ) : ( - <> - - - - - - + {/* CPU */} +
} title="Processor"> + + + +
- {/* Health bar */} -
-
- 0% - Design capacity 100% -
-
-
- {/* Design capacity marker */} -
-
-

- Current max: {bat.health_percent.toFixed(0)}% of original -

-
- - )} -
+ {/* Memory */} +
} title="Memory"> + + + + + + +
- {/* Quick specs summary card — great for sharing machine specs */} -
-
- -

Quick Spec Summary

- Share this when selling your machine -
-
-

Machine: {sys?.hostname ?? "—"}

-

OS: {sys?.os_name ?? "—"} {sys?.os_version ?? ""} (Kernel {sys?.kernel_version ?? "—"})

-

CPU: {sys?.cpu_brand ?? "—"} · {sys?.cpu_count ?? 0} cores

-

RAM: {sys ? fmtBytes(sys.total_memory) : "—"} total

- {bat?.present && ( -

Battery: Health {bat.health_percent.toFixed(0)}% · {bat.cycle_count ?? "?"} cycles · {bat.current_capacity_mwh ?? "?"}mWh remaining capacity

+ {/* Battery */} +
} title="Battery Health"> + {!bat?.present ? ( +

No battery detected — desktop machine or unsupported platform.

+ ) : ( + <> + + + + + + + + {/* Health bar */} +
+
+ 0% + Design capacity 100% +
+
+
+ {/* Design capacity marker */} +
+
+

+ Current max: {bat.health_percent.toFixed(0)}% of original +

+
+ )} +
+ + {/* Quick specs summary card — great for sharing machine specs */} +
+
+ +

Quick Spec Summary

+ Share this when selling your machine +
+
+

Machine: {sys?.hostname ?? "—"}

+

OS: {sys?.os_name ?? "—"} {sys?.os_version ?? ""} (Kernel {sys?.kernel_version ?? "—"})

+

CPU: {sys?.cpu_brand ?? "—"} · {sys?.cpu_count ?? 0} cores

+

RAM: {sys ? fmtBytes(sys.total_memory) : "—"} total

+ {bat?.present && ( +

Battery: Health {bat.health_percent.toFixed(0)}% · {bat.cycle_count ?? "?"} cycles · {bat.current_capacity_mwh ?? "?"}mWh remaining capacity

+ )} +
diff --git a/src/lib/api.ts b/src/lib/api.ts index 46c080b..cfc39ba 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -22,6 +22,7 @@ export const api = { getBattery: () => invoke("get_battery"), getNetworkStats: () => invoke("get_network_stats"), getNetworkHistory: () => invoke("get_network_history"), + exportReport: (path: string) => invoke("export_report", { path }), getTraySnapshot: () => invoke("get_tray_snapshot"), getInstalledApps: () => invoke("get_installed_apps"), uninstallApp: (id: string, path: string) => invoke("uninstall_app", { id, path }), From 90923fda17cbb4848bb6b3b9936dd3439088acbd Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Thu, 14 May 2026 17:10:00 +0100 Subject: [PATCH 09/19] feat: add real-time hardware temperature monitoring with alert thresholds and UI indicators --- src-tauri/src/lib.rs | 64 ++++++++++++++++++++++++++- src/components/layout/StatCard.tsx | 8 +++- src/components/tabs/MemoryTab.tsx | 19 ++++++++ src/components/tabs/SettingsTab.tsx | 41 ++++++++++++++++- src/components/tabs/SystemInfoTab.tsx | 49 +++++++++++++++++++- src/lib/api.ts | 2 + src/types/index.ts | 10 +++++ 7 files changed, 187 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f2df4da..cb87a76 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,8 @@ pub struct AppSettings { pub ram_alert_threshold: f32, pub cpu_alert_threshold: f32, pub start_minimized: bool, + pub temp_threshold: f32, + pub temp_unit: String, } impl Default for AppSettings { @@ -27,6 +29,8 @@ impl Default for AppSettings { ram_alert_threshold: 85.0, cpu_alert_threshold: 80.0, start_minimized: false, + temp_threshold: 85.0, + temp_unit: "c".to_string(), } } } @@ -46,6 +50,7 @@ pub struct SysState { pub is_scanning: Mutex, pub networks: Mutex, pub net_history: Mutex>, + pub components: Mutex, } // ─── Data types (serialized to JSON for the frontend) ─────────────────────── @@ -75,6 +80,7 @@ pub struct SystemSnapshot { pub kernel_version: String, pub hostname: String, pub uptime_secs: u64, + pub cpu_temp: f32, } #[derive(Debug, Serialize, Clone)] @@ -148,6 +154,14 @@ pub struct DiskEntry { pub is_dir: bool, } +#[derive(Debug, Serialize, Clone)] +pub struct TempReading { + pub label: String, + pub current_celsius: f32, + pub max_celsius: f32, + pub critical_celsius: Option, +} + #[derive(Debug, Serialize, Clone)] pub struct ScanProgress { pub scanned: u64, @@ -482,6 +496,14 @@ fn get_system_info(state: State) -> SystemSnapshot { kernel_version: System::kernel_version().unwrap_or_default(), hostname: System::host_name().unwrap_or_default(), uptime_secs: System::uptime(), + cpu_temp: { + let mut components = state.components.lock().unwrap(); + components.refresh(true); + components.iter() + .filter(|c| c.label().to_lowercase().contains("cpu") || c.label().to_lowercase().contains("package")) + .filter_map(|c| c.temperature()) + .fold(0.0, f32::max) + } } } @@ -528,6 +550,22 @@ fn get_network_stats(state: State) -> Vec { }).collect() } +/// Returns all detected temperature sensors. +#[tauri::command] +fn get_temperatures(state: State) -> Vec { + let mut components = state.components.lock().unwrap(); + components.refresh(true); + + components.iter().map(|c| { + TempReading { + label: c.label().to_string(), + current_celsius: c.temperature().unwrap_or(0.0), + max_celsius: c.max().unwrap_or(0.0), + critical_celsius: c.critical(), + } + }).collect() +} + /// Returns the network history for sparklines. #[tauri::command] fn get_network_history(state: State) -> Vec { @@ -1238,13 +1276,14 @@ async fn start_refresh_loop(app: AppHandle) { let mut last_notification = Instant::now() - StdDuration::from_secs(60); loop { - let (interval, ram_threshold, cpu_threshold) = { + let (interval, ram_threshold, cpu_threshold, temp_threshold) = { let state = app.state::(); let settings = state.settings.lock().unwrap(); ( settings.refresh_interval_secs, settings.ram_alert_threshold, settings.cpu_alert_threshold, + settings.temp_threshold, ) }; @@ -1283,6 +1322,20 @@ async fn start_refresh_loop(app: AppHandle) { } } + // Check Temperatures + let mut max_temp: f32 = 0.0; + { + let mut components = state.components.lock().unwrap(); + components.refresh(true); + for c in components.iter() { + if let Some(t) = c.temperature() { + if t > max_temp { + max_temp = t; + } + } + } + } + // Record history point let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1330,6 +1383,13 @@ async fn start_refresh_loop(app: AppHandle) { .body(format!("RAM usage is at {:.1}%", ram_usage)) .show(); triggered = true; + } else if max_temp > temp_threshold { + let _ = app.notification() + .builder() + .title("High Temperature Alert") + .body(format!("Hardware sensor reached {:.1}°C", max_temp)) + .show(); + triggered = true; } if triggered { @@ -1398,6 +1458,7 @@ pub fn run() { is_scanning: Mutex::new(false), networks: Mutex::new(sysinfo::Networks::new_with_refreshed_list()), net_history: Mutex::new(VecDeque::with_capacity(60)), + components: Mutex::new(sysinfo::Components::new_with_refreshed_list()), }); // Force the window icon for Linux dock @@ -1492,6 +1553,7 @@ pub fn run() { get_battery, get_network_stats, get_network_history, + get_temperatures, export_report, get_tray_snapshot, get_installed_apps, diff --git a/src/components/layout/StatCard.tsx b/src/components/layout/StatCard.tsx index ff6433a..9e3cb4d 100644 --- a/src/components/layout/StatCard.tsx +++ b/src/components/layout/StatCard.tsx @@ -10,9 +10,10 @@ interface StatCardProps { bar: (pct: number) => string; text: (pct: number) => string; }; + badge?: React.ReactNode; } -export function StatCard({ label, value, sub, pct, icon, colorFuncs }: StatCardProps) { +export function StatCard({ label, value, sub, pct, icon, colorFuncs, badge }: StatCardProps) { const clampedPct = Math.min(100, Math.max(0, pct)); const barClass = colorFuncs?.bar ? colorFuncs.bar(clampedPct) : barColor(clampedPct); const textClass = colorFuncs?.text ? colorFuncs.text(clampedPct) : pctColor(clampedPct); @@ -20,7 +21,10 @@ export function StatCard({ label, value, sub, pct, icon, colorFuncs }: StatCardP return (
- {label} +
+ {label} + {badge} +
{icon}
diff --git a/src/components/tabs/MemoryTab.tsx b/src/components/tabs/MemoryTab.tsx index a506034..51b44d4 100644 --- a/src/components/tabs/MemoryTab.tsx +++ b/src/components/tabs/MemoryTab.tsx @@ -94,6 +94,16 @@ export function MemoryTab() { queryFn: api.getBattery, }); + const { data: settings } = useQuery({ + queryKey: ["settings"], + queryFn: api.getSettings, + }); + + const fmtTemp = (c: number) => { + if (settings?.temp_unit === "f") return `${((c * 9/5) + 32).toFixed(0)}°F`; + return `${c.toFixed(0)}°C`; + }; + // Refresh when backend emits process-update useEffect(() => { const unlisten = listen("process-update", () => { @@ -148,6 +158,15 @@ export function MemoryTab() { sub={`${sysInfo?.cpu_count ?? 0} cores · ${sysInfo?.cpu_brand ?? "—"}`} pct={cpuPct} icon={} + badge={sysInfo && sysInfo.cpu_temp > 0 && ( +
80 ? "bg-red-500/10 text-red-400 border-red-500/20" : + sysInfo.cpu_temp > 60 ? "bg-amber-500/10 text-amber-400 border-amber-500/20" : + "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" + }`}> + {fmtTemp(sysInfo.cpu_temp)} +
+ )} />
+ +
+
+ + + {settings.temp_threshold}°C + +
+ handleSave({ ...settings, temp_threshold: parseInt(e.target.value) })} + className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-red-400" + /> +
@@ -172,10 +191,30 @@ export function SettingsTab() {
-

Application Behavior

+

App Preferences

+
+
+

Temperature Unit

+

Toggle between Celsius and Fahrenheit

+
+
+ + +
+
{ + if (settings?.temp_unit === "f") return `${((c * 9/5) + 32).toFixed(0)}°F`; + return `${c.toFixed(0)}°C`; + }; + const handleExport = async () => { try { const path = await save({ @@ -78,7 +86,7 @@ export function SystemInfoTab() {
-
+
{/* OS */}
} title="Operating System"> @@ -91,6 +99,16 @@ export function SystemInfoTab() {
} title="Processor"> +
+ Temperature + 80 ? "text-red-400" : + (sys?.cpu_temp ?? 0) > 60 ? "text-amber-400" : + "text-emerald-400" + }`}> + {sys ? fmtTemp(sys.cpu_temp) : "—"} + +
@@ -139,6 +157,33 @@ export function SystemInfoTab() { )}
+ {/* Temperatures */} +
+
} title="Detailed Hardware Sensors"> + {temps && temps.length > 0 ? ( +
+ {temps.map((t) => ( +
+ {t.label} +
+ 80 ? "text-red-400" : + t.current_celsius > 60 ? "text-amber-400" : + "text-emerald-400" + }`}> + {fmtTemp(t.current_celsius)} + + Max {fmtTemp(t.max_celsius)} +
+
+ ))} +
+ ) : ( +

No hardware sensor data available for this platform.

+ )} +
+
+ {/* Quick specs summary card — great for sharing machine specs */}
diff --git a/src/lib/api.ts b/src/lib/api.ts index cfc39ba..038df47 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -12,6 +12,7 @@ import type { HistoryPoint, NetworkInterface, NetworkHistoryPoint, + TempReading, } from "@/types"; export const api = { @@ -22,6 +23,7 @@ export const api = { getBattery: () => invoke("get_battery"), getNetworkStats: () => invoke("get_network_stats"), getNetworkHistory: () => invoke("get_network_history"), + getTemperatures: () => invoke("get_temperatures"), exportReport: (path: string) => invoke("export_report", { path }), getTraySnapshot: () => invoke("get_tray_snapshot"), getInstalledApps: () => invoke("get_installed_apps"), diff --git a/src/types/index.ts b/src/types/index.ts index 3b0cdb0..d03cd34 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -21,6 +21,7 @@ export interface SystemSnapshot { kernel_version: string; hostname: string; uptime_secs: number; + cpu_temp: number; } export interface DiskInfo { @@ -68,6 +69,8 @@ export interface AppSettings { ram_alert_threshold: number; cpu_alert_threshold: number; start_minimized: boolean; + temp_threshold: number; + temp_unit: "c" | "f"; } export interface DiskEntry { @@ -100,6 +103,13 @@ export interface ScanProgress { current_path: string; } +export interface TempReading { + label: string; + current_celsius: number; + max_celsius: number; + critical_celsius: number | null; +} + export interface HistoryPoint { ts: number; cpu: number; From 0cc6148bbddfd1da7307a45ad43bd6316b8ee370 Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Thu, 14 May 2026 17:37:49 +0100 Subject: [PATCH 10/19] feat: implement command-line interface for system monitoring and telemetry output --- src-tauri/Cargo.lock | 132 ++++++++++++++++++++ src-tauri/Cargo.toml | 2 + src-tauri/src/cli.rs | 280 ++++++++++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 8 +- src-tauri/src/main.rs | 21 ++++ 5 files changed, 439 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/cli.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 11878b3..91efffd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -41,6 +41,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -503,6 +553,62 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "combine" version = "4.6.7" @@ -1866,6 +1972,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -1984,6 +2096,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "lazycell" version = "1.3.0" @@ -2519,6 +2637,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.3.4" @@ -3658,6 +3782,8 @@ dependencies = [ "base64 0.22.1", "battery", "chrono", + "clap", + "colored", "plist", "printpdf", "serde", @@ -4586,6 +4712,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8ba5cba..329acd4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,6 +31,8 @@ printpdf = "0.7" walkdir = "2.5.0" base64 = "0.22" battery = "0.7.0" +clap = { version = "4", features = ["derive"] } +colored = "2" [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.52" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs new file mode 100644 index 0000000..0335386 --- /dev/null +++ b/src-tauri/src/cli.rs @@ -0,0 +1,280 @@ +use clap::{Parser, Subcommand}; +use colored::*; +use sysinfo::{System, Components, Disks}; +use sysora_lib::*; +use serde_json; + +#[derive(Parser)] +#[command(name = "sysora")] +#[command(version = "0.3.0")] +#[command(about = "Sysora CLI — System monitoring from your terminal", long_about = None)] +pub struct Cli { + #[command(subcommand)] + pub command: Option, + + /// Output in JSON format + #[arg(short, long)] + pub json: bool, + + /// Hidden arg for compatibility with GUI autostart + #[arg(long, hide = true)] + pub minimized: bool, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Show a quick system snapshot (default) + Status, + /// List top processes by RAM usage + Processes { + /// Number of processes to show + #[arg(short, long, default_value_t = 10)] + top: usize, + }, + /// Show detailed disk usage per mount + Disk, + /// Show battery health and capacity details + Battery, +} + +pub fn run_cli(cli: Cli) { + match cli.command.unwrap_or(Commands::Status) { + Commands::Status => show_status(cli.json), + Commands::Processes { top } => show_processes(top, cli.json), + Commands::Disk => show_disk(cli.json), + Commands::Battery => show_battery(cli.json), + } +} + +fn bar(pct: f32, width: usize) -> String { + let filled = (pct / 100.0 * width as f32).round() as usize; + let filled = filled.min(width); + let mut s = String::new(); + for i in 0..width { + if i < filled { + s.push('█'); + } else { + s.push('░'); + } + } + s +} + +fn show_status(json: bool) { + let mut sys = System::new_all(); + sys.refresh_all(); + + let cpu_count = sys.cpus().len(); + let cpu_usage = if cpu_count > 0 { + sys.cpus().iter().map(|c| c.cpu_usage()).sum::() / cpu_count as f32 + } else { 0.0 }; + + let total_mem = sys.total_memory(); + let used_mem = sys.used_memory(); + let mem_pct = if total_mem > 0 { (used_mem as f32 / total_mem as f32) * 100.0 } else { 0.0 }; + + let mut comps = Components::new_with_refreshed_list(); + comps.refresh(true); + let max_temp = comps.iter().filter_map(|c| c.temperature()).fold(0.0, f32::max); + + let mut disks = Disks::new_with_refreshed_list(); + disks.refresh(true); + let mut disk_used = 0; + let mut disk_total = 0; + for d in disks.iter() { + disk_total += d.total_space(); + disk_used += d.total_space() - d.available_space(); + } + let disk_pct = if disk_total > 0 { (disk_used as f64 / disk_total as f64) * 100.0 } else { 0.0 }; + + let bat = read_battery(); + + if json { + let output = serde_json::json!({ + "os": format!("{} {}", System::name().unwrap_or_default(), System::os_version().unwrap_or_default()), + "hostname": System::host_name().unwrap_or_default(), + "cpu": { "usage_pct": cpu_usage, "cores": cpu_count, "brand": sys.cpus().first().map(|c| c.brand()).unwrap_or_default() }, + "memory": { "total_bytes": total_mem, "used_bytes": used_mem, "usage_pct": mem_pct }, + "disk": { "total_bytes": disk_total, "used_bytes": disk_used, "usage_pct": disk_pct }, + "temp": { "max_celsius": max_temp }, + "battery": bat + }); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + return; + } + + println!("{} {} · {} {} · hostname: {}", + "Sysora".brand_color(), + "v0.3.0".dimmed(), + System::name().unwrap_or_default().cyan(), + System::os_version().unwrap_or_default().cyan(), + System::host_name().unwrap_or_default().yellow() + ); + + // RAM + let ram_bar = bar(mem_pct, 10); + let ram_color = if mem_pct > 85.0 { Color::Red } else if mem_pct > 70.0 { Color::Yellow } else { Color::Green }; + println!("{:<8} {} {} / {} ({:.0}%)", + "RAM".bold(), + ram_bar.color(ram_color), + fmt_bytes(used_mem), + fmt_bytes(total_mem), + mem_pct + ); + + // CPU + let cpu_bar = bar(cpu_usage, 10); + let cpu_color = if cpu_usage > 80.0 { Color::Red } else if cpu_usage > 60.0 { Color::Yellow } else { Color::Green }; + println!("{:<8} {} {:.0}% · {} cores · {}", + "CPU".bold(), + cpu_bar.color(cpu_color), + cpu_usage, + cpu_count, + sys.cpus().first().map(|c| c.brand()).unwrap_or_default().dimmed() + ); + + // Disk + let d_bar = bar(disk_pct as f32, 10); + let d_color = if disk_pct > 90.0 { Color::Red } else if disk_pct > 75.0 { Color::Yellow } else { Color::Green }; + let d_warn = if disk_pct > 95.0 { " ⚠".red().bold().to_string() } else { "".to_string() }; + println!("{:<8} {} {} / {} ({:.0}%){}", + "Disk".bold(), + d_bar.color(d_color), + fmt_bytes(disk_used), + fmt_bytes(disk_total), + disk_pct, + d_warn + ); + + // Battery + if bat.present { + let b_bar = bar(bat.charge_percent as f32, 10); + let b_color = if bat.charge_percent < 20.0 { Color::Red } else if bat.charge_percent < 40.0 { Color::Yellow } else { Color::Green }; + println!("{:<8} {} {:.0}% · Health {:.0}% · {} cycles", + "Battery".bold(), + b_bar.color(b_color), + bat.charge_percent, + bat.health_percent, + bat.cycle_count.unwrap_or(0) + ); + } + + // Temp + let t_bar = bar((max_temp / 100.0 * 100.0).min(100.0), 10); + let t_color = if max_temp > 80.0 { Color::Red } else if max_temp > 60.0 { Color::Yellow } else { Color::Green }; + println!("{:<8} {} {:.0}°C · Max recorded {:.0}°C", + "Temp".bold(), + t_bar.color(t_color), + max_temp, + max_temp // Simplified for one-shot + ); +} + +fn show_processes(top_n: usize, json: bool) { + let mut sys = System::new_all(); + sys.refresh_all(); + + let mut procs: Vec<_> = sys.processes().values().collect(); + procs.sort_by(|a, b| b.memory().cmp(&a.memory())); + + if json { + let output: Vec<_> = procs.iter().take(top_n).map(|p| { + serde_json::json!({ + "pid": p.pid().as_u32(), + "name": p.name(), + "memory_bytes": p.memory(), + "cpu_usage": p.cpu_usage() + }) + }).collect(); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + return; + } + + println!("{:<8} {:<25} {:<15} {:<10}", "PID".bold(), "NAME".bold(), "MEMORY".bold(), "CPU".bold()); + println!("{}", "─".repeat(60).dimmed()); + for p in procs.iter().take(top_n) { + println!("{:<8} {:<25} {:<15} {:<10.1}%", + p.pid().as_u32(), + p.name().to_string_lossy().truncate(24), + fmt_bytes(p.memory()), + p.cpu_usage() + ); + } +} + +fn show_disk(json: bool) { + let mut disks = Disks::new_with_refreshed_list(); + disks.refresh(true); + + if json { + println!("{}", serde_json::to_string_pretty(&disks.iter().map(|d| { + serde_json::json!({ + "name": d.name().to_string_lossy(), + "mount": d.mount_point().to_string_lossy(), + "total_bytes": d.total_space(), + "available_bytes": d.available_space() + }) + }).collect::>()).unwrap()); + return; + } + + println!("{:<15} {:<20} {:<15} {:<10}", "NAME".bold(), "MOUNT".bold(), "USAGE".bold(), "FREE".bold()); + println!("{}", "─".repeat(65).dimmed()); + for d in disks.iter() { + let used = d.total_space() - d.available_space(); + let pct = if d.total_space() > 0 { (used as f64 / d.total_space() as f64) * 100.0 } else { 0.0 }; + println!("{:<15} {:<20} {:<15} {:<10}", + d.name().to_string_lossy().truncate(14), + d.mount_point().to_string_lossy().truncate(19), + format!("{} ({:.0}%)", fmt_bytes(used), pct), + fmt_bytes(d.available_space()) + ); + } +} + +fn show_battery(json: bool) { + let bat = read_battery(); + if json { + println!("{}", serde_json::to_string_pretty(&bat).unwrap()); + return; + } + + if !bat.present { + println!("{}", "No battery detected.".yellow()); + return; + } + + println!("{}", "Battery Health Details".bold().cyan()); + println!("{:<20} {:.0}%", "Charge:", bat.charge_percent); + println!("{:<20} {:.0}% ({})", "Health:", bat.health_percent, health_label(bat.health_percent)); + println!("{:<20} {}", "Cycles:", bat.cycle_count.unwrap_or(0)); + println!("{:<20} {}", "Status:", bat.status); + if let Some(m) = bat.design_capacity_mwh { println!("{:<20} {} mWh", "Design Cap:", m); } + if let Some(m) = bat.current_capacity_mwh { println!("{:<20} {} mWh", "Current Cap:", m); } +} + +trait BrandColor { + fn brand_color(&self) -> String; +} +impl BrandColor for str { + fn brand_color(&self) -> String { self.color(Color::TrueColor { r: 99, g: 102, b: 241 }).bold().to_string() } +} + +trait StringExt { + fn truncate(&self, len: usize) -> String; +} +impl StringExt for String { + fn truncate(&self, len: usize) -> String { + if self.len() > len { format!("{}…", &self[..len-1]) } else { self.clone() } + } +} +impl StringExt for &str { + fn truncate(&self, len: usize) -> String { + if self.len() > len { format!("{}…", &self[..len-1]) } else { self.to_string() } + } +} +impl StringExt for std::borrow::Cow<'_, str> { + fn truncate(&self, len: usize) -> String { + if self.len() > len { format!("{}…", &self[..len-1]) } else { self.to_string() } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cb87a76..90c7c71 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -170,7 +170,7 @@ pub struct ScanProgress { // ─── Helpers ───────────────────────────────────────────────────────────────── -fn fmt_bytes(bytes: u64) -> String { +pub fn fmt_bytes(bytes: u64) -> String { if bytes > 1024 * 1024 * 1024 { format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) } else if bytes > 1024 * 1024 { @@ -180,7 +180,7 @@ fn fmt_bytes(bytes: u64) -> String { } } -fn fmt_uptime(secs: u64) -> String { +pub fn fmt_uptime(secs: u64) -> String { let days = secs / 86400; let hours = (secs % 86400) / 3600; let mins = (secs % 3600) / 60; @@ -193,14 +193,14 @@ fn fmt_uptime(secs: u64) -> String { } } -fn health_label(pct: f32) -> &'static str { +pub fn health_label(pct: f32) -> &'static str { if pct >= 90.0 { "Excellent" } else if pct >= 80.0 { "Good" } else if pct >= 50.0 { "Degraded" } else { "Replace Soon" } } -fn read_battery() -> BatteryInfo { +pub fn read_battery() -> BatteryInfo { #[cfg(target_os = "linux")] { use std::fs; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2326f1f..de1272a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,27 @@ // Prevents additional console window on Windows in release #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod cli; + +use clap::Parser; + fn main() { + let args: Vec = std::env::args().collect(); + + // If we have more than 1 argument, or the first argument is not the binary path (unlikely), + // we assume CLI mode. Note: Tauri might pass some args, so we check specifically. + if args.len() > 1 { + // If the only argument is --minimized, we let it fall through to the GUI + if args.len() == 2 && args[1] == "--minimized" { + // Proceed to GUI + } else { + // For any other arguments, we treat it as a CLI call. + // .parse() will print help/errors and exit the process. + let parsed = cli::Cli::parse(); + cli::run_cli(parsed); + return; + } + } + sysora_lib::run(); } From 7080f5abd16f7333070408eac2783c90e686f110 Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Thu, 14 May 2026 17:49:47 +0100 Subject: [PATCH 11/19] feat: implement light mode support across application components and layout --- src-tauri/src/cli.rs | 2 +- src-tauri/src/lib.rs | 2 + src/components/charts/HistoryChart.tsx | 39 +++++--- src/components/charts/NetworkHistoryChart.tsx | 41 +++++--- src/components/layout/Shell.tsx | 28 +++++- src/components/layout/Sidebar.tsx | 20 ++-- src/components/layout/StatCard.tsx | 12 +-- src/components/layout/TopBar.tsx | 10 +- src/components/modals/AppDetailsModal.tsx | 28 +++--- src/components/tabs/AppsTab.tsx | 38 ++++---- src/components/tabs/DiskTab.tsx | 58 +++++------ src/components/tabs/MemoryTab.tsx | 44 ++++----- src/components/tabs/NetworkTab.tsx | 42 ++++---- src/components/tabs/ProcessesTab.tsx | 32 +++---- src/components/tabs/SettingsTab.tsx | 96 ++++++++++++------- src/components/tabs/SystemInfoTab.tsx | 62 ++++++------ src/index.css | 8 +- src/types/index.ts | 1 + 18 files changed, 323 insertions(+), 240 deletions(-) diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index 0335386..11df15b 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -13,7 +13,7 @@ pub struct Cli { pub command: Option, /// Output in JSON format - #[arg(short, long)] + #[arg(short, long, global = true)] pub json: bool, /// Hidden arg for compatibility with GUI autostart diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 90c7c71..4e75ef8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ pub struct AppSettings { pub start_minimized: bool, pub temp_threshold: f32, pub temp_unit: String, + pub theme: String, } impl Default for AppSettings { @@ -31,6 +32,7 @@ impl Default for AppSettings { start_minimized: false, temp_threshold: 85.0, temp_unit: "c".to_string(), + theme: "system".to_string(), } } } diff --git a/src/components/charts/HistoryChart.tsx b/src/components/charts/HistoryChart.tsx index eaf5b6a..eb807eb 100644 --- a/src/components/charts/HistoryChart.tsx +++ b/src/components/charts/HistoryChart.tsx @@ -31,20 +31,20 @@ export function HistoryChart() { })); return ( -
+
-
- +
+

60s Resource History

-
- CPU +
+ CPU
- RAM + RAM
@@ -62,20 +62,35 @@ export function HistoryChart() { - + `${val}%`} /> [`${val.toFixed(1)}%`]} + content={({ active, payload, label }) => { + if (active && payload && payload.length) { + return ( +
+

{label}

+ {payload.map((entry: any, index: number) => ( +
+
+ + {entry.name}: {entry.value?.toFixed(1)}% + +
+ ))} +
+ ); + } + return null; + }} /> +
-
- +
+

60s Network Activity

-
- Download +
+ Download
-
- Upload +
+ Upload
@@ -59,19 +59,34 @@ export function NetworkHistoryChart() { - + `${(val/1024).toFixed(1)}M`} /> [fmtBytes(val * 1024) + "/s"]} + content={({ active, payload, label }) => { + if (active && payload && payload.length) { + return ( +
+

{label}

+ {payload.map((entry: any, index: number) => ( +
+
+ + {entry.name}: {fmtBytes(entry.value * 1024)}/s + +
+ ))} +
+ ); + } + return null; + }} /> s.activeTab); + const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: api.getSettings }); + + useEffect(() => { + if (!settings) return; + + const root = window.document.documentElement; + const applyTheme = (t: string) => { + if (t === "dark" || (t === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches)) { + root.classList.add("dark"); + } else { + root.classList.remove("dark"); + } + }; + + applyTheme(settings.theme); + + if (settings.theme === "system") { + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const listener = () => applyTheme("system"); + media.addEventListener("change", listener); + return () => media.removeEventListener("change", listener); + } + }, [settings?.theme]); return ( -
+
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index e4793bd..4a22ddc 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -22,16 +22,16 @@ export function Sidebar() { const { activeTab, setActiveTab } = useAppStore(); return ( -
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 4a22ddc..df98f45 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,6 +1,6 @@ import { Activity, List, AppWindow, HardDrive, - Info, Settings, Cpu, Globe + Info, Settings, Cpu, Globe, History } from "lucide-react"; import { useAppStore } from "@/store/app"; import type { TabId } from "@/types"; @@ -10,6 +10,7 @@ const NAV = [ { id: "memory", label: "Memory", icon: Activity }, { id: "processes", label: "Processes", icon: List }, { id: "network", label: "Network", icon: Globe }, + { id: "history", label: "History", icon: History }, { section: "Manage" }, { id: "apps", label: "Applications", icon: AppWindow }, { id: "disk", label: "Disk Scanner", icon: HardDrive }, diff --git a/src/components/tabs/HistoryTab.tsx b/src/components/tabs/HistoryTab.tsx new file mode 100644 index 0000000..bffec87 --- /dev/null +++ b/src/components/tabs/HistoryTab.tsx @@ -0,0 +1,241 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"; +import { api } from "@/lib/api"; +import { Loader2, Calendar, TrendingUp, Cpu, MemoryStick, HardDrive, AlertCircle } from "lucide-react"; +import { fmtBytes } from "@/lib/utils"; + +const RANGES = [ + { label: "1 Hour", value: 1 }, + { label: "6 Hours", value: 6 }, + { label: "24 Hours", value: 24 }, + { label: "7 Days", value: 168 }, +]; + +export function HistoryTab() { + const [range, setRange] = useState(6); // Default 6h + + const { data: history = [], isLoading, error } = useQuery({ + queryKey: ["history-trends", range], + queryFn: () => api.getHistoricalTrends(range), + refetchInterval: 60000, // Refresh every minute + }); + + const chartData = history.map(p => ({ + ...p, + time: new Date(p.ts * 1000).toLocaleTimeString([], { + month: range > 24 ? 'short' : undefined, + day: range > 24 ? 'numeric' : undefined, + hour: '2-digit', + minute: '2-digit' + }), + ram_pct: (p.ram_used / p.ram_total) * 100, + disk_pct: (p.disk_used / p.disk_total) * 100, + })); + + if (isLoading) { + return ( +
+ +

Retrieving historical trends...

+
+ ); + } + + if (error) { + return ( +
+ +
+

Database Error

+

Failed to read historical data from SQLite. Try clearing history in settings.

+
+
+ ); + } + + // Calculate peaks + const peakCpu = Math.max(...history.map(p => p.cpu_pct), 0); + const peakRam = Math.max(...history.map(p => (p.ram_used / p.ram_total) * 100), 0); + const avgCpu = history.length ? history.reduce((acc, p) => acc + p.cpu_pct, 0) / history.length : 0; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

System History

+

Visualize usage trends over time

+
+
+ +
+ {RANGES.map((r) => ( + + ))} +
+
+ + {!history.length ? ( +
+ +
+

No data points yet

+

Historical snapshots are recorded every minute. Please wait a few moments for the first data to appear.

+
+
+ ) : ( + <> + {/* Main Chart */} +
+
+
+ +

Usage Trends (%)

+
+
+
+
+ CPU +
+
+
+ RAM +
+
+
+ Disk +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + `${val}%`} + /> + { + if (active && payload && payload.length) { + return ( +
+

{label}

+ {payload.map((entry: any, index: number) => ( +
+
+
+ {entry.name} +
+ + {entry.value.toFixed(1)}% + +
+ ))} +
+ ); + } + return null; + }} + /> + + + + + +
+
+ + {/* Stats Cards */} +
+ } + label="Peak CPU" + value={`${peakCpu.toFixed(1)}%`} + subValue={`Avg: ${avgCpu.toFixed(1)}%`} + color="text-brand-500" + /> + } + label="Peak RAM" + value={`${peakRam.toFixed(1)}%`} + subValue="Last recording" + color="text-emerald-500" + /> + } + label="Storage Load" + value={fmtBytes(history[history.length-1]?.disk_used ?? 0)} + subValue={`${((history[history.length-1]?.disk_used / history[history.length-1]?.disk_total) * 100).toFixed(1)}% used`} + color="text-amber-500" + /> + } + label="Data Points" + value={history.length.toString()} + subValue={`Range: ${range}h`} + color="text-sky-500" + /> +
+ + )} +
+ ); +} + +function StatCard({ icon, label, value, subValue, color }: { icon: any, label: string, value: string, subValue: string, color: string }) { + return ( +
+
+
+ {icon} +
+ {label} +
+
{value}
+
{subValue}
+
+ ); +} diff --git a/src/components/tabs/SettingsTab.tsx b/src/components/tabs/SettingsTab.tsx index ae12a20..7e9ae7e 100644 --- a/src/components/tabs/SettingsTab.tsx +++ b/src/components/tabs/SettingsTab.tsx @@ -6,10 +6,13 @@ import { RotateCcw, Save, Rocket, - AlertCircle + AlertCircle, + Database, + Trash2 } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import { AppSettings } from "@/types"; +import { fmtBytes } from "@/lib/utils"; const DEFAULT_SETTINGS: AppSettings = { refresh_interval_secs: 3, @@ -27,16 +30,27 @@ export function SettingsTab() { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - useEffect(() => { + const [dbStats, setDbStats] = useState<[number, number] | null>(null); + const [clearing, setClearing] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + + const loadSettings = useCallback(() => { invoke("get_settings") .then(setSettings) .catch((e) => { console.error("Failed to load settings:", e); - // If backend fails, use defaults so UI is still usable setSettings(DEFAULT_SETTINGS); }); + + invoke<[number, number]>("get_db_stats") + .then(setDbStats) + .catch(console.error); }, []); + useEffect(() => { + loadSettings(); + }, [loadSettings]); + const handleSave = useCallback(async (newSettings: AppSettings) => { setSettings(newSettings); setError(null); @@ -195,7 +209,7 @@ export function SettingsTab() {

App Preferences

-
+

Theme

@@ -252,6 +266,61 @@ export function SettingsTab() {
+ {/* Storage & History */} +
+
+ +

Storage & History

+
+ +
+
+

Local Database

+

+ History is using {dbStats ? fmtBytes(dbStats[0]) : "..."} · {dbStats ? dbStats[1].toLocaleString() : "..."} snapshots recorded +

+
+ + {showConfirm ? ( +
+ + +
+ ) : ( + + )} +
+
+ {/* Reset */}
@@ -53,8 +66,8 @@ export function AppDetailsModal({ app, onClose, onUninstall }: Props) {
-

{app.name}

-

Version {app.version || "0.1.0"}

+

{app.name}

+

Version {app.version || "0.1.0"}

@@ -82,7 +95,7 @@ export function AppDetailsModal({ app, onClose, onUninstall }: Props) {
{/* Footer */} -
+