CPU 与内存详情改为 OpenStats 自己的设计:热力图、水位条、压力走势与按应用汇总
- CPU:占用、状态、30 秒变化与温度余量;核心热力图;核心分工与最忙核心;排队程度(每核平均负载与趋势);按应用汇总 - 内存:还可用多少与压力走势条;内存水位条;压缩省下的内存、压缩比与交换区实时读写;按应用汇总 - 数据层新增各核心占用历史、内存压力历史、压缩前大小与交换区换入换出累计量;进程采样返回全部可读进程 - 去掉仿 Stats 的圆环与仪表组件
This commit is contained in:
1 parent
50d04ebda8
commit
3caf690987
12 files changed
+455
-251
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 204 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 162 KiB |
+3
-3
@@ -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、内存、电池和掌托温度。
|
||||
|
||||
@@ -28,7 +28,9 @@ public struct MemorySampler {
|
||||
|
||||
var swap = xsw_usage()
|
||||
var swapSize = MemoryLayout<xsw_usage>.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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -213,82 +213,134 @@ struct CoreClusterBars: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 分段圆环:各段首尾相接(内存的 App / 联动 / 压缩),剩余为底槽
|
||||
struct SegmentedRing<Center: View>: 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..<columns {
|
||||
let rect = CGRect(x: CGFloat(column) * (cellWidth + Self.gap), y: y, width: cellWidth, height: rowHeight)
|
||||
let value = column >= 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Content: View>: 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
|
||||
|
||||
@@ -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: "接口"
|
||||
|
||||
@@ -14,9 +14,15 @@ public final class MetricsStore {
|
||||
|
||||
public private(set) var cpu: CPULoad?
|
||||
public private(set) var cpuTotal = History<Double>(capacity: historyCapacity)
|
||||
/// 每次采样各核心的占用,用于核心热力图
|
||||
public private(set) var coreHistory = History<[Double]>(capacity: historyCapacity)
|
||||
|
||||
public private(set) var memory: MemoryUsage?
|
||||
public private(set) var memoryHistory = History<Double>(capacity: historyCapacity)
|
||||
public private(set) var pressureHistory = History<MemoryPressure>(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<Double>(capacity: historyCapacity)
|
||||
public private(set) var uploadHistory = History<Double>(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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+5
-5
@@ -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:/
|
||||
<p align="center"><img src="Assets/readme/menubar-dark.png" width="600" alt="菜单栏"></p>
|
||||
|
||||
**详情弹窗**——每个指标一个菜单栏图标,点击弹出该项的窄详情,显示哪些区块可在设置里勾选;按 `Esc` 关闭。
|
||||
- **CPU**:负载历史、按超级核 / 性能核 / 能效核分组的核心负载、处理器信息、高占用进程。
|
||||
- **内存**:使用历史、内存构成与一键释放、高占用进程。
|
||||
- **CPU**:占用与状态、与 30 秒前的变化、温度余量;核心热力图;核心分工;排队程度(每核平均负载与趋势);按应用汇总。
|
||||
- **内存**:还可用多少与压力走势;内存水位条;压缩省下的内存与交换区读写;按应用汇总。
|
||||
- **网络**:流量历史;连接探测格子(延迟、抖动、丢包);接口、Wi-Fi 信号、VPN / 代理;本地与公网 IPv4 / IPv6,
|
||||
归属地国旗、ASN;DNS 一键刷新,一键切换 Cloudflare、Google、腾讯、阿里云或手动填写;各进程流量。
|
||||
- **GPU**、**温度与风扇**:使用历史、各组温度、风扇转速与快捷模式。
|
||||
|
||||
Reference in new issue
Block a user