Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/DiskPage.swift
T

190 lines
8.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}
}
}