From 248e265165009eeca659e160e130a9d8f4c13ce4 Mon Sep 17 00:00:00 2001 From: cmliu Date: Thu, 30 Jul 2026 01:53:43 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E7=A7=8D=E6=A0=BC=E5=BC=8F=E7=9A=84=E6=95=B0=E5=AD=97=E5=92=8C?= =?UTF-8?q?=E5=86=85=E5=AD=98=E4=BD=BF=E7=94=A8=E6=83=85=E5=86=B5=E8=A7=A3?= =?UTF-8?q?=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/top-parser.ts | 186 +++++++++++++++++++++++++++++++------- 1 file changed, 155 insertions(+), 31 deletions(-) diff --git a/src/backend/top-parser.ts b/src/backend/top-parser.ts index a5ed431..91d6448 100644 --- a/src/backend/top-parser.ts +++ b/src/backend/top-parser.ts @@ -31,21 +31,37 @@ export interface ProcessSnapshot { const ANSI_ESCAPE_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g; const MAX_PROCESSES = 512; -function finiteNumber(value: string | undefined): number | null { +// Matches a single numeric token that may use ',' or '.' as decimal separator. +const NUMBER_TOKEN_RE = /\d+(?:[.,]\d+)?/; + +function finiteNumber(value: string | undefined | null): number | null { if (!value) return null; const parsed = Number(value.replace(',', '.').replace(/%$/, '')); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; } +// Parses "10,23" / "10.23" / "10" -> number. Returns null on invalid input. +function parseDecimal(token: string | undefined | null): number | null { + if (!token) return null; + if (!NUMBER_TOKEN_RE.test(token)) return null; + // Ensure the whole token is a number (e.g. reject "10abc"). + if (!/^\d+(?:[.,]\d+)?$/.test(token)) return null; + return Number(token.replace(',', '.')); +} + function clampPercent(value: number): number { return Math.min(100, Math.max(0, value)); } function parseLoad(lines: string[]): [number, number, number] | null { for (const line of lines) { - const match = line.match(/load average:\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i); - if (!match) continue; - const values = match.slice(1, 4).map((value) => finiteNumber(value)); + // Linux: "load average: 10.23, 11.92, 12.63" + // FreeBSD: "load averages: 10,23, 11,92, 12,63" + const prefix = line.match(/load averages?\s*:\s*(.+)/i); + if (!prefix) continue; + const tokens = prefix[1].match(/\d+(?:[.,]\d+)?/g); + if (!tokens || tokens.length < 3) continue; + const values = tokens.slice(0, 3).map(parseDecimal); if (values.every((value): value is number => value !== null)) return values as [number, number, number]; } return null; @@ -53,50 +69,148 @@ function parseLoad(lines: string[]): [number, number, number] | null { function parseCPU(lines: string[]): number | null { for (const line of lines) { - if (!/(?:^|\s)(?:%?Cpu\(s\)|CPU):/i.test(line)) continue; - const idle = line.match(/([\d.]+)\s*%?\s*(?:id|idle)\b/i); - const idlePercent = finiteNumber(idle?.[1]); + // Linux: "%Cpu(s): 13.1 us, 0.7 sy, 6.4 ni, 79.6 id, ..." + // FreeBSD: "CPU: 13,1% user, 0,7% nice, 6,4% system, 0,1% interrupt, 79,6% idle" + // macOS: "CPU usage: 13% user, 6% sys, 79% idle" + if (!/(?:^|\s)(?:%?Cpu\(s\)|CPU(?:\s+usage)?|%CPU)\s*:/i.test(line)) continue; + const idle = line.match(/([\d.,]+)\s*%?\s*(?:id|idle)\b/i); + const idlePercent = parseDecimal(idle?.[1]); if (idlePercent !== null) return clampPercent(100 - idlePercent); + // Fallback: sum user + sys (macOS sometimes omits idle). + const user = line.match(/([\d.,]+)\s*%?\s*(?:us(?:er)?|user)\b/i); + const sys = line.match(/([\d.,]+)\s*%?\s*(?:sy(?:s|stem)?|system)\b/i); + const userVal = parseDecimal(user?.[1]); + const sysVal = parseDecimal(sys?.[1]); + if (userVal !== null && sysVal !== null) return clampPercent(userVal + sysVal); } return null; } function unitMultiplier(label: string | undefined): number { const unit = label?.toLowerCase() ?? ''; + if (unit.startsWith('t')) return 1024 ** 4; if (unit.startsWith('g')) return 1024 ** 3; if (unit.startsWith('m')) return 1024 ** 2; if (unit.startsWith('k')) return 1024; return 1; } +// Parses "24G" / "3196K" / "5120M" / "10" -> bytes. Suffix is a single letter (k/m/g/t). +function parseSizeToken(token: string): number | null { + const match = token.match(/^([\d.,]+)\s*([kmgtpe]?)$/i); + if (!match) return null; + const value = parseDecimal(match[1]); + if (value === null) return null; + const suffix = match[2].toLowerCase(); + const power = suffix ? 'kmgtpe'.indexOf(suffix) + 1 : 0; + return Math.round(value * (1024 ** power)); +} + function parseUsage(lines: string[], kind: 'Mem' | 'Swap'): ResourceUsage | null { for (const line of lines) { + // macOS PhysMem (only valid for Mem). + if (kind === 'Mem' && /^\s*PhysMem\s*:/i.test(line)) { + const result = parseMacOSPhysMem(line); + if (result) return result; + continue; + } if (!new RegExp(`(?:^|\\s)(?:KiB|MiB|GiB)?\\s*${kind}\\s*:`, 'i').test(line)) continue; - const unit = line.match(/(?:^|\s)(KiB|MiB|GiB)\s*(?:Mem|Swap)\s*:/i)?.[1]; - const multiplier = unitMultiplier(unit ?? 'KiB'); - const amount = (label: 'total' | 'used' | 'free'): number | null => { - const match = line.match(new RegExp(`([\\d.]+)\\s*([kmgt]?)\\s+${label}\\b`, 'i')); - const value = finiteNumber(match?.[1]); - return value === null ? null : value * (match?.[2] ? unitMultiplier(match[2]) : multiplier); - }; - const total = amount('total'); - const used = amount('used'); - const free = amount('free'); - const resolvedTotal = total ?? (used !== null && free !== null ? used + free : null); - if (resolvedTotal === null || used === null || resolvedTotal < 0) continue; - const totalBytes = Math.round(resolvedTotal); - const usedBytes = Math.min(totalBytes, Math.round(used)); - return { - usedBytes, - totalBytes, - percent: totalBytes > 0 ? clampPercent((usedBytes / totalBytes) * 100) : 0, - }; + + // Try the explicit total/used/free labelled form first (Linux + FreeBSD Swap). + const labelled = parseLabelledUsage(line); + if (labelled) return labelled; + + // Then the FreeBSD category form: "Mem: 24G Active, 22G Inact, 18G Laundry, 51G Wired, 10G Free". + if (kind === 'Mem') { + const categorised = parseFreeBSDMemUsage(line); + if (categorised) return categorised; + } } return null; } +// Handles "X total, Y used, Z free" style lines (Linux, FreeBSD Swap). +function parseLabelledUsage(line: string): ResourceUsage | null { + const unit = line.match(/(?:^|\s)(KiB|MiB|GiB)\s*(?:Mem|Swap)\s*:/i)?.[1]; + const defaultMultiplier = unit ? unitMultiplier(unit) : 1; + + const amount = (label: string): number | null => { + const match = line.match(new RegExp(`([\\d.,]+)\\s*([kmgtpe]i?b?|[kmgtpe]?)\\s+${label}\\b`, 'i')); + if (!match) return null; + const value = parseDecimal(match[1]); + if (value === null) return null; + const suffix = match[2]; + const mult = suffix ? unitMultiplier(suffix) : defaultMultiplier; + return value * mult; + }; + + const total = amount('total'); + const used = amount('used'); + const free = amount('free'); + const resolvedTotal = total ?? (used !== null && free !== null ? used + free : null); + if (resolvedTotal === null || used === null || resolvedTotal < 0) return null; + const totalBytes = Math.round(resolvedTotal); + const usedBytes = Math.min(totalBytes, Math.round(used)); + return { + usedBytes, + totalBytes, + percent: totalBytes > 0 ? clampPercent((usedBytes / totalBytes) * 100) : 0, + }; +} + +// Handles FreeBSD Mem: "24G Active, 22G Inact, 18G Laundry, 51G Wired, 10G Free". +// total = sum of all listed categories; used = total - free. +function parseFreeBSDMemUsage(line: string): ResourceUsage | null { + const memLabels = new Set(['active', 'inact', 'laundry', 'wired', 'buf', 'cache', 'free', 'laund']); + // Match "