fix(local-llm): show accurate macOS memory (app vs cached vs free)
Replace Node.js os.freemem() with vm_stat parsing for macOS. The old approach reported ~47.7 GB / 48 GB 'used' because os.freemem() only counts truly free pages, ignoring ~20 GB of inactive/reclaimable cache. New memory breakdown: - App Memory: active + wired + compressor (actual process usage) - Cached: inactive + purgeable + speculative (reclaimable on demand) - Available: free + cached (what apps can actually use) - Pressure: normal/warning/critical based on app memory ratio Dashboard UI updated to show app memory, cached (reclaimable) label, and pressure-based color coding on progress bars.
This commit is contained in:
parent
b77afce9ae
commit
43f8103c5a
@ -89,23 +89,66 @@ async function getStaticInfo() {
|
||||
return staticCache;
|
||||
}
|
||||
|
||||
// macOS vm_stat gives accurate memory breakdown (os.freemem() excludes reclaimable cache)
|
||||
async function getAccurateMemory(): Promise<{
|
||||
total: number;
|
||||
appMemory: number;
|
||||
cached: number;
|
||||
free: number;
|
||||
pressure: string;
|
||||
}> {
|
||||
const totalMem = os.totalmem();
|
||||
try {
|
||||
const { stdout } = await execAsync('vm_stat', { timeout: 2000 });
|
||||
const pageSize = 16384; // macOS Apple Silicon default
|
||||
const parse = (label: string): number => {
|
||||
const match = stdout.match(new RegExp(`${label}:\\s+(\\d+)`));
|
||||
return match ? parseInt(match[1]) * pageSize : 0;
|
||||
};
|
||||
const active = parse('Pages active');
|
||||
const wired = parse('Pages wired down');
|
||||
const inactive = parse('Pages inactive');
|
||||
const purgeable = parse('Pages purgeable');
|
||||
const speculative = parse('Pages speculative');
|
||||
const free = parse('Pages free');
|
||||
const compressor = parse('Pages occupied by compressor');
|
||||
|
||||
const appMemory = active + wired + compressor;
|
||||
const cached = inactive + purgeable + speculative;
|
||||
const trueFree = free + cached; // macOS reclaims cached on demand
|
||||
|
||||
const ratio = appMemory / totalMem;
|
||||
const pressure = ratio > 0.85 ? 'critical' : ratio > 0.7 ? 'warning' : 'normal';
|
||||
|
||||
return { total: totalMem, appMemory, cached, free: trueFree, pressure };
|
||||
} catch {
|
||||
// Fallback to Node.js (inaccurate on macOS but works everywhere)
|
||||
const freeMem = os.freemem();
|
||||
return {
|
||||
total: totalMem,
|
||||
appMemory: totalMem - freeMem,
|
||||
cached: 0,
|
||||
free: freeMem,
|
||||
pressure: 'unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const [staticInfo, disk, ollamaDisk] = await Promise.all([
|
||||
const [staticInfo, disk, ollamaDisk, memory] = await Promise.all([
|
||||
getStaticInfo(),
|
||||
getDiskSpace(),
|
||||
getOllamaModelsDiskUsage(),
|
||||
getAccurateMemory(),
|
||||
]);
|
||||
|
||||
const totalMem = os.totalmem();
|
||||
const freeMem = os.freemem();
|
||||
const usedMem = totalMem - freeMem;
|
||||
const cpuCount = os.cpus().length;
|
||||
const uptime = os.uptime();
|
||||
|
||||
return NextResponse.json({
|
||||
chip: staticInfo.chip,
|
||||
gpu: staticInfo.gpu,
|
||||
memory: { total: totalMem, used: usedMem, free: freeMem },
|
||||
memory,
|
||||
disk,
|
||||
ollamaDiskUsage: ollamaDisk,
|
||||
cpuCores: cpuCount,
|
||||
|
||||
@ -75,7 +75,7 @@ interface WhisperData {
|
||||
interface SystemData {
|
||||
chip: string;
|
||||
gpu: string;
|
||||
memory: { total: number; used: number; free: number };
|
||||
memory: { total: number; appMemory: number; cached: number; free: number; pressure: string };
|
||||
disk: { total: number; used: number; free: number };
|
||||
ollamaDiskUsage: number;
|
||||
cpuCores: number;
|
||||
@ -428,18 +428,21 @@ export default function Dashboard() {
|
||||
MEMORY
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold">{formatBytes(system?.memory.used || 0)}</span>
|
||||
<span className="text-lg font-bold">{formatBytes(system?.memory.appMemory || 0)}</span>
|
||||
<span className="text-sm ml-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
/ {formatBytes(system?.memory.total || 0)}
|
||||
</span>
|
||||
<p className="text-[10px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{formatBytes(system?.memory.cached || 0)} cached (reclaimable)
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<ProgressBar
|
||||
value={system?.memory.used || 0}
|
||||
value={system?.memory.appMemory || 0}
|
||||
max={system?.memory.total || 1}
|
||||
color={
|
||||
(system?.memory.used || 0) / (system?.memory.total || 1) > 0.85
|
||||
system?.memory.pressure === 'critical'
|
||||
? 'var(--danger)'
|
||||
: (system?.memory.used || 0) / (system?.memory.total || 1) > 0.7
|
||||
: system?.memory.pressure === 'warning'
|
||||
? 'var(--warning)'
|
||||
: 'var(--accent-primary)'
|
||||
}
|
||||
@ -750,14 +753,27 @@ export default function Dashboard() {
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-mono" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{formatBytes(system?.memory.free || 0)} free
|
||||
{formatBytes(system?.memory.free || 0)} avail
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={system?.memory.used || 0}
|
||||
value={system?.memory.appMemory || 0}
|
||||
max={system?.memory.total || 1}
|
||||
color="var(--warning)"
|
||||
color={
|
||||
system?.memory.pressure === 'critical'
|
||||
? 'var(--danger)'
|
||||
: system?.memory.pressure === 'warning'
|
||||
? 'var(--warning)'
|
||||
: 'var(--accent-secondary)'
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="flex justify-between mt-1 text-[10px]"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
<span>App: {formatBytes(system?.memory.appMemory || 0)}</span>
|
||||
<span>Cache: {formatBytes(system?.memory.cached || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user