feat: complete rewrite for accurate PSS memory and smart user-focused hierarchy

This commit is contained in:
2026-02-23 20:28:45 +01:00
parent 9c39c8b35f
commit d9f4c74b9f
5 changed files with 315 additions and 75 deletions

15
build_log.txt Normal file
View File

@@ -0,0 +1,15 @@
> syspulse@0.1.0 build
> tsc && vite build
vite v6.4.1 building for production...
transforming...
✓ 2352 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.55 kB │ gzip: 0.31 kB
dist/assets/index-C5k9P4b0.css 23.62 kB │ gzip: 5.27 kB
dist/assets/index--56uGBza.js 16.97 kB │ gzip: 4.60 kB
dist/assets/charts-vendor-CdA6MGxy.js 310.46 kB │ gzip: 79.84 kB
dist/assets/vendor-B6fi2Fb1.js 313.23 kB │ gzip: 99.70 kB
✓ built in 8.00s

View File

@@ -1,10 +1,12 @@
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { GlobalStats } from './types';
import { GlobalStats, ProfilingReport } from './types';
import { Dashboard } from './components/Dashboard';
import { ReportView } from './components/ReportView';
function App() {
const [stats, setStats] = useState<GlobalStats | null>(null);
const [report, setReport] = useState<ProfilingReport | null>(null);
const [history, setHistory] = useState<{ time: string; cpu: number }[]>([]);
useEffect(() => {
@@ -14,9 +16,7 @@ function App() {
const fetchStats = async () => {
try {
const data = await invoke<GlobalStats>('get_latest_stats');
if (!isMounted) return;
setStats(data);
setHistory(prev => {
@@ -25,7 +25,7 @@ function App() {
return newHistory;
});
} catch (e) {
console.error('Failed to fetch stats:', e);
console.error(e);
} finally {
if (isMounted) {
timeoutId = window.setTimeout(fetchStats, 1000);
@@ -42,17 +42,19 @@ function App() {
if (!stats) return (
<div className="h-screen w-screen flex flex-col items-center justify-center text-text bg-base font-black italic tracking-tighter uppercase text-2xl animate-pulse">
Initializing SysPulse
<div className="mt-4 w-48 h-1 bg-surface1 rounded-full overflow-hidden">
<div className="h-full bg-blue animate-[loading_2s_infinite]" style={{ width: '30%' }} />
</div>
Initializing SysPulse...
</div>
);
if (report) {
return <ReportView report={report} onBack={() => setReport(null)} />;
}
return (
<Dashboard
stats={stats}
history={history}
history={history}
onShowReport={(rep) => setReport(rep)}
/>
);
}

View File

@@ -1,19 +1,22 @@
import { useState, useMemo, Fragment } from 'react';
import { useState, Fragment } from 'react';
import {
Activity, Cpu, Server, Database, Shield, ChevronRight, ChevronDown, Search
Activity, Cpu, Server, Database, Target, Play, Square, ChevronRight, ChevronDown, Search, Download
} from 'lucide-react';
import { AreaChart, Area, ResponsiveContainer } from 'recharts';
import { GlobalStats, ProcessNode } from '../types';
import { invoke } from '@tauri-apps/api/core';
import { GlobalStats, ProcessNode, ProfilingReport } from '../types';
import { cn, formatBytes } from '../utils';
interface Props {
stats: GlobalStats;
history: { time: string; cpu: number }[];
onShowReport: (report: ProfilingReport) => void;
}
export function Dashboard({ stats, history }: Props) {
export function Dashboard({ stats, history, onShowReport }: Props) {
const [searchTerm, setSearchBar] = useState("");
const [expandedPids, setExpandedPids] = useState<Set<number>>(new Set());
const [pinnedPid, setPinnedPid] = useState<number | null>(null);
const memoryPercent = (Number(stats.mem_used) / Number(stats.mem_total)) * 100;
@@ -24,43 +27,54 @@ export function Dashboard({ stats, history }: Props) {
setExpandedPids(next);
};
// Flatten the tree for the list view if searching, or keep it hierarchical
const filteredTree = useMemo(() => {
if (!searchTerm) return stats.process_tree;
const search = searchTerm.toLowerCase();
const result: ProcessNode[] = [];
function searchRecursive(nodes: ProcessNode[]) {
for (const node of nodes) {
if (node.name.toLowerCase().includes(search) || node.pid.toString().includes(search)) {
result.push(node);
}
searchRecursive(node.children);
}
const toggleRecording = async () => {
if (stats.is_profiling) {
const report = await invoke<ProfilingReport | null>('stop_profiling');
if (report) onShowReport(report);
} else {
await invoke('start_profiling', { target_pid: pinnedPid });
}
searchRecursive(stats.process_tree);
return result;
}, [stats.process_tree, searchTerm]);
};
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = JSON.parse(event.target?.result as string) as ProfilingReport;
onShowReport(data);
} catch (err) {
alert('Failed to parse report');
}
};
reader.readAsText(file);
};
const renderProcessNode = (node: ProcessNode, depth = 0) => {
const isExpanded = expandedPids.has(node.pid);
const hasChildren = node.children.length > 0;
const totalCpu = node.cpu_self + node.cpu_children;
const totalMem = node.mem_rss + node.mem_children;
const isPinned = pinnedPid === node.pid;
if (searchTerm && !node.name.toLowerCase().includes(searchTerm.toLowerCase()) && !node.pid.toString().includes(searchTerm)) {
// Simple search for now: if node doesn't match, search children
if (!node.children.some(c => c.name.toLowerCase().includes(searchTerm.toLowerCase()))) {
return null;
}
}
return (
<Fragment key={node.pid}>
<div
className={cn(
"grid grid-cols-[1fr_80px_100px_120px_80px] gap-4 px-4 py-3 border-b border-surface1/20 group hover:bg-surface1/20 transition-all rounded-xl items-center",
depth > 0 && "bg-base/10"
"grid grid-cols-[1fr_80px_100px_120px_100px] gap-4 px-4 py-3 border-b border-surface1/20 group hover:bg-surface1/20 transition-all rounded-xl items-center cursor-pointer",
isPinned && "bg-blue/10 border-blue/30"
)}
onClick={() => setPinnedPid(isPinned ? null : node.pid)}
>
<div className="font-bold text-text truncate text-sm flex items-center gap-2" style={{ paddingLeft: depth * 16 }}>
{hasChildren && !searchTerm ? (
<button onClick={() => toggleExpand(node.pid)} className="p-1 hover:bg-surface2 rounded">
{hasChildren ? (
<button onClick={(e) => { e.stopPropagation(); toggleExpand(node.pid); }} className="p-1 hover:bg-surface2 rounded">
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
) : <div className="w-6" />}
@@ -68,20 +82,21 @@ export function Dashboard({ stats, history }: Props) {
</div>
<div className="font-mono text-overlay2 text-xs text-right">{node.pid}</div>
<div className="text-blue font-black text-sm text-right tabular-nums">
{totalCpu.toFixed(1)}%
{depth === 0 && node.cpu_children > 0 && <span className="block text-[9px] text-overlay1 font-normal">Self: {node.cpu_self.toFixed(1)}%</span>}
{node.cpu_inclusive.toFixed(1)}%
</div>
<div className="text-mauve font-black text-sm text-right tabular-nums">
{formatBytes(totalMem)}
{depth === 0 && node.mem_children > 0 && <span className="block text-[9px] text-overlay1 font-normal">Self: {formatBytes(node.mem_rss)}</span>}
{formatBytes(node.mem_pss_inclusive)}
</div>
<div className="flex justify-center">
<button className="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-red/20 text-red rounded-lg transition-all">
<Shield size={14} />
<div className="flex justify-center gap-2">
<button
className={cn("p-1.5 rounded-lg transition-all", isPinned ? "text-blue opacity-100" : "opacity-0 group-hover:opacity-100 text-overlay1 hover:text-blue")}
onClick={(e) => { e.stopPropagation(); setPinnedPid(isPinned ? null : node.pid); }}
>
<Target size={14} />
</button>
</div>
</div>
{hasChildren && isExpanded && !searchTerm && node.children.map(child => renderProcessNode(child, depth + 1))}
{hasChildren && isExpanded && node.children.map(child => renderProcessNode(child, depth + 1))}
</Fragment>
);
};
@@ -96,10 +111,24 @@ export function Dashboard({ stats, history }: Props) {
<span className="font-black tracking-tighter uppercase italic text-xl">SysPulse</span>
</div>
<div className="flex items-center gap-4 px-4">
<div className="text-[10px] bg-surface1 px-3 py-1.5 rounded-full font-black text-overlay1 uppercase tracking-widest flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-green animate-pulse" />
Live Monitor
</div>
<label className="flex items-center gap-2 text-subtext0 hover:text-text transition-colors text-[10px] font-black uppercase tracking-widest cursor-pointer group">
<Download size={14} className="text-blue group-hover:scale-110 transition-transform" />
<span>Import</span>
<input type="file" accept=".json" onChange={handleImport} className="hidden" />
</label>
<div className="h-4 w-px bg-surface1 mx-1" />
<button
className={cn(
"flex items-center gap-2 px-6 py-2 rounded-xl text-xs font-black transition-all shadow-xl",
stats.is_profiling
? "bg-red text-base animate-pulse ring-4 ring-red/20"
: "bg-green text-base hover:scale-105 active:scale-95"
)}
onClick={toggleRecording}
>
{stats.is_profiling ? <Square size={12} fill="currentColor" /> : <Play size={12} fill="currentColor" />}
{stats.is_profiling ? `STOP` : (pinnedPid ? "PROFILE TARGET" : "START GLOBAL")}
</button>
</div>
</div>
@@ -127,7 +156,7 @@ export function Dashboard({ stats, history }: Props) {
</div>
<div className="card bg-gradient-to-b from-surface0 to-base/50">
<div className="card-title"><Database size={16} className="text-mauve" strokeWidth={3} /> Memory</div>
<div className="card-title"><Database size={16} className="text-mauve" strokeWidth={3} /> Real Memory (PSS)</div>
<div className="flex items-end gap-2">
<span className="stat-value text-mauve tabular-nums">{formatBytes(stats.mem_used)}</span>
<span className="text-subtext0 font-black mb-2">used</span>
@@ -144,13 +173,13 @@ export function Dashboard({ stats, history }: Props) {
</div>
<div className="card bg-gradient-to-b from-surface0 to-base/50">
<div className="card-title"><Server size={16} className="text-green" strokeWidth={3} /> Process Stack</div>
<div className="card-title"><Server size={16} className="text-green" strokeWidth={3} /> Smart View</div>
<div className="flex items-end gap-2">
<span className="stat-value text-green tabular-nums">{stats.process_count}</span>
<span className="text-subtext0 font-black mb-2">Active</span>
<span className="stat-value text-green tabular-nums">{stats.smart_tree.length}</span>
<span className="text-subtext0 font-black mb-2">Entry points</span>
</div>
<div className="mt-4 text-xs text-subtext1 font-medium leading-relaxed bg-base/30 p-3 rounded-xl border border-surface1">
Deep hierarchical tracking enabled. Child process overhead is summed into parent totals.
Top-level processes are grouped logically. Shared memory is distributed fairly via PSS.
</div>
</div>
</div>
@@ -159,32 +188,30 @@ export function Dashboard({ stats, history }: Props) {
<div className="flex justify-between items-center mb-6 px-2 shrink-0">
<h3 className="text-xl font-black tracking-tighter uppercase italic flex items-center gap-2">
<Activity size={24} className="text-red" strokeWidth={3} />
System Hierarchy
Process Hierarchy
</h3>
<div className="flex items-center gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-overlay1" size={14} />
<input
type="text"
placeholder="SEARCH HIERARCHY..."
value={searchTerm}
onChange={(e) => setSearchBar(e.target.value)}
className="bg-surface1/50 border border-surface2 rounded-xl pl-9 pr-4 py-2 text-xs font-bold text-text placeholder:text-overlay1 focus:outline-none focus:ring-2 focus:ring-blue/30 w-64 transition-all"
/>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-overlay1" size={14} />
<input
type="text"
placeholder="SEARCH..."
value={searchTerm}
onChange={(e) => setSearchBar(e.target.value)}
className="bg-surface1/50 border border-surface2 rounded-xl pl-9 pr-4 py-2 text-xs font-bold text-text placeholder:text-overlay1 focus:outline-none focus:ring-2 focus:ring-blue/30 w-64 transition-all"
/>
</div>
</div>
<div className="grid grid-cols-[1fr_80px_100px_120px_80px] gap-4 px-6 py-4 bg-surface1/50 rounded-2xl font-black uppercase tracking-widest text-[10px] text-overlay1 mb-2">
<div className="grid grid-cols-[1fr_80px_100px_120px_100px] gap-4 px-6 py-4 bg-surface1/50 rounded-2xl font-black uppercase tracking-widest text-[10px] text-overlay1 mb-2">
<div>Hierarchy</div>
<div className="text-right">PID</div>
<div className="text-right">Total CPU</div>
<div className="text-right">Total RAM</div>
<div className="text-center">Action</div>
<div className="text-right">Inclusive CPU</div>
<div className="text-right">Inclusive PSS</div>
<div className="text-center">Target</div>
</div>
<div className="overflow-y-auto flex-1 custom-scrollbar px-2">
{filteredTree.map(node => renderProcessNode(node))}
{stats.smart_tree.map(node => renderProcessNode(node))}
</div>
</div>
</main>

View File

@@ -0,0 +1,172 @@
import { useState, Fragment } from 'react';
import {
Activity, Server, ArrowLeft, ChevronRight, ChevronDown, Save, FileJson, Clock, Target, AlertTriangle
} from 'lucide-react';
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts';
import { invoke } from '@tauri-apps/api/core';
import { ProcessNode, ProfilingReport } from '../types';
import { formatBytes, formatDuration } from '../utils';
interface Props {
report: ProfilingReport;
onBack: () => void;
}
export function ReportView({ report, onBack }: Props) {
const [expandedPids, setExpandedPids] = useState<Set<number>>(new Set());
const toggleExpand = (pid: number) => {
const next = new Set(expandedPids);
if (next.has(pid)) next.delete(pid);
else next.add(pid);
setExpandedPids(next);
};
const saveReport = async () => {
try {
const path = await invoke<string>('save_report', { report });
alert(`Report saved to: ${path}`);
} catch (e) {
alert(`Failed to save: ${e}`);
}
};
const renderProcessNode = (node: ProcessNode, depth = 0) => {
const isExpanded = expandedPids.has(node.pid);
const hasChildren = node.children.length > 0;
return (
<Fragment key={node.pid}>
<div
className="grid grid-cols-[1fr_80px_100px_120px_100px] gap-4 px-4 py-3 border-b border-surface1/20 group hover:bg-surface1/20 transition-all rounded-xl items-center"
>
<div className="font-bold text-text truncate text-sm flex items-center gap-2" style={{ paddingLeft: depth * 16 }}>
{hasChildren ? (
<button onClick={() => toggleExpand(node.pid)} className="p-1 hover:bg-surface2 rounded">
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
) : <div className="w-6" />}
<span title={node.name}>{node.name}</span>
</div>
<div className="font-mono text-overlay2 text-xs text-right">{node.pid}</div>
<div className="text-blue font-black text-sm text-right tabular-nums">
{node.cpu_inclusive.toFixed(1)}%
{hasChildren && <span className="block text-[9px] text-overlay1 font-normal">Self: {node.cpu_self.toFixed(1)}%</span>}
</div>
<div className="text-mauve font-black text-sm text-right tabular-nums">
{formatBytes(node.mem_pss_inclusive)}
{hasChildren && <span className="block text-[9px] text-overlay1 font-normal">Self: {formatBytes(node.mem_pss_self)}</span>}
</div>
<div className="flex justify-center">
{node.cpu_inclusive > 50 && <AlertTriangle size={14} className="text-red" />}
</div>
</div>
{hasChildren && isExpanded && node.children.map(child => renderProcessNode(child, depth + 1))}
</Fragment>
);
};
return (
<div className="flex flex-col h-screen bg-base text-text">
<div className="titlebar shrink-0">
<div className="titlebar-drag gap-2 px-4">
<div className="w-7 h-7 rounded-xl bg-gradient-to-br from-mauve via-blue to-teal flex items-center justify-center shadow-xl shadow-mauve/20 border border-white/10">
<Activity size={16} className="text-base" strokeWidth={3} />
</div>
<span className="font-black tracking-tighter uppercase italic text-xl">
Profiling Report
</span>
</div>
<div className="flex gap-4 px-4">
<button
onClick={saveReport}
className="flex items-center gap-2 px-4 py-1.5 bg-surface1/50 hover:bg-surface1 border border-surface2 rounded-xl text-xs font-black transition-all text-text uppercase tracking-widest"
>
<Save size={14} className="text-blue" /> SAVE JSON
</button>
<button
onClick={onBack}
className="flex items-center gap-2 px-4 py-1.5 bg-mauve/10 hover:bg-mauve/20 border border-mauve/30 rounded-xl text-xs font-black transition-all text-mauve uppercase tracking-widest"
>
<ArrowLeft size={14} /> DASHBOARD
</button>
</div>
</div>
<main className="flex-1 overflow-y-auto p-6 flex flex-col gap-6 custom-scrollbar relative">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 shrink-0">
<div className="bg-surface0 p-6 rounded-2xl border border-surface1 shadow-xl text-center">
<div className="text-[10px] font-black text-overlay2 uppercase tracking-widest mb-1 flex items-center justify-center gap-1"><Clock size={10} /> Duration</div>
<div className="text-2xl font-black text-text font-mono tabular-nums">{formatDuration(report.duration_seconds)}</div>
</div>
<div className="bg-surface0 p-6 rounded-2xl border border-surface1 shadow-xl text-center">
<div className="text-[10px] font-black text-overlay2 uppercase tracking-widest mb-1 flex items-center justify-center gap-1"><Target size={10} /> Target</div>
<div className="text-xl font-bold text-text truncate">{report.target_pid ? `PID ${report.target_pid}` : "Whole System"}</div>
</div>
<div className="bg-surface0 p-6 rounded-2xl border border-surface1 shadow-xl text-center">
<div className="text-[10px] font-black text-overlay2 uppercase tracking-widest mb-1 flex items-center justify-center gap-1"><Server size={10} /> Nodes</div>
<div className="text-2xl font-black text-blue tabular-nums">{report.aggregated_tree.length}</div>
</div>
<div className="bg-surface0 p-6 rounded-2xl border border-surface1 shadow-xl text-center">
<div className="text-[10px] font-black text-overlay2 uppercase tracking-widest mb-1 flex items-center justify-center gap-1"><FileJson size={10} /> Snapshots</div>
<div className="text-2xl font-black text-mauve tabular-nums">{report.snapshots.length}</div>
</div>
</div>
<div className="card h-72 border border-surface1/50 shadow-2xl bg-gradient-to-b from-surface0 to-base/50">
<div className="flex justify-between items-center mb-4">
<h3 className="text-sm font-black uppercase italic tracking-widest flex items-center gap-2">
<Activity size={16} className="text-blue" strokeWidth={3} /> Load Over Time
</h3>
</div>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={report.snapshots}>
<defs>
<linearGradient id="cpuReportGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--blue)" stopOpacity={0.6}/>
<stop offset="95%" stopColor="var(--blue)" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--surface1)" vertical={false} opacity={0.5} />
<XAxis dataKey="timestamp" hide />
<YAxis stroke="var(--subtext0)" tick={{fontSize: 9}} />
<Tooltip
contentStyle={{ background: 'var(--mantle)', border: '1px solid var(--surface1)', borderRadius: '12px', fontSize: '11px', fontWeight: 'bold' }}
itemStyle={{ color: 'var(--text)' }}
formatter={(value: number, name: string) => {
if (name === 'mem_used') return [formatBytes(value), 'System RAM'];
if (name === 'cpu_usage') return [`${value.toFixed(1)}%`, 'System CPU'];
return [value, name];
}}
/>
<Area type="monotone" dataKey="cpu_usage" name="CPU %" stroke="var(--blue)" fill="url(#cpuReportGradient)" strokeWidth={3} isAnimationActive={true} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
<div className="card flex-1 flex flex-col min-h-0 border border-surface1/50 shadow-[0_35px_60px_-15px_rgba(0,0,0,0.5)] overflow-hidden">
<div className="flex justify-between items-center mb-6 px-2">
<h3 className="text-xl font-black tracking-tighter uppercase italic flex items-center gap-2">
<Server size={24} className="text-green" strokeWidth={3} /> Hierarchical Analysis
</h3>
<span className="text-[10px] bg-surface1 px-3 py-1.5 rounded-full font-black text-overlay1 uppercase tracking-widest">Averaged across session</span>
</div>
<div className="grid grid-cols-[1fr_80px_100px_120px_100px] gap-4 px-6 py-4 bg-surface1/50 rounded-2xl font-black uppercase tracking-widest text-[10px] text-overlay1 mb-2">
<div>Hierarchy</div>
<div className="text-right">PID</div>
<div className="text-right">Avg CPU</div>
<div className="text-right">Avg PSS</div>
<div className="text-center">Alerts</div>
</div>
<div className="overflow-y-auto flex-1 custom-scrollbar px-2">
{report.aggregated_tree.map(node => renderProcessNode(node))}
</div>
</div>
</main>
</div>
);
}

View File

@@ -2,9 +2,9 @@ export interface ProcessNode {
pid: number;
name: string;
cpu_self: number;
cpu_children: number;
mem_rss: number;
mem_children: number;
cpu_inclusive: number;
mem_pss_self: number;
mem_pss_inclusive: number;
children: ProcessNode[];
}
@@ -12,6 +12,30 @@ export interface GlobalStats {
cpu_total: number;
mem_used: number;
mem_total: number;
process_tree: ProcessNode[];
smart_tree: ProcessNode[];
process_count: number;
is_profiling: boolean;
}
export interface ProfilingReport {
start_time: string;
end_time: string;
duration_seconds: number;
snapshots: ReportSnapshot[];
target_pid?: number;
aggregated_tree: ProcessNode[];
}
export interface ReportSnapshot {
timestamp: string;
cpu_usage: number;
mem_used: number;
top_processes: ProcessSnapshot[];
}
export interface ProcessSnapshot {
pid: number;
name: string;
cpu: number;
pss: number;
}