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

283 lines
14 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
/// 历史:回看最近 1 小时、24 小时、7 天的 CPU、内存、网络、GPU、温度与功耗。鼠标移到图上查看某一时刻
struct HistoryPage: View {
@Environment(AppModel.self) private var model
@State private var hoverDate: Date?
@State private var confirmingClear = false
var body: some View {
@Bindable var settings = model.settings
let history = model.history
let points = history.points
let end = Date()
let start = history.rangeStart
PageScroll {
HStack(spacing: DS.Space.s3) {
SegmentedControl(selection: Binding(get: { history.range }, set: { history.load($0) }),
options: HistoryRecorder.Range.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.sidebarWidth + DS.Space.s16)
Spacer()
Text(verbatim: hoverDate.map { "\($0.formatted(date: .abbreviated, time: .shortened))" }
?? (points.isEmpty ? "" : "每点 \(bucketTitle(history.range))"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.monospacedDigit()
}
if !settings.historyEnabled {
InfoBanner(icon: "pause.circle", text: "历史记录已关闭,下面是关闭前记录的数据。", tone: .warning) {
Button("开启") { settings.historyEnabled = true }.buttonStyle(DSButtonStyle(kind: .primary))
}
}
if points.isEmpty {
Card {
Text(history.isQuerying ? "正在读取…" : "这段时间还没有记录。OpenStats 运行时每分钟记录一次,睡眠与锁屏期间不记录。")
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
}
} else {
HistoryChartCard(icon: "cpu", title: "CPU", points: points, start: start, end: end, hoverDate: $hoverDate,
series: [.init(label: "平均", color: DS.Palette.primary, value: \.cpu),
.init(label: "峰值", color: DS.Palette.primary.opacity(0.35), value: \.cpuMax, filled: false)],
maxValue: 1, format: { Format.percent($0) })
HistoryChartCard(icon: "memorychip", title: "内存", points: points, start: start, end: end, hoverDate: $hoverDate,
series: [.init(label: "已用", color: DS.Palette.primary, value: \.memory)],
maxValue: 1, format: { Format.percent($0) },
note: criticalNote(points))
HistoryChartCard(icon: "network", title: "网络", points: points, start: start, end: end, hoverDate: $hoverDate,
series: [.init(label: "下载", color: Color(nsColor: DS.NetworkPalette.download), value: \.download),
.init(label: "上传", color: Color(nsColor: DS.NetworkPalette.upload), value: \.upload, filled: false)],
maxValue: nil, format: { Format.menuBarRate($0) })
HistoryChartCard(icon: "square.3.layers.3d", title: "GPU", points: points, start: start, end: end, hoverDate: $hoverDate,
series: [.init(label: "占用", color: DS.Palette.primary, value: \.gpu)],
maxValue: 1, format: { Format.percent($0) },
emptyNote: "只在菜单栏显示 GPU 或打开 GPU 相关页面时记录")
HistoryChartCard(icon: "thermometer.medium", title: "CPU 温度", points: points, start: start, end: end, hoverDate: $hoverDate,
series: [.init(label: "最高", color: DS.Palette.warning, value: \.temperature)],
maxValue: nil, format: { Format.temperature($0, fahrenheit: settings.useFahrenheit) },
emptyNote: "只在菜单栏显示温度、开启过热通知或打开温度页面时记录")
HistoryChartCard(icon: "bolt", title: "整机功耗", points: points, start: start, end: end, hoverDate: $hoverDate,
series: [.init(label: "平均", color: DS.Palette.warning, value: \.power)],
maxValue: nil, format: { Format.watts($0) },
emptyNote: "只在打开温度与风扇页面时记录")
}
SettingsGroup(caption: "历史记录") {
GroupRow(showsDivider: false) {
SettingRow(title: "记录历史数据", subtitle: "每分钟把主要指标的平均值与峰值写入本机数据库,保留 7 天,不上传") {
DSToggle(isOn: $settings.historyEnabled, label: "记录历史数据")
}
}
GroupRow {
SettingRow(title: "清除历史", subtitle: "共 \(history.recordCount.formatted()) 条记录") {
Button("清除…") { confirmingClear = true }
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(history.recordCount == 0)
}
}
}
}
.task {
// 页面打开期间每分钟补上新记录的一点
history.load()
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(60))
if hoverDate == nil { history.load() }
}
}
.confirmationDialog("清除全部历史记录?", isPresented: $confirmingClear, titleVisibility: .visible) {
Button("清除", role: .destructive) { history.clear() }
Button("取消", role: .cancel) {}
} message: {
Text("已记录的数据会从本机删除,无法恢复。")
}
}
private func bucketTitle(_ range: HistoryRecorder.Range) -> String {
range.bucket >= 60 * 60 ? "\(range.bucket / 3600) 小时" : "\(range.bucket / 60) 分钟"
}
private func criticalNote(_ points: [HistoryPoint]) -> String? {
let critical = points.filter { ($0.pressure ?? 0) >= MemoryPressure.critical.rawValue }.count
return critical > 0 ? "内存压力严重的时段:\(critical) 个(图上红色标记)" : nil
}
}
private struct HistoryChartCard: View {
struct Series {
let label: String
let color: Color
let value: KeyPath<HistoryPoint, Double?>
var filled = true
}
let icon: String
let title: String
let points: [HistoryPoint]
let start: Date
let end: Date
@Binding var hoverDate: Date?
let series: [Series]
let maxValue: Double?
let format: (Double) -> String
var note: String?
var emptyNote: String?
var body: some View {
let hasData = series.contains { item in points.contains { $0[keyPath: item.value] != nil } }
let primary = series[0]
let values = points.compactMap { $0[keyPath: primary.value] }
let peakPoint = points.max { ($0[keyPath: primary.value] ?? -1) < ($1[keyPath: primary.value] ?? -1) }
Card {
CardHeader(icon: icon, title: title) {
if let hover = hoverPoint {
HStack(spacing: DS.Space.s3) {
ForEach(series.indices, id: \.self) { index in
legend(series[index], value: hover[keyPath: series[index].value])
}
}
} else if !values.isEmpty {
Text(verbatim: "平均 \(format(values.reduce(0, +) / Double(values.count))) · 峰值 \(format(values.max() ?? 0))\(peakPoint.map { "(\(Self.moment($0.date)))" } ?? "")")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.monospacedDigit()
}
}
if hasData {
TimeSeriesChart(points: points, start: start, end: end, series: series, maxValue: maxValue, hoverDate: $hoverDate)
.frame(height: DS.Size.chartHeight * 2)
HStack {
Text(verbatim: Self.moment(start))
Spacer()
Text(verbatim: Self.moment(start.addingTimeInterval(end.timeIntervalSince(start) / 2)))
Spacer()
Text("现在")
}
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.monospacedDigit()
if let note {
Text(note).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
} else {
Text(emptyNote ?? "这段时间没有记录").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
/// 同一天只显示时间,跨天带上日期
static func moment(_ date: Date) -> String {
Calendar.current.isDateInToday(date)
? date.formatted(date: .omitted, time: .shortened)
: date.formatted(.dateTime.month(.abbreviated).day().hour().minute())
}
private var hoverPoint: HistoryPoint? {
guard let hoverDate else { return nil }
return points.min { abs($0.date.timeIntervalSince(hoverDate)) < abs($1.date.timeIntervalSince(hoverDate)) }
}
private func legend(_ item: Series, value: Double?) -> some View {
HStack(spacing: DS.Space.s1) {
Circle().fill(item.color).frame(width: DS.Space.s2, height: DS.Space.s2)
Text(verbatim: "\(item.label) \(value.map(format) ?? "—")")
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
.monospacedDigit()
}
}
}
/// 按时间定位的折线图:相邻两点间隔超过 3 个时间桶视为没有记录(睡眠、关机),线条断开
private struct TimeSeriesChart: View {
let points: [HistoryPoint]
let start: Date
let end: Date
let series: [HistoryChartCard.Series]
let maxValue: Double?
@Binding var hoverDate: Date?
var body: some View {
GeometryReader { proxy in
let size = proxy.size
let span = max(1, end.timeIntervalSince(start))
let bucket = points.count > 1 ? medianGap : 60
let peak = maxValue ?? max(series.flatMap { item in points.compactMap { $0[keyPath: item.value] } }.max() ?? 0, 1) * 1.15
Canvas { context, _ in
func x(_ date: Date) -> CGFloat { CGFloat(date.timeIntervalSince(start) / span) * size.width }
func y(_ value: Double) -> CGFloat { size.height - CGFloat(min(1, value / peak)) * size.height }
// 网格:上中下三条
for fraction in [0.0, 0.5, 1.0] {
var grid = Path()
let lineY = size.height * fraction
grid.move(to: CGPoint(x: 0, y: lineY))
grid.addLine(to: CGPoint(x: size.width, y: lineY))
context.stroke(grid, with: .color(DS.Palette.track), lineWidth: DS.Size.stroke)
}
// 内存压力严重的时段
for point in points where (point.pressure ?? 0) >= MemoryPressure.critical.rawValue && series.contains(where: { $0.value == \HistoryPoint.memory }) {
let rect = CGRect(x: x(point.date), y: 0, width: max(DS.Size.stroke * 2, size.width * CGFloat(bucket / span)), height: size.height)
context.fill(Path(rect), with: .color(DS.Palette.error.opacity(0.15)))
}
for item in series.reversed() {
var segments: [[CGPoint]] = []
var previous: Date?
for point in points {
guard let value = point[keyPath: item.value] else { previous = nil; continue }
let location = CGPoint(x: x(point.date), y: y(value))
if let previous, point.date.timeIntervalSince(previous) <= bucket * 3, !segments.isEmpty {
segments[segments.count - 1].append(location)
} else {
segments.append([location])
}
previous = point.date
}
for segment in segments {
var line = Path()
line.addLines(segment.count == 1 ? [segment[0], CGPoint(x: segment[0].x + DS.Size.stroke, y: segment[0].y)] : segment)
if item.filled, let first = segment.first, let last = segment.last {
var area = line
area.addLine(to: CGPoint(x: last.x, y: size.height))
area.addLine(to: CGPoint(x: first.x, y: size.height))
area.closeSubpath()
context.fill(area, with: .color(item.color.opacity(0.12)))
}
context.stroke(line, with: .color(item.color), style: StrokeStyle(lineWidth: DS.Size.chartLine, lineJoin: .round))
}
}
if let hoverDate {
var cursor = Path()
cursor.move(to: CGPoint(x: x(hoverDate), y: 0))
cursor.addLine(to: CGPoint(x: x(hoverDate), y: size.height))
context.stroke(cursor, with: .color(DS.Palette.textTertiary), lineWidth: DS.Size.stroke)
}
}
.contentShape(Rectangle())
.onContinuousHover { phase in
switch phase {
case .active(let location):
hoverDate = start.addingTimeInterval(span * Double(max(0, min(1, location.x / max(1, size.width)))))
case .ended:
hoverDate = nil
}
}
}
}
/// 相邻点的典型间隔,即时间桶大小
private var medianGap: TimeInterval {
let gaps = zip(points.dropFirst(), points).map { $0.date.timeIntervalSince($1.date) }.sorted()
return gaps.isEmpty ? 60 : gaps[gaps.count / 2]
}
}