diff --git a/Assets/readme/popover-cpu-light.png b/Assets/readme/popover-cpu-light.png index 6f4ebca..b584dec 100644 Binary files a/Assets/readme/popover-cpu-light.png and b/Assets/readme/popover-cpu-light.png differ diff --git a/Assets/readme/popover-memory-dark.png b/Assets/readme/popover-memory-dark.png index 4d2f44e..74c6d5f 100644 Binary files a/Assets/readme/popover-memory-dark.png and b/Assets/readme/popover-memory-dark.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index a8c4ac0..30b68f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,10 @@ - 网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。 - DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。 - 主窗口:左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理;指标页右上角直接开关该项的菜单栏显示;宽度与高度都可调整,卡片按比例放大。打开主窗口时应用出现在程序坞中,关闭后回到仅菜单栏运行。 -- CPU 详情:顶部温度、占用、负载三个圆环;负载历史面积图;核心负载按超级核 / 性能核分色;详细信息列出用户、系统、空闲与各类核心平均占用;新增 1 / 5 / 15 分钟平均负载。 -- 内存详情:内存压力仪表(正常、偏高、严重三段加指针)与按 App / 联动 / 压缩分段的占用圆环;内存构成补充已用、可用与交换区。 +- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。 +- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。 - 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。 -- 进程显示应用的本地化名称(如“微信”),详情弹窗的高占用进程列表增加到 8 行。 +- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。 - 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 - CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。 - 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。 diff --git a/Packages/OpenStatsKit/Sources/Metrics/MemorySampler.swift b/Packages/OpenStatsKit/Sources/Metrics/MemorySampler.swift index eeb959e..5368544 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/MemorySampler.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/MemorySampler.swift @@ -28,7 +28,9 @@ public struct MemorySampler { var swap = xsw_usage() var swapSize = MemoryLayout.size - let swapUsed = sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0) == 0 ? swap.xsu_used : 0 + let hasSwap = sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0) == 0 + let swapUsed = hasSwap ? swap.xsu_used : 0 + let swapTotal = hasSwap ? swap.xsu_total : 0 let level = Sysctl.int("kern.memorystatus_vm_pressure_level") ?? 1 return MemoryUsage(total: total, @@ -37,6 +39,10 @@ public struct MemorySampler { compressed: UInt64(stats.compressor_page_count) * pageSize, cached: (UInt64(stats.external_page_count) + purgeable) * pageSize, swapUsed: swapUsed, - pressure: MemoryPressure(rawValue: level) ?? .normal) + swapTotal: swapTotal, + pressure: MemoryPressure(rawValue: level) ?? .normal, + uncompressed: stats.total_uncompressed_pages_in_compressor * pageSize, + swapInBytes: stats.swapins * pageSize, + swapOutBytes: stats.swapouts * pageSize) } } diff --git a/Packages/OpenStatsKit/Sources/Metrics/Models.swift b/Packages/OpenStatsKit/Sources/Metrics/Models.swift index b906189..56354aa 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/Models.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/Models.swift @@ -40,10 +40,21 @@ public struct MemoryUsage: Sendable, Equatable { public var compressed: UInt64 public var cached: UInt64 public var swapUsed: UInt64 + public var swapTotal: UInt64 public var pressure: MemoryPressure + /// 被压缩的内容原本的大小;与 compressed 之差就是压缩省下的内存 + public var uncompressed: UInt64 + /// 开机以来从交换区换入 / 换出的累计字节数,用相邻两次采样算速率 + public var swapInBytes: UInt64 + public var swapOutBytes: UInt64 public var used: UInt64 { app + wired + compressed } public var usedFraction: Double { total == 0 ? 0 : min(1, Double(used) / Double(total)) } + public var available: UInt64 { total > used ? total - used : 0 } + /// 空闲 = 总量 - 已用 - 缓存文件 + public var free: UInt64 { total > used + cached ? total - used - cached : 0 } + public var compressionSavings: UInt64 { uncompressed > compressed ? uncompressed - compressed : 0 } + public var compressionRatio: Double? { compressed > 0 && uncompressed > 0 ? Double(uncompressed) / Double(compressed) : nil } } public struct NetworkInterfaceInfo: Sendable, Equatable { diff --git a/Packages/OpenStatsKit/Sources/Metrics/ProcessSampler.swift b/Packages/OpenStatsKit/Sources/Metrics/ProcessSampler.swift index e5b60d0..4cb9282 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/ProcessSampler.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/ProcessSampler.swift @@ -20,7 +20,8 @@ public struct ProcessSampler { public init() {} /// 返回按 CPU 排序的进程;无权限读取的进程(通常属于 root)会被跳过。 - public mutating func sample(limit: Int = 50) -> [ProcessUsage] { + /// 默认返回全部可读进程:按应用汇总内存时,CPU 很低但内存很大的进程也要算进去 + public mutating func sample(limit: Int = .max) -> [ProcessUsage] { let capacity = Int(proc_listallpids(nil, 0)) + 64 guard capacity > 64 else { return [] } var pids = [pid_t](repeating: 0, count: capacity) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift index 9e58c8a..6dbba22 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift @@ -213,82 +213,134 @@ struct CoreClusterBars: View { } } -/// 分段圆环:各段首尾相接(内存的 App / 联动 / 压缩),剩余为底槽 -struct SegmentedRing: View { - let segments: [(fraction: Double, color: Color)] - var lineWidth: CGFloat = DS.Space.s2 - var size: CGFloat = DS.Space.s16 - @ViewBuilder var center: Center +/// 核心热力图:每行一个核心(按类型分组,高性能档在上),每列一次采样,最新在右。 +/// 颜色越深越忙,超过 85% 用警告色 +struct CoreHeatmap: View { + let topology: CPUTopology + let history: [[Double]] + var columns = 40 + var rowHeight: CGFloat = DS.Space.s1 - DS.Size.stroke + + private static let gap = DS.Size.stroke + private static let groupGap = DS.Space.s1 var body: some View { - ZStack { - Circle().stroke(DS.Palette.track, lineWidth: lineWidth) - ForEach(Array(ranges.enumerated()), id: \.offset) { _, range in - Circle() - .trim(from: range.start, to: range.end) - .stroke(range.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt)) - .rotationEffect(.degrees(-90)) + let clusters = topology.clusters.sorted { $0.id < $1.id } + let height = clusters.reduce(CGFloat(0)) { $0 + CGFloat($1.coreIndices.count) * (rowHeight + Self.gap) } + + CGFloat(max(0, clusters.count - 1)) * Self.groupGap + + HStack(alignment: .top, spacing: DS.Space.s2) { + VStack(alignment: .leading, spacing: Self.groupGap) { + ForEach(clusters) { cluster in + Text(verbatim: cluster.name) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .lineLimit(1) + .frame(height: CGFloat(cluster.coreIndices.count) * (rowHeight + Self.gap), alignment: .center) + } } - center + .fixedSize() + + Canvas { context, size in + let recent = Array(history.suffix(columns)) + let cellWidth = (size.width - Self.gap * CGFloat(columns - 1)) / CGFloat(columns) + let offset = columns - recent.count + var y: CGFloat = 0 + for cluster in clusters { + for core in cluster.coreIndices.reversed() { + for column in 0..= offset ? recent[column - offset][safe: core] : nil + context.fill(Path(rect), with: .color(Self.color(value))) + } + y += rowHeight + Self.gap + } + y += Self.groupGap + } + } + .frame(height: height) } - .padding(lineWidth / 2) - .frame(width: size, height: size) + .accessibilityElement(children: .ignore) + .accessibilityLabel("核心热力图") } - private var ranges: [(start: Double, end: Double, color: Color)] { - var start = 0.0 - return segments.map { segment in - let end = min(1, start + max(0, segment.fraction)) - defer { start = end } - return (start, end, segment.color) + static func color(_ value: Double?) -> Color { + guard let value else { return DS.Palette.track } + if value >= 0.85 { return DS.Palette.warning } + return DS.Palette.primary.opacity(0.08 + 0.92 * min(1, max(0, value))) + } +} + +/// 热力图图例:低 → 高 +struct HeatLegend: View { + var body: some View { + HStack(spacing: DS.Space.s1) { + Text("闲").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + ForEach([0.05, 0.3, 0.55, 0.8, 0.9], id: \.self) { value in + RoundedRectangle(cornerRadius: DS.Radius.sm / 2) + .fill(CoreHeatmap.color(value)) + .frame(width: DS.Space.s2, height: DS.Space.s2) + } + Text("忙").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) } } } -/// 内存压力仪表:半圆弧分正常、偏高、严重三段,指针指向当前档位 -struct PressureGauge: View { - let pressure: MemoryPressure? - var width: CGFloat = DS.Space.s16 +/// 压力走势条:每次采样一段,绿色正常、橙色偏高、红色严重 +struct PressureStrip: View { + let history: [MemoryPressure] + var capacity = MetricsStore.historyCapacity var body: some View { - VStack(spacing: DS.Space.s1) { - Canvas { context, size in - let lineWidth = DS.Space.s2 - DS.Space.s1 / 2 - let radius = min(size.width / 2, size.height) - lineWidth / 2 - let center = CGPoint(x: size.width / 2, y: size.height - DS.Space.s1) - // 角度从左(0°)经过顶部到右(180°) - func point(_ degrees: Double, _ r: CGFloat) -> CGPoint { - let radians = degrees * .pi / 180 - return CGPoint(x: center.x - r * cos(radians), y: center.y - r * sin(radians)) + Canvas { context, size in + let width = size.width / CGFloat(capacity) + let offset = capacity - min(capacity, history.count) + let recent = history.suffix(capacity) + context.clip(to: Path(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: size.height / 2)) + context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(DS.Palette.track)) + for (index, pressure) in recent.enumerated() { + let color: Color = switch pressure { + case .normal: DS.Palette.success + case .warning: DS.Palette.warning + case .critical: DS.Palette.error } - let bands: [(Double, Double, Color)] = [(0, 58, DS.Palette.success), (62, 118, DS.Palette.warning), (122, 180, DS.Palette.error)] - for (start, end, color) in bands { - var arc = Path() - arc.move(to: point(start, radius)) - for step in stride(from: start, through: end, by: 2) { arc.addLine(to: point(step, radius)) } - context.stroke(arc, with: .color(color), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) - } - let angle: Double = switch pressure { - case .warning: 90 - case .critical: 150 - default: 30 - } - var needle = Path() - needle.move(to: center) - needle.addLine(to: point(angle, radius * 0.72)) - context.stroke(needle, with: .color(DS.Palette.textPrimary), - style: StrokeStyle(lineWidth: DS.Size.chartLine * 2, lineCap: .round)) - let dot = DS.Space.s1 + DS.Space.s1 / 2 - context.fill(Path(ellipseIn: CGRect(x: center.x - dot / 2, y: center.y - dot / 2, width: dot, height: dot)), - with: .color(DS.Palette.textPrimary)) + // 多画 1pt 盖住相邻段之间的缝 + let rect = CGRect(x: CGFloat(offset + index) * width, y: 0, width: width + DS.Size.stroke, height: size.height) + context.fill(Path(rect), with: .color(color)) } - .frame(width: width, height: width / 2 + DS.Space.s1) - Text(pressure.map { "压力\($0.title)" } ?? "—") - .dsFont(.xs, weight: .semibold) - .foregroundStyle(DS.Palette.textSecondary) } - .accessibilityElement(children: .ignore) - .accessibilityLabel("内存压力 \(pressure?.title ?? "未知")") + .frame(height: DS.Space.s2) + .accessibilityElement() + .accessibilityLabel("最近的内存压力走势") + } +} + +/// 横向水位条:各段按大小依次排开,段与段之间留 1pt 缝 +struct WaterlineBar: View { + let segments: [(value: Double, color: Color)] + let total: Double + var height: CGFloat = DS.Space.s4 + DS.Space.s1 + + var body: some View { + Canvas { context, size in + context.clip(to: Path(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: DS.Radius.sm)) + context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(DS.Palette.track)) + var x: CGFloat = 0 + for segment in segments where segment.value > 0 { + let width = size.width * CGFloat(segment.value / max(total, 1)) + context.fill(Path(CGRect(x: x, y: 0, width: max(0, width - DS.Size.stroke), height: size.height)), + with: .color(segment.color)) + x += width + } + } + .frame(height: height) + .accessibilityHidden(true) + } +} + +extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift index e415d77..f04bea1 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift @@ -27,107 +27,131 @@ struct CPUPopover: View { let settings = model.settings let store = model.store let cpu = store.cpu - let temperature = store.sensors?.temperature(.cpu) - let load = cpu?.loadAverage.first let cores = Double(max(1, store.topology.logicalCores)) - // 三个圆环:温度、总占用、1 分钟负载(按逻辑核心数归一) - Card(padding: DS.Space.s3, spacing: DS.Space.s2) { - HStack(alignment: .center, spacing: DS.Space.s3) { - LabeledRing(title: "温度", fraction: (temperature?.maximum ?? 0) / DS.Thermal.scaleMax, - color: temperature.map { Tone.forTemperature($0.maximum).color } ?? DS.Palette.primary, - value: temperature.map { "\(Int($0.maximum.rounded()))°" } ?? "—") - .frame(maxWidth: .infinity) - LabeledRing(title: "占用", fraction: cpu?.total ?? 0, color: loadTone(cpu?.total ?? 0).color, - value: cpu.map { Format.percent($0.total) } ?? "—", large: true) - .frame(maxWidth: .infinity) - LabeledRing(title: "负载", fraction: (load ?? 0) / cores, color: loadTone((load ?? 0) / cores).color, - value: load.map { $0.formatted(.number.precision(.fractionLength(2))) } ?? "—") - .frame(maxWidth: .infinity) - } - } + CPUHero() ForEach(MenuBarItem.cpu.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in switch section { - case .cpuHistory: - SectionCard(title: section.title, trailing: { Text("最近 60 秒") }) { - LineHistoryChart(values: store.cpuTotal.elements, height: detailChartHeight(isDetailPage)) + case .cpuHeatmap: + SectionCard(title: section.title, trailing: { HeatLegend() }) { + CoreHeatmap(topology: store.topology, history: store.coreHistory.elements, + columns: isDetailPage ? 60 : 36, + rowHeight: isDetailPage ? DS.Space.s2 : DS.Space.s1 - DS.Size.stroke) + Text("每行一个核心,每列一次采样,最新的在右边") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) } - case .cpuCores: + case .cpuClusters: SectionCard(title: section.title) { - CoreClusterBars(topology: store.topology, perCore: cpu?.perCore ?? [], - barHeight: isDetailPage ? DS.Space.s16 : DS.Space.s12, byCluster: true) - } - case .cpuDetails: - SectionCard(title: section.title) { - LegendRow(color: DS.Palette.primary, label: "用户", value: cpu.map { Format.percent($0.user) } ?? "—") - LegendRow(color: DS.Palette.secondary, label: "系统", value: cpu.map { Format.percent($0.system) } ?? "—") - LegendRow(color: DS.Palette.track, label: "空闲", value: cpu.map { Format.percent(max(0, 1 - $0.total)) } ?? "—") ForEach(store.topology.clusters) { cluster in - LegendRow(color: DS.Palette.cluster(cluster.id), label: "\(cluster.name)平均", - value: CoreClusterBars.average(of: cluster, perCore: cpu?.perCore ?? []).map { Format.percent($0) } ?? "—") + let average = CoreClusterBars.average(of: cluster, perCore: cpu?.perCore ?? []) ?? 0 + ShareRow(label: "\(cluster.name) · \(cluster.coreIndices.count) 核", value: Format.percent(average), + fraction: average, color: DS.Palette.cluster(cluster.id)) } - InfoRow(label: "处理器", text: "\(store.topology.brand) · \(store.topology.logicalCores) 核") - if let boot = store.system.bootDate { - InfoRow(label: "已运行", text: Format.uptime(since: boot)) + if let busiest = busiestCore(store.topology, cpu?.perCore ?? []) { + InfoRow(label: "最忙的核心", text: busiest) } } case .cpuLoadAverage: - SectionCard(title: section.title, trailing: { Text(verbatim: "\(store.topology.logicalCores) 核") }) { + SectionCard(title: section.title, trailing: { Text(loadTrend(cpu?.loadAverage ?? [])) }) { let averages = cpu?.loadAverage ?? [] ForEach(Array(["1 分钟", "5 分钟", "15 分钟"].enumerated()), id: \.offset) { index, label in - let value = index < averages.count ? averages[index] : nil - VStack(spacing: DS.Space.s1) { - InfoRow(label: label, text: value.map { $0.formatted(.number.precision(.fractionLength(2))) } ?? "—") - ProgressTrack(fraction: (value ?? 0) / cores, color: loadTone((value ?? 0) / cores).color, height: DS.Space.s1) - } + let value = averages[safe: index] ?? 0 + ShareRow(label: label, + value: "每核 \((value / cores).formatted(.number.precision(.fractionLength(2))))", + fraction: value / cores, color: loadTone(value / cores).color) } + Text("平均负载除以核心数:小于 1 表示任务不用排队,大于 1 表示有任务在等 CPU") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) } - case .cpuProcesses: + case .cpuApps: SectionCard(title: section.title, trailing: { Text("CPU") }) { - let processes = store.processes - ProcessList(count: processes.count, rowCount: 8) { index in - let process = processes[index] - HStack(spacing: DS.Space.s2) { - ProcessNameLabel(icon: AppIconCache.shared.image(for: process), name: process.displayName) - Text(verbatim: "\((process.cpu * 100).formatted(.number.precision(.fractionLength(1))))%") - .dsFont(.xs, weight: .medium) - .monospacedDigit() - .foregroundStyle(DS.Palette.textSecondary) - } - .explainable(.init(process)) - } + AppUsageList(apps: AppUsage.group(store.processes).sorted { $0.cpu > $1.cpu }, + rowCount: isDetailPage ? 10 : 6, metric: .cpu) } default: EmptyView() } } } + + private func busiestCore(_ topology: CPUTopology, _ perCore: [Double]) -> String? { + guard let (index, value) = perCore.enumerated().max(by: { $0.element < $1.element }).map({ ($0.offset, $0.element) }), + let cluster = topology.clusters.first(where: { $0.coreIndices.contains(index) }) else { return nil } + return "\(cluster.name) #\(index + 1) · \(Format.percent(value))" + } + + private func loadTrend(_ averages: [Double]) -> String { + guard averages.count >= 3, averages[2] > 0 else { return "" } + let change = averages[0] / averages[2] + return change > 1.15 ? "负载在上升" : change < 0.85 ? "负载在下降" : "负载平稳" + } } -/// 圆环 + 中间数值 + 下方标题 -private struct LabeledRing: View { - let title: String - let fraction: Double - let color: Color - let value: String - var large = false +/// CPU 顶部:大号占用、状态、与 30 秒前相比的变化、温度余量,下面是走势线和用户 / 系统 / 空闲构成 +private struct CPUHero: View { + @Environment(AppModel.self) private var model var body: some View { - VStack(spacing: DS.Space.s1) { - RingGauge(fraction: fraction, color: color, - lineWidth: large ? DS.Space.s2 : DS.Space.s1 + DS.Space.s1 / 2, - size: large ? DS.Space.s16 + DS.Space.s2 : DS.Space.s12 + DS.Space.s1) { - Text(verbatim: value) - .dsFont(large ? .base : .xs, weight: .semibold) - .monospacedDigit() - .foregroundStyle(DS.Palette.textPrimary) - .minimumScaleFactor(0.7) - .lineLimit(1) + let store = model.store + let cpu = store.cpu + let total = cpu?.total ?? 0 + let history = store.cpuTotal.elements + let temperature = store.sensors?.temperature(.cpu) + + Card(padding: DS.Space.s3, spacing: DS.Space.s2) { + HStack(alignment: .center, spacing: DS.Space.s3) { + HeroValue(value: cpu.map { "\(Int(($0.total * 100).rounded()))" } ?? "—", unit: "%", size: .xxl) + VStack(alignment: .leading, spacing: DS.Space.s1) { + StatusBadge(text: status(total), tone: total >= 0.85 ? .error : total >= 0.6 ? .warning : .success) + Text(verbatim: trend(history)) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .monospacedDigit() + } + Spacer(minLength: DS.Space.s2) + if let temperature { + VStack(alignment: .trailing, spacing: 0) { + Text("温度余量").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + Text(verbatim: "\(Int(max(0, 100 - temperature.maximum).rounded()))°C") + .dsFont(.base, weight: .semibold) + .monospacedDigit() + .foregroundStyle(Tone.forTemperature(temperature.maximum).color) + Text(verbatim: "当前 \(Format.temperature(temperature.maximum, fahrenheit: model.settings.useFahrenheit))") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + } + .help("离 100°C 还差多少度;越接近 0,越可能因过热降频") + } + } + LineHistoryChart(values: history, height: DS.Space.s8) + if let cpu { + WaterlineBar(segments: [(cpu.user, DS.Palette.primary), (cpu.system, DS.Palette.secondary)], + total: 1, height: DS.Space.s2) + HStack(spacing: DS.Space.s3) { + LegendItem(color: DS.Palette.primary, label: "用户", value: Format.percent(cpu.user)) + LegendItem(color: DS.Palette.secondary, label: "系统", value: Format.percent(cpu.system)) + LegendItem(color: DS.Palette.track, label: "空闲", value: Format.percent(max(0, 1 - cpu.total))) + } } - Text(title).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) } } + + private func status(_ total: Double) -> String { + total >= 0.85 ? "满载" : total >= 0.6 ? "繁忙" : total >= 0.25 ? "适中" : "空闲" + } + + /// 与大约 30 秒前(第 15 个采样之前)相比的变化 + private func trend(_ history: [Double]) -> String { + guard history.count > 15, let now = history.last else { return "正在收集走势…" } + let before = history[history.count - 16] + let delta = Int(((now - before) * 100).rounded()) + if abs(delta) < 3 { return "与 30 秒前持平" } + return delta > 0 ? "比 30 秒前高 \(delta)%" : "比 30 秒前低 \(-delta)%" + } } // MARK: - 内存 @@ -141,73 +165,208 @@ struct MemoryPopover: View { let store = model.store let memory = store.memory - // 左边内存压力仪表,右边按 App / 联动 / 压缩分段的占用圆环 - Card(padding: DS.Space.s3, spacing: DS.Space.s2) { - HStack(alignment: .center, spacing: DS.Space.s3) { - PressureGauge(pressure: memory?.pressure) - .frame(maxWidth: .infinity) - SegmentedRing(segments: memory.map(segments) ?? [], size: DS.Space.s16 + DS.Space.s2) { - VStack(spacing: 0) { - Text(verbatim: memory.map { Format.percent($0.usedFraction) } ?? "—") - .dsFont(.base, weight: .semibold) - .monospacedDigit() - .foregroundStyle(DS.Palette.textPrimary) - Text(verbatim: memory.map { Format.bytes($0.total) } ?? "") - .dsFont(.xs) - .foregroundStyle(DS.Palette.textTertiary) - } - } - .frame(maxWidth: .infinity) - } - } + MemoryHero() ForEach(MenuBarItem.memory.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in switch section { - case .memoryHistory: - SectionCard(title: section.title, trailing: { Text("最近 60 秒") }) { - LineHistoryChart(values: store.memoryHistory.elements, height: detailChartHeight(isDetailPage)) - } - case .memoryBreakdown: - SectionCard(title: section.title) { + case .memoryWaterline: + SectionCard(title: section.title, trailing: { Text(verbatim: memory.map { Format.bytes($0.total) } ?? "") }) { if let memory { - InfoRow(label: "已用", text: "\(Format.bytes(memory.used)) / \(Format.bytes(memory.total))") - MemoryBar(memory: memory) - LegendRow(color: DS.Palette.primary, label: "App 内存", value: Format.bytes(memory.app)) - LegendRow(color: DS.Palette.secondary, label: "联动内存", value: Format.bytes(memory.wired)) - LegendRow(color: DS.Palette.warning, label: "被压缩", value: Format.bytes(memory.compressed)) - LegendRow(color: DS.Palette.track, label: "可用", - value: Format.bytes(memory.total > memory.used ? memory.total - memory.used : 0)) - LegendRow(color: DS.Palette.neutral300, label: "缓存文件", value: Format.bytes(memory.cached)) - InfoRow(label: "交换区", text: Format.bytes(memory.swapUsed)) + let parts: [(String, UInt64, Color)] = [ + ("App", memory.app, DS.Palette.primary), + ("联动", memory.wired, DS.Palette.secondary), + ("压缩", memory.compressed, DS.Palette.warning), + ("缓存", memory.cached, DS.Palette.primarySoft), + ("空闲", memory.free, DS.Palette.track), + ] + WaterlineBar(segments: parts.map { (Double($0.1), $0.2) }, total: Double(memory.total)) + LazyVGrid(columns: [GridItem(.flexible(), spacing: DS.Space.s3), GridItem(.flexible())], spacing: DS.Space.s2) { + ForEach(parts, id: \.0) { part in + HStack(spacing: DS.Space.s1) { + RoundedRectangle(cornerRadius: DS.Radius.sm / 2).fill(part.2) + .frame(width: DS.Size.barHeight, height: DS.Size.barHeight) + Text(part.0).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + Spacer(minLength: DS.Space.s1) + Text(verbatim: Format.bytes(part.1)).dsFont(.xs, weight: .medium).monospacedDigit() + .foregroundStyle(DS.Palette.textPrimary) + } + } + } } PurgeMemoryRow() } - case .memoryProcesses: - SectionCard(title: section.title, trailing: { Text("内存") }) { - let processes = store.processes.sorted { $0.memory > $1.memory } - ProcessList(count: processes.count, rowCount: 8) { index in - let process = processes[index] - HStack(spacing: DS.Space.s2) { - ProcessNameLabel(icon: AppIconCache.shared.image(for: process), name: process.displayName) - Text(verbatim: Format.bytes(process.memory)) - .dsFont(.xs, weight: .medium) - .monospacedDigit() - .foregroundStyle(DS.Palette.textSecondary) + case .memoryCompression: + SectionCard(title: section.title) { + if let memory { + InfoRow(label: "压缩为你省下") { + Text(verbatim: Format.bytes(memory.compressionSavings)).foregroundStyle(DS.Palette.success) + } + InfoRow(label: "压缩比", text: memory.compressionRatio.map { "\($0.formatted(.number.precision(.fractionLength(1))))×" } ?? "—") + InfoRow(label: "交换区", text: memory.swapTotal > 0 + ? "\(Format.bytes(memory.swapUsed)) / \(Format.bytes(memory.swapTotal))" : "未使用") + InfoRow(label: "换入 / 换出", text: store.swapRate.map { "\(Format.menuBarRate($0.swapIn)) / \(Format.menuBarRate($0.swapOut))" } ?? "—") + if let rate = store.swapRate, rate.swapOut > 1024 * 1024 { + InfoBanner(icon: "exclamationmark.triangle.fill", text: "系统正在把内存写到磁盘,可能会变慢。可以关掉占用大的应用。", tone: .warning) } - .explainable(.init(process)) } } + case .memoryApps: + SectionCard(title: section.title, trailing: { Text("内存") }) { + AppUsageList(apps: AppUsage.group(store.processes).sorted { $0.memory > $1.memory }, + rowCount: isDetailPage ? 10 : 6, metric: .memory, total: memory?.used) + } default: EmptyView() } } } +} - private func segments(_ memory: MemoryUsage) -> [(fraction: Double, color: Color)] { - let total = max(1, Double(memory.total)) - return [(Double(memory.app) / total, DS.Palette.primary), - (Double(memory.wired) / total, DS.Palette.secondary), - (Double(memory.compressed) / total, DS.Palette.warning)] +/// 内存顶部:可用多少、压力状态、最近一分钟的压力走势 +private struct MemoryHero: View { + @Environment(AppModel.self) private var model + + var body: some View { + let store = model.store + let memory = store.memory + + Card(padding: DS.Space.s3, spacing: DS.Space.s2) { + HStack(alignment: .center, spacing: DS.Space.s3) { + VStack(alignment: .leading, spacing: 0) { + Text("还可用").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + HeroValue(value: memory.map { availableNumber($0.available) } ?? "—", unit: "GB", size: .xxl) + } + Spacer(minLength: DS.Space.s2) + VStack(alignment: .trailing, spacing: DS.Space.s1) { + StatusBadge(text: "压力\(memory?.pressure.title ?? "—")", tone: tone(memory?.pressure)) + Text(verbatim: memory.map { "已用 \(Format.bytes($0.used)) · \(Format.percent($0.usedFraction))" } ?? "") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .monospacedDigit() + } + } + PressureStrip(history: store.pressureHistory.elements) + HStack { + Text("压力走势").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + Spacer() + Text("最近 60 秒").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + } + } + + private func availableNumber(_ bytes: UInt64) -> String { + (Double(bytes) / 1_073_741_824).formatted(.number.precision(.fractionLength(1))) + } + + private func tone(_ pressure: MemoryPressure?) -> Tone { + switch pressure { + case .normal: .success + case .warning: .warning + case .critical: .error + case nil: .neutral + } + } +} + +// MARK: - 按应用汇总 + +/// 同一个应用的主进程与辅助进程合并计算 +struct AppUsage: Identifiable { + let id: String + let name: String + let bundlePath: String? + var cpu: Double + var memory: UInt64 + var processCount: Int + /// 占用最高的那个进程,用于图标与 Apple 智能解释 + var representative: ProcessUsage + + @MainActor + static func group(_ processes: [ProcessUsage]) -> [AppUsage] { + var groups: [String: AppUsage] = [:] + for process in processes { + let key = process.appBundlePath ?? "pid-\(process.pid)" + if var group = groups[key] { + group.cpu += process.cpu + group.memory += process.memory + group.processCount += 1 + if process.memory > group.representative.memory { group.representative = process } + groups[key] = group + } else { + let name = process.appBundlePath.map { AppNameCache.shared.name(forBundle: $0) } ?? process.displayName + groups[key] = AppUsage(id: key, name: name, bundlePath: process.appBundlePath, + cpu: process.cpu, memory: process.memory, processCount: 1, representative: process) + } + } + return Array(groups.values) + } +} + +private struct AppUsageList: View { + enum Metric { case cpu, memory } + + let apps: [AppUsage] + let rowCount: Int + let metric: Metric + var total: UInt64? + + var body: some View { + let visible = Array(apps.prefix(rowCount)) + // 横条以列表里最大的一项为满格,比较谁占得多 + let peak = visible.map { metric == .cpu ? $0.cpu : Double($0.memory) }.max() ?? 1 + if visible.isEmpty { + Text("正在统计…").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + ForEach(visible) { app in + let value = metric == .cpu ? app.cpu : Double(app.memory) + VStack(spacing: DS.Space.s1) { + HStack(spacing: DS.Space.s2) { + AppIconCache.shared.image(bundlePath: app.bundlePath) + .resizable() + .frame(width: DS.Size.iconInline, height: DS.Size.iconInline) + Text(verbatim: app.name) + .dsFont(.xs, weight: .medium) + .foregroundStyle(DS.Palette.textPrimary) + .lineLimit(1) + if app.processCount > 1 { + Text(verbatim: "\(app.processCount) 个进程").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + Spacer(minLength: DS.Space.s2) + Text(verbatim: valueText(app)) + .dsFont(.xs, weight: .medium) + .monospacedDigit() + .foregroundStyle(DS.Palette.textSecondary) + } + ProgressTrack(fraction: peak > 0 ? value / peak : 0, color: DS.Palette.primary, height: DS.Space.s1) + .padding(.leading, DS.Size.iconInline + DS.Space.s2) + } + .explainable(.init(app.representative)) + } + } + + private func valueText(_ app: AppUsage) -> String { + switch metric { + case .cpu: + return "\((app.cpu * 100).formatted(.number.precision(.fractionLength(1))))%" + case .memory: + guard let total, total > 0 else { return Format.bytes(app.memory) } + return "\(Format.bytes(app.memory)) · \(Format.percent(Double(app.memory) / Double(total)))" + } + } +} + +/// 标签 + 数值 + 横条 +private struct ShareRow: View { + let label: String + let value: String + let fraction: Double + let color: Color + + var body: some View { + VStack(spacing: DS.Space.s1) { + InfoRow(label: label, text: value) + ProgressTrack(fraction: fraction, color: color, height: DS.Space.s1 + DS.Space.s1 / 2) + } } } @@ -238,49 +397,8 @@ private struct ExplainableRow: View { } } -private struct LegendRow: View { - let color: Color - let label: String - let value: String - - var body: some View { - HStack(spacing: DS.Space.s2) { - RoundedRectangle(cornerRadius: DS.Radius.sm / 2).fill(color) - .frame(width: DS.Size.barHeight, height: DS.Size.barHeight) - Text(label).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) - Spacer(minLength: DS.Space.s2) - Text(verbatim: value).dsFont(.xs, weight: .medium).monospacedDigit().foregroundStyle(DS.Palette.textPrimary) - } - } -} - -/// App / 联动 / 压缩三段堆叠条,剩余部分为可用 -private struct MemoryBar: View { - let memory: MemoryUsage - - var body: some View { - GeometryReader { proxy in - let total = max(1, Double(memory.total)) - let parts: [(UInt64, Color)] = [(memory.app, DS.Palette.primary), (memory.wired, DS.Palette.secondary), - (memory.compressed, DS.Palette.warning)] - HStack(spacing: DS.Size.stroke) { - ForEach(Array(parts.enumerated()), id: \.offset) { _, part in - Rectangle().fill(part.1) - .frame(width: max(0, proxy.size.width * Double(part.0) / total - DS.Size.stroke)) - } - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity) - .background(DS.Palette.track) - .clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm)) - } - .frame(height: DS.Space.s3) - } -} - private struct PurgeMemoryRow: View { @Environment(AppModel.self) private var model - @Environment(\.isDetailPage) private var isDetailPage var body: some View { let maintenance = model.maintenance diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index 7681f3c..dab9560 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -55,8 +55,8 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { /// 点击该项弹出的详情里可以显示的内容,按显示顺序排列 var popoverSections: [PopoverSection] { switch self { - case .cpu: [.cpuHistory, .cpuCores, .cpuDetails, .cpuLoadAverage, .cpuProcesses] - case .memory: [.memoryHistory, .memoryBreakdown, .memoryProcesses] + case .cpu: [.cpuHeatmap, .cpuClusters, .cpuLoadAverage, .cpuApps] + case .memory: [.memoryWaterline, .memoryCompression, .memoryApps] case .network: [.networkHistory, .networkProbe, .networkInterface, .networkAddresses, .networkDNS, .networkProcesses] case .gpu: [.gpuHistory, .gpuDetails] case .temperature: [.thermalSensors, .thermalFans] @@ -81,8 +81,8 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { /// 详情弹窗里可以单独隐藏的区块 public enum PopoverSection: String, CaseIterable, Identifiable, Sendable { - case cpuHistory, cpuCores, cpuDetails, cpuLoadAverage, cpuProcesses - case memoryHistory, memoryBreakdown, memoryProcesses + case cpuHeatmap, cpuClusters, cpuLoadAverage, cpuApps + case memoryWaterline, memoryCompression, memoryApps case networkHistory, networkProbe, networkInterface, networkAddresses, networkDNS, networkProcesses case gpuHistory, gpuDetails case thermalSensors, thermalFans @@ -91,13 +91,13 @@ public enum PopoverSection: String, CaseIterable, Identifiable, Sendable { var title: String { switch self { - case .cpuHistory: "负载历史" - case .cpuCores: "核心负载" - case .cpuDetails: "详细信息" - case .cpuLoadAverage: "平均负载" - case .cpuProcesses, .memoryProcesses, .networkProcesses: "高占用进程" - case .memoryHistory: "使用历史" - case .memoryBreakdown: "内存构成" + case .cpuHeatmap: "核心热力图" + case .cpuClusters: "核心分工" + case .cpuLoadAverage: "排队程度" + case .cpuApps, .memoryApps: "按应用汇总" + case .networkProcesses: "高占用进程" + case .memoryWaterline: "内存水位" + case .memoryCompression: "压缩与交换" case .networkHistory: "流量历史" case .networkProbe: "连接探测" case .networkInterface: "接口" diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift index 30a6858..f0dce27 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift @@ -14,9 +14,15 @@ public final class MetricsStore { public private(set) var cpu: CPULoad? public private(set) var cpuTotal = History(capacity: historyCapacity) + /// 每次采样各核心的占用,用于核心热力图 + public private(set) var coreHistory = History<[Double]>(capacity: historyCapacity) public private(set) var memory: MemoryUsage? public private(set) var memoryHistory = History(capacity: historyCapacity) + public private(set) var pressureHistory = History(capacity: historyCapacity) + /// 交换区换入 / 换出速率(字节 / 秒) + public private(set) var swapRate: (swapIn: Double, swapOut: Double)? + @ObservationIgnored private var lastSwap: (date: Date, swapIn: UInt64, swapOut: UInt64)? public private(set) var network: NetworkRate? public private(set) var downloadHistory = History(capacity: historyCapacity) public private(set) var uploadHistory = History(capacity: historyCapacity) @@ -38,10 +44,18 @@ public final class MetricsStore { if let cpu = snapshot.cpu { self.cpu = cpu cpuTotal.append(cpu.total) + coreHistory.append(cpu.perCore) } if let memory = snapshot.memory { self.memory = memory memoryHistory.append(memory.usedFraction) + pressureHistory.append(memory.pressure) + if let last = lastSwap, snapshot.date > last.date { + let seconds = snapshot.date.timeIntervalSince(last.date) + swapRate = (Double(memory.swapInBytes >= last.swapIn ? memory.swapInBytes - last.swapIn : 0) / seconds, + Double(memory.swapOutBytes >= last.swapOut ? memory.swapOutBytes - last.swapOut : 0) / seconds) + } + lastSwap = (snapshot.date, memory.swapInBytes, memory.swapOutBytes) } if let network = snapshot.network { self.network = network diff --git a/README.md b/README.md index 9072fef..1e3847a 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,10 @@ Not released yet · **38** changes in development · [full changelog](CHANGELOG. - 网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。 - DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。 - 主窗口:左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理;指标页右上角直接开关该项的菜单栏显示;宽度与高度都可调整,卡片按比例放大。打开主窗口时应用出现在程序坞中,关闭后回到仅菜单栏运行。 -- CPU 详情:顶部温度、占用、负载三个圆环;负载历史面积图;核心负载按超级核 / 性能核分色;详细信息列出用户、系统、空闲与各类核心平均占用;新增 1 / 5 / 15 分钟平均负载。 -- 内存详情:内存压力仪表(正常、偏高、严重三段加指针)与按 App / 联动 / 压缩分段的占用圆环;内存构成补充已用、可用与交换区。 +- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。 +- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。 - 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。 -- 进程显示应用的本地化名称(如“微信”),详情弹窗的高占用进程列表增加到 8 行。 +- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。 - 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 - CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。 - 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。 @@ -139,8 +139,10 @@ Not released yet · **38** changes in development · [full changelog](CHANGELOG. **Detail popovers** — each metric gets its own menu-bar item; click it for a narrow popover. Choose which sections each popover shows in Settings. `Esc` closes it. -- **CPU.** Load history, per-core load grouped by core type, processor info, busiest processes. -- **Memory.** Usage history, breakdown with one-click purge, busiest processes. +- **CPU.** Usage with a status and 30-second change, thermal headroom; a per-core heatmap; load by core type; + queueing (load average per core, rising or falling); usage summed by app. +- **Memory.** What is still available with a pressure timeline; a waterline bar; memory saved by compression and + live swap I/O; usage summed by app. - **Network.** Traffic history; a connection-probe grid (latency, jitter, loss); interface, Wi-Fi signal, VPN / proxy; local and public IPv4 / IPv6 with a flag, region and ASN; DNS flush and one-click switching to Cloudflare, Google, Tencent, Alibaba Cloud or manual servers; diff --git a/README.zh-CN.md b/README.zh-CN.md index 89cf2ff..07924c1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -60,10 +60,10 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/ - 网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。 - DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。 - 主窗口:左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理;指标页右上角直接开关该项的菜单栏显示;宽度与高度都可调整,卡片按比例放大。打开主窗口时应用出现在程序坞中,关闭后回到仅菜单栏运行。 -- CPU 详情:顶部温度、占用、负载三个圆环;负载历史面积图;核心负载按超级核 / 性能核分色;详细信息列出用户、系统、空闲与各类核心平均占用;新增 1 / 5 / 15 分钟平均负载。 -- 内存详情:内存压力仪表(正常、偏高、严重三段加指针)与按 App / 联动 / 压缩分段的占用圆环;内存构成补充已用、可用与交换区。 +- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。 +- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。 - 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。 -- 进程显示应用的本地化名称(如“微信”),详情弹窗的高占用进程列表增加到 8 行。 +- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。 - 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 - CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。 - 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。 @@ -134,8 +134,8 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/

菜单栏

**详情弹窗**——每个指标一个菜单栏图标,点击弹出该项的窄详情,显示哪些区块可在设置里勾选;按 `Esc` 关闭。 -- **CPU**:负载历史、按超级核 / 性能核 / 能效核分组的核心负载、处理器信息、高占用进程。 -- **内存**:使用历史、内存构成与一键释放、高占用进程。 +- **CPU**:占用与状态、与 30 秒前的变化、温度余量;核心热力图;核心分工;排队程度(每核平均负载与趋势);按应用汇总。 +- **内存**:还可用多少与压力走势;内存水位条;压缩省下的内存与交换区读写;按应用汇总。 - **网络**:流量历史;连接探测格子(延迟、抖动、丢包);接口、Wi-Fi 信号、VPN / 代理;本地与公网 IPv4 / IPv6, 归属地国旗、ASN;DNS 一键刷新,一键切换 Cloudflare、Google、腾讯、阿里云或手动填写;各进程流量。 - **GPU**、**温度与风扇**:使用历史、各组温度、风扇转速与快捷模式。