190 lines
8.9 KiB
Swift
190 lines
8.9 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|