From 0b09fb91c142bfa86334705ec8b6cfab97dd1d41 Mon Sep 17 00:00:00 2001 From: gentpan Date: Sun, 13 Sep 2026 08:33:54 +0500 Subject: [PATCH] =?UTF-8?q?=E7=A3=81=E7=9B=98=E9=A1=B5=EF=BC=9A=E5=AE=B9?= =?UTF-8?q?=E9=87=8F=E3=80=81=E8=AF=BB=E5=86=99=E9=80=9F=E5=BA=A6=E3=80=81?= =?UTF-8?q?SSD=20=E5=81=A5=E5=BA=B7=E4=B8=8E=E8=AF=BB=E5=86=99=E6=9C=80?= =?UTF-8?q?=E5=A4=9A=E7=9A=84=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../Sources/Metrics/DiskSamplers.swift | 137 +++++++++++++ .../Sources/Metrics/MetricsHub.swift | 14 ++ .../OpenStatsKit/Sources/Metrics/Models.swift | 2 + .../Sources/OpenStatsUI/Panel/DiskPage.swift | 189 ++++++++++++++++++ .../OpenStatsUI/Panel/MainWindowView.swift | 1 + .../Snapshot/SnapshotRenderer.swift | 1 + .../OpenStatsUI/State/AlertController.swift | 2 +- .../Sources/OpenStatsUI/State/AppModel.swift | 5 +- .../OpenStatsUI/State/AppSettings.swift | 6 +- .../OpenStatsUI/State/MetricsStore.swift | 10 + .../MetricsTests/PowerSamplerTests.swift | 27 +++ 12 files changed, 390 insertions(+), 5 deletions(-) create mode 100644 Packages/OpenStatsKit/Sources/Metrics/DiskSamplers.swift create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/DiskPage.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index cd3b772..aff1f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - 导出诊断信息(设置 · 关于):把版本、系统与辅助工具状态、主要设置、最近 3 天的运行日志、清理记录和最近的崩溃报告打包成 zip,反馈问题时附上;自动去掉用户目录名、IP 与硬件地址,不含序列号。应用与辅助工具的关键操作和错误写入系统日志。 - 系统通知(设置 · 通知):CPU 过热(持续 1 分钟高于设定温度)、内存压力严重(持续 30 秒)、磁盘空间不足(低于 10% 或 10 GB)、网络断开超过 20 秒(恢复后再提示)、电池最大容量低于 80%,逐项开关,默认关闭;同一状况持续期间只提醒一次并有冷却时间,点通知打开对应页面;可发送测试通知,通知被关闭时提示去系统设置打开。 - 功耗与频率:“温度与风扇”页和温度详情新增功耗,显示整机功耗走势、电源输入、电池充放电功率和 GPU 功耗(读 SMC 与系统能耗统计,不需要辅助工具);CPU 详情的核心分工显示各类核心工作时的平均频率。读取能耗统计有一定开销,只在这些界面打开时每 2 秒读一次。 +- 磁盘页(主窗口侧边栏“磁盘”):启动磁盘容量、所有磁盘合计的实时读写速度与 60 秒走势、SSD 健康(通过系统自带的 NVMe SMART 接口读取剩余寿命、累计读写量、备用空间、温度、通电时间与次数、异常断电和介质错误,出现警告时提示备份)、读写磁盘最多的应用;磁盘空间不足的通知改为打开磁盘页。 #### 调整 diff --git a/Packages/OpenStatsKit/Sources/Metrics/DiskSamplers.swift b/Packages/OpenStatsKit/Sources/Metrics/DiskSamplers.swift new file mode 100644 index 0000000..9409f99 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/DiskSamplers.swift @@ -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?>? + 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) + } +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift b/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift index 4daa280..59cd04d 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift @@ -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) diff --git a/Packages/OpenStatsKit/Sources/Metrics/Models.swift b/Packages/OpenStatsKit/Sources/Metrics/Models.swift index ddf8f2f..7065290 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/Models.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/Models.swift @@ -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() {} } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/DiskPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/DiskPage.swift new file mode 100644 index 0000000..dcfb131 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/DiskPage.swift @@ -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) + } + } + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift index 6504b40..d086806 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift @@ -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() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift index 3d9a031..31d01ff 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift @@ -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)) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AlertController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AlertController.swift index df2cb91..ea74dca 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AlertController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AlertController.swift @@ -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 } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index 69c6d95..a520056 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -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() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index 707e8d7..f1e0f60 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -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" diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift index 04f3d81..8a4374b 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift @@ -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(capacity: historyCapacity) + public private(set) var diskActivity: DiskActivity? + public private(set) var diskReadHistory = History(capacity: historyCapacity) + public private(set) var diskWriteHistory = History(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) } diff --git a/Packages/OpenStatsKit/Tests/MetricsTests/PowerSamplerTests.swift b/Packages/OpenStatsKit/Tests/MetricsTests/PowerSamplerTests.swift index a441f48..6f313e4 100644 --- a/Packages/OpenStatsKit/Tests/MetricsTests/PowerSamplerTests.swift +++ b/Packages/OpenStatsKit/Tests/MetricsTests/PowerSamplerTests.swift @@ -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) + } +}