磁盘页:容量、读写速度、SSD 健康与读写最多的应用
This commit is contained in:
1 parent
722b278832
commit
0b09fb91c1
12 files changed
+390
-5
No files matched your search
@@ -14,6 +14,7 @@
|
||||
- 导出诊断信息(设置 · 关于):把版本、系统与辅助工具状态、主要设置、最近 3 天的运行日志、清理记录和最近的崩溃报告打包成 zip,反馈问题时附上;自动去掉用户目录名、IP 与硬件地址,不含序列号。应用与辅助工具的关键操作和错误写入系统日志。
|
||||
- 系统通知(设置 · 通知):CPU 过热(持续 1 分钟高于设定温度)、内存压力严重(持续 30 秒)、磁盘空间不足(低于 10% 或 10 GB)、网络断开超过 20 秒(恢复后再提示)、电池最大容量低于 80%,逐项开关,默认关闭;同一状况持续期间只提醒一次并有冷却时间,点通知打开对应页面;可发送测试通知,通知被关闭时提示去系统设置打开。
|
||||
- 功耗与频率:“温度与风扇”页和温度详情新增功耗,显示整机功耗走势、电源输入、电池充放电功率和 GPU 功耗(读 SMC 与系统能耗统计,不需要辅助工具);CPU 详情的核心分工显示各类核心工作时的平均频率。读取能耗统计有一定开销,只在这些界面打开时每 2 秒读一次。
|
||||
- 磁盘页(主窗口侧边栏“磁盘”):启动磁盘容量、所有磁盘合计的实时读写速度与 60 秒走势、SSD 健康(通过系统自带的 NVMe SMART 接口读取剩余寿命、累计读写量、备用空间、温度、通电时间与次数、异常断电和介质错误,出现警告时提示备份)、读写磁盘最多的应用;磁盘空间不足的通知改为打开磁盘页。
|
||||
|
||||
#### 调整
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import Foundation
|
||||
import IOKit
|
||||
|
||||
/// 启动磁盘的 NVMe SMART 健康信息
|
||||
public struct DiskHealth: Sendable, Equatable {
|
||||
public var model: String
|
||||
/// 已消耗的寿命(厂商估算,可以超过 100)
|
||||
public var percentageUsed: Int
|
||||
public var availableSpare: Int
|
||||
public var availableSpareThreshold: Int
|
||||
public var temperature: Double?
|
||||
public var bytesRead: UInt64
|
||||
public var bytesWritten: UInt64
|
||||
public var powerOnHours: UInt64
|
||||
public var powerCycles: UInt64
|
||||
public var unsafeShutdowns: UInt64
|
||||
public var mediaErrors: UInt64
|
||||
public var criticalWarning: UInt8
|
||||
|
||||
public var remainingLife: Int { max(0, 100 - percentageUsed) }
|
||||
}
|
||||
|
||||
/// 通过系统自带的 NVMeSMARTLib 插件读取 SMART,不需要 root
|
||||
public enum DiskHealthReader {
|
||||
public static func read() -> DiskHealth? {
|
||||
var iterator: io_iterator_t = 0
|
||||
guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IOBlockStorageDevice"), &iterator) == KERN_SUCCESS else {
|
||||
return nil
|
||||
}
|
||||
defer { IOObjectRelease(iterator) }
|
||||
while case let service = IOIteratorNext(iterator), service != 0 {
|
||||
defer { IOObjectRelease(service) }
|
||||
guard let capable = IORegistryEntryCreateCFProperty(service, "NVMe SMART Capable" as CFString, kCFAllocatorDefault, 0)?
|
||||
.takeRetainedValue() as? Bool, capable,
|
||||
let bytes = smartData(for: service) else { continue }
|
||||
// 型号在上层的 NVMe 控制器上
|
||||
let model = (IORegistryEntrySearchCFProperty(service, kIOServicePlane, "Model Number" as CFString, kCFAllocatorDefault,
|
||||
IOOptionBits(kIORegistryIterateRecursively | kIORegistryIterateParents)) as? String)?
|
||||
.trimmingCharacters(in: .whitespaces) ?? "SSD"
|
||||
return parse(bytes, model: model)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// NVMe 1.0c 5.10.1.2:512 字节 SMART / Health 日志,小端,无对齐填充
|
||||
static func parse(_ data: [UInt8], model: String) -> DiskHealth? {
|
||||
guard data.count >= 192 else { return nil }
|
||||
func u64(_ offset: Int) -> UInt64 {
|
||||
(0..<8).reduce(UInt64(0)) { $0 | UInt64(data[offset + $1]) << (8 * UInt64($1)) }
|
||||
}
|
||||
let kelvin = Double(UInt16(data[1]) | UInt16(data[2]) << 8)
|
||||
// 数据单位为 1000 个 512 字节扇区
|
||||
let unit: UInt64 = 512 * 1000
|
||||
return DiskHealth(model: model,
|
||||
percentageUsed: Int(data[5]),
|
||||
availableSpare: Int(data[3]),
|
||||
availableSpareThreshold: Int(data[4]),
|
||||
temperature: kelvin > 200 ? kelvin - 273.15 : nil,
|
||||
bytesRead: u64(32).multipliedReportingOverflow(by: unit).partialValue,
|
||||
bytesWritten: u64(48).multipliedReportingOverflow(by: unit).partialValue,
|
||||
powerOnHours: u64(128),
|
||||
powerCycles: u64(112),
|
||||
unsafeShutdowns: u64(144),
|
||||
mediaErrors: u64(160),
|
||||
criticalWarning: data[0])
|
||||
}
|
||||
|
||||
private static func smartData(for service: io_service_t) -> [UInt8]? {
|
||||
// kIOCFPlugInInterfaceID、kIONVMeSMARTUserClientTypeID、kIONVMeSMARTInterfaceID 是 C 宏,Swift 里按字节重建
|
||||
let pluginInterfaceID = CFUUIDGetConstantUUIDWithBytes(nil, 0xC2, 0x44, 0xE8, 0x58, 0x10, 0x9C, 0x11, 0xD4,
|
||||
0x91, 0xD4, 0x00, 0x50, 0xE4, 0xC6, 0x42, 0x6F)
|
||||
let userClientTypeID = CFUUIDGetConstantUUIDWithBytes(nil, 0xAA, 0x0F, 0xA6, 0xF9, 0xC2, 0xD6, 0x45, 0x7F,
|
||||
0xB1, 0x0B, 0x59, 0xA1, 0x32, 0x53, 0x29, 0x2F)
|
||||
let smartInterfaceID = CFUUIDGetConstantUUIDWithBytes(nil, 0xCC, 0xD1, 0xDB, 0x19, 0xFD, 0x9A, 0x4D, 0xAF,
|
||||
0xBF, 0x95, 0x12, 0x45, 0x4B, 0x23, 0x0A, 0xB6)
|
||||
var plugin: UnsafeMutablePointer<UnsafeMutablePointer<IOCFPlugInInterface>?>?
|
||||
var score: Int32 = 0
|
||||
guard IOCreatePlugInInterfaceForService(service, userClientTypeID, pluginInterfaceID, &plugin, &score) == KERN_SUCCESS,
|
||||
let plugin, let vtable = plugin.pointee?.pointee else { return nil }
|
||||
defer { _ = vtable.Release(plugin) }
|
||||
|
||||
var smart: LPVOID?
|
||||
guard vtable.QueryInterface(plugin, CFUUIDGetUUIDBytes(smartInterfaceID), &smart) == S_OK, let smart else { return nil }
|
||||
// IONVMeSMARTInterface:IUNKNOWN_C_GUTS(4 个指针)、version、revision,之后第一个函数是 SMARTReadData
|
||||
let interface = smart.assumingMemoryBound(to: UnsafeMutableRawPointer.self)
|
||||
let table = interface.pointee
|
||||
typealias Release = @convention(c) (UnsafeMutableRawPointer?) -> UInt32
|
||||
typealias ReadData = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> IOReturn
|
||||
defer { _ = table.load(fromByteOffset: 24, as: Release.self)(smart) }
|
||||
let readData = table.load(fromByteOffset: 40, as: ReadData.self)
|
||||
var buffer = [UInt8](repeating: 0, count: 512)
|
||||
let result = buffer.withUnsafeMutableBytes { readData(smart, $0.baseAddress) }
|
||||
return result == kIOReturnSuccess ? buffer : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 所有块设备的读写速率(字节 / 秒)
|
||||
public struct DiskActivity: Sendable, Equatable {
|
||||
public var readRate: Double
|
||||
public var writeRate: Double
|
||||
}
|
||||
|
||||
/// 读取 IOBlockStorageDriver 的累计读写字节数,两次采样相减得到速率
|
||||
public struct DiskActivitySampler {
|
||||
private var previous: (date: Date, read: UInt64, write: UInt64)?
|
||||
|
||||
public init() {}
|
||||
|
||||
public mutating func sample(now: Date = Date()) -> DiskActivity? {
|
||||
guard let totals = Self.totals() else { return nil }
|
||||
defer { previous = (now, totals.read, totals.write) }
|
||||
guard let previous, now > previous.date else { return nil }
|
||||
let seconds = now.timeIntervalSince(previous.date)
|
||||
// 磁盘被推出后累计值会变小,这一次不计
|
||||
guard totals.read >= previous.read, totals.write >= previous.write, seconds < 60 else { return nil }
|
||||
return DiskActivity(readRate: Double(totals.read - previous.read) / seconds,
|
||||
writeRate: Double(totals.write - previous.write) / seconds)
|
||||
}
|
||||
|
||||
static func totals() -> (read: UInt64, write: UInt64)? {
|
||||
var iterator: io_iterator_t = 0
|
||||
guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IOBlockStorageDriver"), &iterator) == KERN_SUCCESS else {
|
||||
return nil
|
||||
}
|
||||
defer { IOObjectRelease(iterator) }
|
||||
var read: UInt64 = 0
|
||||
var write: UInt64 = 0
|
||||
while case let driver = IOIteratorNext(iterator), driver != 0 {
|
||||
defer { IOObjectRelease(driver) }
|
||||
guard let statistics = IORegistryEntryCreateCFProperty(driver, "Statistics" as CFString, kCFAllocatorDefault, 0)?
|
||||
.takeRetainedValue() as? [String: Any] else { continue }
|
||||
read &+= (statistics["Bytes (Read)"] as? NSNumber)?.uint64Value ?? 0
|
||||
write &+= (statistics["Bytes (Write)"] as? NSNumber)?.uint64Value ?? 0
|
||||
}
|
||||
return (read, write)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ public struct MetricsDemand: Sendable, Equatable {
|
||||
public var power = false
|
||||
/// 各类核心的频率
|
||||
public var cpuFrequency = false
|
||||
/// 磁盘读写速率与 SMART 健康信息
|
||||
public var diskDetail = false
|
||||
|
||||
public init() {}
|
||||
}
|
||||
@@ -29,6 +31,7 @@ public actor MetricsHub {
|
||||
private var processes = ProcessSampler()
|
||||
private let sensors = SensorSampler()
|
||||
private let power = PowerSampler()
|
||||
private var diskActivity = DiskActivitySampler()
|
||||
|
||||
private var demand = MetricsDemand()
|
||||
private var handler: (@MainActor @Sendable (MetricsSnapshot) -> Void)?
|
||||
@@ -41,6 +44,7 @@ public actor MetricsHub {
|
||||
private var lastBattery = Date.distantPast
|
||||
private var lastInterface = Date.distantPast
|
||||
private var lastPower = Date.distantPast
|
||||
private var lastDiskHealth = Date.distantPast
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -54,6 +58,7 @@ public actor MetricsHub {
|
||||
let needsImmediateRefresh = newDemand.interval < demand.interval
|
||||
|| (newDemand.processes && !demand.processes)
|
||||
|| (newDemand.disk && !demand.disk)
|
||||
|| (newDemand.diskDetail && !demand.diskDetail)
|
||||
if (newDemand.power && !demand.power) || (newDemand.cpuFrequency && !demand.cpuFrequency) {
|
||||
lastPower = .distantPast
|
||||
}
|
||||
@@ -62,6 +67,7 @@ public actor MetricsHub {
|
||||
lastDisk = .distantPast
|
||||
lastBattery = .distantPast
|
||||
lastInterface = .distantPast
|
||||
lastDiskHealth = .distantPast
|
||||
}
|
||||
|
||||
restart()
|
||||
@@ -93,6 +99,7 @@ public actor MetricsHub {
|
||||
demand.fans = true
|
||||
demand.power = true
|
||||
demand.cpuFrequency = true
|
||||
demand.diskDetail = true
|
||||
return demand
|
||||
}
|
||||
|
||||
@@ -145,6 +152,13 @@ public actor MetricsHub {
|
||||
if !demand.temperatures.isEmpty || demand.fans {
|
||||
snapshot.sensors = sensors.sample(groups: demand.temperatures, includeFans: demand.fans)
|
||||
}
|
||||
if demand.diskDetail {
|
||||
snapshot.diskActivity = diskActivity.sample(now: now)
|
||||
if now.timeIntervalSince(lastDiskHealth) > 60 {
|
||||
snapshot.diskHealth = DiskHealthReader.read()
|
||||
lastDiskHealth = now
|
||||
}
|
||||
}
|
||||
// IOReport 采样有一定开销,至少间隔 2 秒
|
||||
if demand.power || demand.cpuFrequency, now.timeIntervalSince(lastPower) >= 1.9 {
|
||||
snapshot.power = power.sample(gpu: demand.power, frequency: demand.cpuFrequency, now: now)
|
||||
|
||||
@@ -193,6 +193,8 @@ public struct MetricsSnapshot: Sendable {
|
||||
public var systemCounts: SystemCounts?
|
||||
public var sensors: SensorReadings?
|
||||
public var power: PowerReading?
|
||||
public var diskActivity: DiskActivity?
|
||||
public var diskHealth: DiskHealth?
|
||||
|
||||
public init() {}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import Metrics
|
||||
import SwiftUI
|
||||
|
||||
/// 磁盘:容量、实时读写、SSD 健康(SMART)与读写最多的应用
|
||||
struct DiskPage: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
PageScroll {
|
||||
WeightedRow(weights: [1, 1]) {
|
||||
CapacityCard()
|
||||
ActivityCard()
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HealthCard()
|
||||
DiskProcessesCard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CapacityCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
let disk = model.store.disk
|
||||
Card {
|
||||
CardHeader(icon: "internaldrive", title: disk?.volumeName ?? "启动磁盘",
|
||||
detail: disk.map { "共 \(Format.bytes($0.total, base: .decimal))" } ?? "读取中")
|
||||
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1) {
|
||||
Text(verbatim: disk.map { "\(Int(($0.usedFraction * 100).rounded()))" } ?? "—")
|
||||
.dsFont(.xxl, weight: .semibold)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.monospacedDigit()
|
||||
Text(verbatim: "%").dsFont(.sm).foregroundStyle(DS.Palette.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
ProgressTrack(fraction: disk?.usedFraction ?? 0,
|
||||
color: (disk?.usedFraction ?? 0) > 0.9 ? DS.Palette.warning : DS.Palette.primary)
|
||||
if let disk {
|
||||
InfoRow(label: "已用", text: Format.bytes(disk.used, base: .decimal))
|
||||
InfoRow(label: "可用", text: Format.bytes(disk.available, base: .decimal))
|
||||
Text("可用空间包含系统可以随时清除的缓存,与访达显示一致")
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActivityCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
let store = model.store
|
||||
let reads = store.diskReadHistory.elements
|
||||
let writes = store.diskWriteHistory.elements
|
||||
Card {
|
||||
CardHeader(icon: "arrow.up.arrow.down", title: "读写速度", detail: "所有磁盘合计")
|
||||
HStack(spacing: DS.Space.s4) {
|
||||
rate("读取", store.diskActivity?.readRate, color: DS.NetworkPalette.download)
|
||||
rate("写入", store.diskActivity?.writeRate, color: DS.NetworkPalette.upload)
|
||||
Spacer()
|
||||
}
|
||||
// 上半写入、下半读取,与网络流量图同一套配色
|
||||
MirroredRateChart(upload: writes, download: reads, height: DS.Size.chartHeight * 2)
|
||||
InfoRow(label: "60 秒峰值") {
|
||||
Text(verbatim: "读 \(Format.menuBarRate(reads.max() ?? 0)) · 写 \(Format.menuBarRate(writes.max() ?? 0))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func rate(_ label: String, _ value: Double?, color: NSColor) -> some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.s1) {
|
||||
HStack(spacing: DS.Space.s1) {
|
||||
Circle().fill(Color(nsColor: color)).frame(width: DS.Space.s2, height: DS.Space.s2)
|
||||
Text(label).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
Text(verbatim: value.map(Format.menuBarRate) ?? "—")
|
||||
.dsFont(.lg, weight: .semibold)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HealthCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
let health = model.store.diskHealth
|
||||
Card {
|
||||
CardHeader(icon: "heart.text.square", title: "SSD 健康", detail: health?.model ?? "读取中")
|
||||
if let health {
|
||||
let tone = tone(health)
|
||||
HStack(alignment: .center, spacing: DS.Space.s6) {
|
||||
RingGauge(fraction: Double(health.remainingLife) / 100, color: tone.color, size: DS.Space.s16 + DS.Space.s6) {
|
||||
VStack(spacing: 0) {
|
||||
Text(verbatim: "\(health.remainingLife)%")
|
||||
.dsFont(.base, weight: .semibold)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.monospacedDigit()
|
||||
Text("剩余寿命").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: DS.Space.s1) {
|
||||
StatusBadge(text: summary(health), tone: tone)
|
||||
Text("寿命按厂商估算的已用比例计算;写入量越大消耗越快,日常使用通常可用很多年")
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
WeightedRow(weights: [1, 1], spacing: DS.Space.s6) {
|
||||
VStack(spacing: DS.Space.s2) {
|
||||
InfoRow(label: "累计写入", text: Format.bytes(health.bytesWritten, base: .decimal))
|
||||
InfoRow(label: "累计读取", text: Format.bytes(health.bytesRead, base: .decimal))
|
||||
InfoRow(label: "备用空间", text: "\(health.availableSpare)%(阈值 \(health.availableSpareThreshold)%)")
|
||||
if let temperature = health.temperature {
|
||||
InfoRow(label: "温度", text: Format.temperature(temperature, fahrenheit: model.settings.useFahrenheit))
|
||||
}
|
||||
}
|
||||
VStack(spacing: DS.Space.s2) {
|
||||
InfoRow(label: "通电时间", text: "\(health.powerOnHours.formatted()) 小时")
|
||||
InfoRow(label: "通电次数", text: health.powerCycles.formatted())
|
||||
InfoRow(label: "异常断电", text: health.unsafeShutdowns.formatted())
|
||||
InfoRow(label: "介质错误", text: health.mediaErrors.formatted())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("正在读取 SMART 信息;外置磁盘或不支持 NVMe SMART 的磁盘不显示")
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func tone(_ health: DiskHealth) -> Tone {
|
||||
if health.criticalWarning != 0 || health.mediaErrors > 0 || health.availableSpare < health.availableSpareThreshold { return .error }
|
||||
return health.remainingLife < 20 ? .warning : .success
|
||||
}
|
||||
|
||||
private func summary(_ health: DiskHealth) -> String {
|
||||
if health.criticalWarning != 0 { return "磁盘报告了严重警告,建议尽快备份" }
|
||||
if health.mediaErrors > 0 { return "出现介质错误,建议备份" }
|
||||
if health.availableSpare < health.availableSpareThreshold { return "备用空间低于阈值,建议备份" }
|
||||
return health.remainingLife < 20 ? "寿命偏低,注意备份" : "状态良好"
|
||||
}
|
||||
}
|
||||
|
||||
private struct DiskProcessesCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
let rows = ProcessRowModel.grouped(model.store.processes)
|
||||
.compactMap { row in row.disk.map { (row, $0.read + $0.write) } }
|
||||
.filter { $0.1 > 0 }
|
||||
.sorted { $0.1 > $1.1 }
|
||||
.prefix(8)
|
||||
let peak = rows.first?.1 ?? 1
|
||||
|
||||
Card {
|
||||
CardHeader(icon: "list.bullet.rectangle", title: "读写最多的应用", detail: "只含当前用户的进程")
|
||||
if rows.isEmpty {
|
||||
Text("最近没有应用在读写磁盘").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
ForEach(Array(rows), id: \.0.id) { row, total in
|
||||
VStack(spacing: DS.Space.s1) {
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
AppIconCache.shared.image(bundlePath: row.bundlePath)
|
||||
.resizable()
|
||||
.frame(width: DS.Size.iconInline, height: DS.Size.iconInline)
|
||||
Text(verbatim: row.title)
|
||||
.dsFont(.xs, weight: .medium)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: DS.Space.s2)
|
||||
Text(verbatim: "读 \(Format.menuBarRate(row.disk?.read ?? 0)) · 写 \(Format.menuBarRate(row.disk?.write ?? 0))")
|
||||
.dsFont(.xs, weight: .medium)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
ProgressTrack(fraction: total / peak, height: DS.Space.s1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public struct MainWindowView: View {
|
||||
case .cpu: DetailPage { CPUPopover() }
|
||||
case .gpu: DetailPage { GPUPopover() }
|
||||
case .memory: DetailPage { MemoryPopover() }
|
||||
case .disk: DiskPage()
|
||||
case .network:
|
||||
DetailPage {
|
||||
NetworkPopover()
|
||||
|
||||
@@ -31,6 +31,7 @@ enum SnapshotRenderer {
|
||||
demand.fans = true
|
||||
demand.power = true
|
||||
demand.cpuFrequency = true
|
||||
demand.diskDetail = true
|
||||
await model.hub.update(demand)
|
||||
await model.hub.start { snapshot in model.handle(snapshot) }
|
||||
try? await Task.sleep(for: .seconds(12))
|
||||
|
||||
@@ -46,7 +46,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable {
|
||||
switch self {
|
||||
case .cpuTemperature: .thermal
|
||||
case .memoryPressure: .memory
|
||||
case .diskSpace: .cleaner
|
||||
case .diskSpace: .disk
|
||||
case .networkDown: .network
|
||||
case .batteryHealth: .system
|
||||
}
|
||||
|
||||
@@ -65,9 +65,10 @@ public final class AppModel {
|
||||
demand.network = true
|
||||
let showing = { (page: PanelTab, item: MenuBarItem) in (window && tab == page) || popover == item }
|
||||
demand.gpu = (window && [.overview, .system].contains(tab)) || menu.contains(.gpu) || showing(.gpu, .gpu)
|
||||
demand.disk = window && [.overview, .system, .cleaner].contains(tab)
|
||||
demand.disk = window && [.overview, .system, .cleaner, .disk].contains(tab)
|
||||
demand.diskDetail = window && tab == .disk
|
||||
demand.battery = (window && [.overview, .system, .keepAwake].contains(tab)) || keepAwake.lidClosedActive
|
||||
demand.processes = (window && [.processes, .overview].contains(tab)) || showing(.cpu, .cpu) || showing(.memory, .memory)
|
||||
demand.processes = (window && [.processes, .overview, .disk].contains(tab)) || showing(.cpu, .cpu) || showing(.memory, .memory)
|
||||
demand.systemProcesses = window && tab == .processes
|
||||
|
||||
var groups = Set<TemperatureGroup>()
|
||||
|
||||
@@ -220,12 +220,12 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable {
|
||||
|
||||
/// 主窗口侧边栏的页面
|
||||
public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
|
||||
case overview, system, cpu, gpu, memory, network, thermal, processes, keepAwake, cleaner
|
||||
case overview, system, cpu, gpu, memory, disk, network, thermal, processes, keepAwake, cleaner
|
||||
case settingsGeneral, settingsMenuBar, settingsNotifications, settingsHelper, settingsAbout
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
static let monitors: [PanelTab] = [.overview, .system, .cpu, .gpu, .memory, .network, .thermal]
|
||||
static let monitors: [PanelTab] = [.overview, .system, .cpu, .gpu, .memory, .disk, .network, .thermal]
|
||||
static let tools: [PanelTab] = [.processes, .keepAwake, .cleaner]
|
||||
static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsNotifications, .settingsHelper, .settingsAbout]
|
||||
|
||||
@@ -241,6 +241,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
|
||||
case .cpu: "CPU"
|
||||
case .gpu: "GPU"
|
||||
case .memory: "内存"
|
||||
case .disk: "磁盘"
|
||||
case .network: "网络"
|
||||
case .thermal: "温度与风扇"
|
||||
case .processes: "进程"
|
||||
@@ -261,6 +262,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
|
||||
case .cpu: "cpu"
|
||||
case .gpu: "square.3.layers.3d"
|
||||
case .memory: "memorychip"
|
||||
case .disk: "internaldrive"
|
||||
case .network: "network"
|
||||
case .thermal: "fan"
|
||||
case .processes: "list.bullet.rectangle"
|
||||
|
||||
@@ -38,12 +38,22 @@ public final class MetricsStore {
|
||||
public private(set) var sensors: SensorReadings?
|
||||
public private(set) var power: PowerReading?
|
||||
public private(set) var powerHistory = History<Double>(capacity: historyCapacity)
|
||||
public private(set) var diskActivity: DiskActivity?
|
||||
public private(set) var diskReadHistory = History<Double>(capacity: historyCapacity)
|
||||
public private(set) var diskWriteHistory = History<Double>(capacity: historyCapacity)
|
||||
public private(set) var diskHealth: DiskHealth?
|
||||
public private(set) var lastUpdate: Date?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func apply(_ snapshot: MetricsSnapshot) {
|
||||
lastUpdate = snapshot.date
|
||||
if let activity = snapshot.diskActivity {
|
||||
diskActivity = activity
|
||||
diskReadHistory.append(activity.readRate)
|
||||
diskWriteHistory.append(activity.writeRate)
|
||||
}
|
||||
if let health = snapshot.diskHealth { diskHealth = health }
|
||||
if let power = snapshot.power {
|
||||
self.power = power
|
||||
if let system = power.system { powerHistory.append(system) }
|
||||
|
||||
@@ -44,3 +44,30 @@ import Testing
|
||||
#expect(PowerSampler.joulesPerUnit("mJ") == 1e-3)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct DiskSamplerTests {
|
||||
@Test func parsesNVMeHealthLog() throws {
|
||||
var log = [UInt8](repeating: 0, count: 512)
|
||||
log[0] = 0
|
||||
log[1] = 0x3B; log[2] = 0x01 // 315 K ≈ 41.85 °C
|
||||
log[3] = 100; log[4] = 10; log[5] = 3
|
||||
log[48] = 0x10; log[49] = 0x27 // 写入 10000 个数据单位
|
||||
log[128] = 0xE8; log[129] = 0x03 // 通电 1000 小时
|
||||
log[144] = 5
|
||||
let health = try #require(DiskHealthReader.parse(log, model: "SSD"))
|
||||
#expect(health.percentageUsed == 3)
|
||||
#expect(health.remainingLife == 97)
|
||||
#expect(health.bytesWritten == 10_000 * 512 * 1000)
|
||||
#expect(health.powerOnHours == 1000)
|
||||
#expect(health.unsafeShutdowns == 5)
|
||||
#expect(abs((health.temperature ?? 0) - 41.85) < 0.01)
|
||||
#expect(DiskHealthReader.parse([1, 2, 3], model: "SSD") == nil)
|
||||
}
|
||||
|
||||
@Test func activityNeedsTwoSamples() {
|
||||
var sampler = DiskActivitySampler()
|
||||
#expect(sampler.sample(now: Date()) == nil)
|
||||
let second = sampler.sample(now: Date().addingTimeInterval(1))
|
||||
#expect(second.map { $0.readRate >= 0 && $0.writeRate >= 0 } ?? true)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user