历史回看:每分钟记录到本机数据库,1 小时 / 24 小时 / 7 天图表
This commit is contained in:
1 parent
1a79a5d98d
commit
9e236670e5
11 files changed
+696
-5
No files matched your search
@@ -17,6 +17,7 @@
|
||||
- 磁盘页(主窗口侧边栏“磁盘”):启动磁盘容量、所有磁盘合计的实时读写速度与 60 秒走势、SSD 健康(通过系统自带的 NVMe SMART 接口读取剩余寿命、累计读写量、备用空间、温度、通电时间与次数、异常断电和介质错误,出现警告时提示备份)、读写磁盘最多的应用;磁盘空间不足的通知改为打开磁盘页。
|
||||
- 卸载应用(主窗口工具分组):列出“应用程序”里的第三方应用及其体积,选中或把应用拖进来后找出它留下的应用数据、缓存、偏好设置、沙盒容器、窗口状态、日志、网页数据和登录启动项,可逐项取消勾选;确认后连同应用一起移到废纸篓(可放回),并可同时从程序坞移除图标。只匹配应用包名与同名目录,系统自带和 Apple 的应用不列出,正在运行时提示先退出。
|
||||
- 启动项(主窗口工具分组):列出当前用户、所有用户的 LaunchAgents 和系统 LaunchDaemons,显示所属应用、可执行文件、是否登录时运行 / 保持运行,以及运行中(PID)、已加载、已停用状态;当前用户的启动项可以直接停用或重新启用(写入 launchd 停用记录并卸载,不删除文件),其余只读并提供登录项设置入口。
|
||||
- 历史(主窗口监控分组):每分钟把 CPU 平均与峰值、内存与内存压力、网络上下行、GPU、CPU 最高温度和整机功耗写入本机 SQLite 数据库,保留 7 天;可回看最近 1 小时 / 24 小时 / 7 天,睡眠等没有记录的时段线条断开,内存压力严重的时段标红,鼠标移到图上查看那一刻的数值;可关闭记录或清除历史,数据不上传。
|
||||
|
||||
#### 调整
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
/// 每分钟一条的历史记录;没有采集到的指标为 nil
|
||||
public struct HistoryRecord: Sendable, Equatable {
|
||||
/// Unix 时间戳按分钟取整
|
||||
public var minute: Int
|
||||
public var cpu: Double?
|
||||
public var cpuMax: Double?
|
||||
public var memory: Double?
|
||||
/// 内存压力最高档:1 正常、2 偏高、4 严重
|
||||
public var pressure: Int?
|
||||
public var download: Double?
|
||||
public var upload: Double?
|
||||
public var gpu: Double?
|
||||
public var temperature: Double?
|
||||
public var power: Double?
|
||||
|
||||
public init(minute: Int, cpu: Double? = nil, cpuMax: Double? = nil, memory: Double? = nil, pressure: Int? = nil,
|
||||
download: Double? = nil, upload: Double? = nil, gpu: Double? = nil, temperature: Double? = nil, power: Double? = nil) {
|
||||
self.minute = minute
|
||||
self.cpu = cpu
|
||||
self.cpuMax = cpuMax
|
||||
self.memory = memory
|
||||
self.pressure = pressure
|
||||
self.download = download
|
||||
self.upload = upload
|
||||
self.gpu = gpu
|
||||
self.temperature = temperature
|
||||
self.power = power
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询结果:一个时间桶内的平均值与峰值
|
||||
public struct HistoryPoint: Sendable, Equatable {
|
||||
public var date: Date
|
||||
public var cpu: Double?
|
||||
public var cpuMax: Double?
|
||||
public var memory: Double?
|
||||
public var pressure: Int?
|
||||
public var download: Double?
|
||||
public var upload: Double?
|
||||
public var gpu: Double?
|
||||
public var temperature: Double?
|
||||
public var power: Double?
|
||||
}
|
||||
|
||||
/// 本机 SQLite 历史库(~/Library/Application Support/OpenStats/history.sqlite),只存每分钟汇总,7 天约 1 万行
|
||||
public actor HistoryDatabase {
|
||||
public enum Error: Swift.Error {
|
||||
case open(String)
|
||||
}
|
||||
|
||||
public static let retention: TimeInterval = 8 * 24 * 60 * 60
|
||||
|
||||
public static var defaultURL: URL {
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("OpenStats/history.sqlite")
|
||||
}
|
||||
|
||||
nonisolated(unsafe) private var db: OpaquePointer?
|
||||
|
||||
public init(url: URL = HistoryDatabase.defaultURL) throws {
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
var handle: OpaquePointer?
|
||||
guard sqlite3_open_v2(url.path, &handle, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
|
||||
let message = handle.map { String(cString: sqlite3_errmsg($0)) } ?? "无法打开"
|
||||
sqlite3_close(handle)
|
||||
throw Error.open(message)
|
||||
}
|
||||
db = handle
|
||||
let schema = """
|
||||
PRAGMA journal_mode = WAL;
|
||||
CREATE TABLE IF NOT EXISTS samples (
|
||||
minute INTEGER PRIMARY KEY,
|
||||
cpu REAL, cpu_max REAL, memory REAL, pressure INTEGER,
|
||||
download REAL, upload REAL, gpu REAL, temperature REAL, power REAL
|
||||
);
|
||||
"""
|
||||
guard sqlite3_exec(handle, schema, nil, nil, nil) == SQLITE_OK else {
|
||||
let message = String(cString: sqlite3_errmsg(handle))
|
||||
sqlite3_close(handle)
|
||||
db = nil
|
||||
throw Error.open(message)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
sqlite3_close(db)
|
||||
}
|
||||
|
||||
public func insert(_ record: HistoryRecord) {
|
||||
let sql = """
|
||||
INSERT OR REPLACE INTO samples (minute, cpu, cpu_max, memory, pressure, download, upload, gpu, temperature, power)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_int64(statement, 1, Int64(record.minute))
|
||||
let values: [Double?] = [record.cpu, record.cpuMax, record.memory, record.pressure.map(Double.init),
|
||||
record.download, record.upload, record.gpu, record.temperature, record.power]
|
||||
for (index, value) in values.enumerated() {
|
||||
let position = Int32(index + 2)
|
||||
if let value, value.isFinite {
|
||||
sqlite3_bind_double(statement, position, value)
|
||||
} else {
|
||||
sqlite3_bind_null(statement, position)
|
||||
}
|
||||
}
|
||||
sqlite3_step(statement)
|
||||
}
|
||||
|
||||
/// 按 bucket 秒汇总:平均值取平均,CPU 峰值、内存压力、温度取最大
|
||||
public func query(from start: Date, to end: Date, bucket: Int) -> [HistoryPoint] {
|
||||
let size = max(60, bucket)
|
||||
let sql = """
|
||||
SELECT (minute / ?) * ? AS slot, AVG(cpu), MAX(cpu_max), AVG(memory), MAX(pressure),
|
||||
AVG(download), AVG(upload), AVG(gpu), MAX(temperature), AVG(power)
|
||||
FROM samples WHERE minute >= ? AND minute < ?
|
||||
GROUP BY slot ORDER BY slot
|
||||
"""
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_int64(statement, 1, Int64(size))
|
||||
sqlite3_bind_int64(statement, 2, Int64(size))
|
||||
sqlite3_bind_int64(statement, 3, Int64(start.timeIntervalSince1970))
|
||||
sqlite3_bind_int64(statement, 4, Int64(end.timeIntervalSince1970))
|
||||
|
||||
func double(_ column: Int32) -> Double? {
|
||||
sqlite3_column_type(statement, column) == SQLITE_NULL ? nil : sqlite3_column_double(statement, column)
|
||||
}
|
||||
var points: [HistoryPoint] = []
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
points.append(HistoryPoint(date: Date(timeIntervalSince1970: TimeInterval(sqlite3_column_int64(statement, 0))),
|
||||
cpu: double(1), cpuMax: double(2), memory: double(3), pressure: double(4).map { Int($0) },
|
||||
download: double(5), upload: double(6), gpu: double(7), temperature: double(8), power: double(9)))
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
public func prune(before date: Date) {
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, "DELETE FROM samples WHERE minute < ?", -1, &statement, nil) == SQLITE_OK else { return }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_int64(statement, 1, Int64(date.timeIntervalSince1970))
|
||||
sqlite3_step(statement)
|
||||
}
|
||||
|
||||
public func clear() {
|
||||
sqlite3_exec(db, "DELETE FROM samples; VACUUM;", nil, nil, nil)
|
||||
}
|
||||
|
||||
public func count() -> Int {
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM samples", -1, &statement, nil) == SQLITE_OK else { return 0 }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
return sqlite3_step(statement) == SQLITE_ROW ? Int(sqlite3_column_int64(statement, 0)) : 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 把一分钟内的多次采样合成一条记录
|
||||
public struct HistoryAccumulator: Sendable {
|
||||
private var minute: Int?
|
||||
private var sums: [String: (total: Double, count: Int)] = [:]
|
||||
private var maxima: [String: Double] = [:]
|
||||
|
||||
public init() {}
|
||||
|
||||
/// 加入一次采样;跨到下一分钟时返回上一分钟的记录
|
||||
public mutating func add(date: Date, cpu: Double?, memory: Double?, pressure: Int?, download: Double?, upload: Double?,
|
||||
gpu: Double?, temperature: Double?, power: Double?) -> HistoryRecord? {
|
||||
let current = Int(date.timeIntervalSince1970) / 60 * 60
|
||||
var finished: HistoryRecord?
|
||||
if let minute, minute != current {
|
||||
finished = record(for: minute)
|
||||
sums = [:]
|
||||
maxima = [:]
|
||||
}
|
||||
minute = current
|
||||
func average(_ key: String, _ value: Double?) {
|
||||
guard let value, value.isFinite else { return }
|
||||
let entry = sums[key] ?? (0, 0)
|
||||
sums[key] = (entry.total + value, entry.count + 1)
|
||||
}
|
||||
func peak(_ key: String, _ value: Double?) {
|
||||
guard let value, value.isFinite else { return }
|
||||
maxima[key] = max(maxima[key] ?? value, value)
|
||||
}
|
||||
average("cpu", cpu); peak("cpu", cpu)
|
||||
average("memory", memory)
|
||||
peak("pressure", pressure.map(Double.init))
|
||||
average("download", download)
|
||||
average("upload", upload)
|
||||
average("gpu", gpu)
|
||||
peak("temperature", temperature)
|
||||
average("power", power)
|
||||
return finished
|
||||
}
|
||||
|
||||
/// 退出或暂停时写入当前这一分钟
|
||||
public mutating func flush() -> HistoryRecord? {
|
||||
guard let minute, !sums.isEmpty || !maxima.isEmpty else { return nil }
|
||||
defer {
|
||||
self.minute = nil
|
||||
sums = [:]
|
||||
maxima = [:]
|
||||
}
|
||||
return record(for: minute)
|
||||
}
|
||||
|
||||
private func record(for minute: Int) -> HistoryRecord {
|
||||
func mean(_ key: String) -> Double? { sums[key].map { $0.total / Double($0.count) } }
|
||||
return HistoryRecord(minute: minute, cpu: mean("cpu"), cpuMax: maxima["cpu"], memory: mean("memory"),
|
||||
pressure: maxima["pressure"].map { Int($0) }, download: mean("download"), upload: mean("upload"),
|
||||
gpu: mean("gpu"), temperature: maxima["temperature"], power: mean("power"))
|
||||
}
|
||||
}
|
||||
@@ -218,6 +218,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
guard let self else { return }
|
||||
self.menuBar.dismissPopovers()
|
||||
self.model.network.setPaused(true)
|
||||
self.model.history.flush()
|
||||
Task { await self.model.hub.setPaused(true) }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
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]
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ public struct MainWindowView: View {
|
||||
switch model.settings.panelTab {
|
||||
case .overview: OverviewPage()
|
||||
case .system: SystemInfoPage()
|
||||
case .history: HistoryPage()
|
||||
case .cpu: DetailPage { CPUPopover() }
|
||||
case .gpu: DetailPage { GPUPopover() }
|
||||
case .memory: DetailPage { MemoryPopover() }
|
||||
|
||||
@@ -14,7 +14,23 @@ enum SnapshotRenderer {
|
||||
let settings = AppSettings(defaults: defaults)
|
||||
settings.menuBarItems = [.cpu, .gpu, .memory, .network, .temperature]
|
||||
settings.probeSeconds = 1
|
||||
let model = AppModel(settings: settings)
|
||||
// 历史页用示例数据:最近 24 小时每分钟一条,中间留一段“睡眠”空档
|
||||
let historyURL = FileManager.default.temporaryDirectory.appendingPathComponent("openstats-snapshot-history.sqlite")
|
||||
try? FileManager.default.removeItem(at: historyURL)
|
||||
if let database = try? HistoryDatabase(url: historyURL) {
|
||||
let now = Int(Date().timeIntervalSince1970) / 60 * 60
|
||||
for index in 0..<(24 * 60) where !(600..<780).contains(index) {
|
||||
let t = Double(index)
|
||||
let wave = (sin(t / 90) + 1) / 2
|
||||
await database.insert(HistoryRecord(minute: now - (24 * 60 - index) * 60,
|
||||
cpu: 0.08 + wave * 0.3, cpuMax: min(1, 0.2 + wave * 0.6),
|
||||
memory: 0.55 + wave * 0.2, pressure: index % 400 == 0 ? 4 : 1,
|
||||
download: 200_000 + wave * 3_000_000, upload: 40_000 + wave * 400_000,
|
||||
gpu: index % 3 == 0 ? wave * 0.4 : nil, temperature: 45 + wave * 30,
|
||||
power: index > 1200 ? 20 + wave * 25 : nil))
|
||||
}
|
||||
}
|
||||
let model = AppModel(settings: settings, historyURL: historyURL)
|
||||
model.isMainWindowVisible = true
|
||||
model.network.setVisibility(inMenuBar: true, detailVisible: true)
|
||||
|
||||
@@ -41,6 +57,7 @@ enum SnapshotRenderer {
|
||||
// 清理页展示真实扫描结果(只读,不删除任何文件)
|
||||
model.cleaner.scan()
|
||||
while model.cleaner.isBusy { try? await Task.sleep(for: .milliseconds(200)) }
|
||||
model.history.load(.day)
|
||||
model.startupItems.refresh()
|
||||
model.uninstaller.loadApps()
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
|
||||
@@ -23,6 +23,7 @@ public final class AppModel {
|
||||
public let alerts: AlertController
|
||||
public let uninstaller = UninstallerController()
|
||||
let startupItems = StartupItemsController()
|
||||
public let history: HistoryRecorder
|
||||
@ObservationIgnored public let hub = MetricsHub()
|
||||
|
||||
public var isMainWindowVisible = false
|
||||
@@ -35,7 +36,7 @@ public final class AppModel {
|
||||
@ObservationIgnored var openMainWindow: (PanelTab?) -> Void = { _ in }
|
||||
@ObservationIgnored var quit: () -> Void = {}
|
||||
|
||||
public init(settings: AppSettings = AppSettings()) {
|
||||
public init(settings: AppSettings = AppSettings(), historyURL: URL? = HistoryDatabase.defaultURL) {
|
||||
let store = MetricsStore()
|
||||
let helper = HelperClient()
|
||||
self.settings = settings
|
||||
@@ -49,6 +50,7 @@ public final class AppModel {
|
||||
network = NetworkController(settings: settings, geo: geo)
|
||||
updates = UpdateController(settings: settings)
|
||||
alerts = AlertController(settings: settings)
|
||||
history = HistoryRecorder(settings: settings, databaseURL: historyURL)
|
||||
}
|
||||
|
||||
/// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项
|
||||
@@ -145,5 +147,6 @@ public final class AppModel {
|
||||
fans.evaluateSafety()
|
||||
keepAwake.evaluateBattery(store.battery)
|
||||
alerts.evaluate(store)
|
||||
history.record(snapshot)
|
||||
}
|
||||
}
|
||||
@@ -220,12 +220,12 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable {
|
||||
|
||||
/// 主窗口侧边栏的页面
|
||||
public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
|
||||
case overview, system, cpu, gpu, memory, disk, network, thermal, processes, keepAwake, cleaner, uninstaller, startupItems
|
||||
case overview, system, history, cpu, gpu, memory, disk, network, thermal, processes, keepAwake, cleaner, uninstaller, startupItems
|
||||
case settingsGeneral, settingsMenuBar, settingsNotifications, settingsHelper, settingsAbout
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
static let monitors: [PanelTab] = [.overview, .system, .cpu, .gpu, .memory, .disk, .network, .thermal]
|
||||
static let monitors: [PanelTab] = [.overview, .system, .history, .cpu, .gpu, .memory, .disk, .network, .thermal]
|
||||
static let tools: [PanelTab] = [.processes, .startupItems, .keepAwake, .cleaner, .uninstaller]
|
||||
static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsNotifications, .settingsHelper, .settingsAbout]
|
||||
|
||||
@@ -238,6 +238,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
|
||||
switch self {
|
||||
case .overview: "仪表盘"
|
||||
case .system: "本机信息"
|
||||
case .history: "历史"
|
||||
case .cpu: "CPU"
|
||||
case .gpu: "GPU"
|
||||
case .memory: "内存"
|
||||
@@ -261,6 +262,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
|
||||
switch self {
|
||||
case .overview: "square.grid.2x2"
|
||||
case .system: "laptopcomputer"
|
||||
case .history: "clock.arrow.circlepath"
|
||||
case .cpu: "cpu"
|
||||
case .gpu: "square.3.layers.3d"
|
||||
case .memory: "memorychip"
|
||||
@@ -388,6 +390,10 @@ public final class AppSettings {
|
||||
public var alertCPUTemperature: Int {
|
||||
didSet { defaults.set(alertCPUTemperature, forKey: Keys.alertCPUTemperature) }
|
||||
}
|
||||
/// 每分钟把主要指标写入本机历史库
|
||||
public var historyEnabled: Bool {
|
||||
didSet { defaults.set(historyEnabled, forKey: Keys.historyEnabled) }
|
||||
}
|
||||
/// 启动时与每天检查一次新版本
|
||||
public var autoCheckUpdates: Bool {
|
||||
didSet { defaults.set(autoCheckUpdates, forKey: Keys.autoCheckUpdates) }
|
||||
@@ -436,6 +442,7 @@ public final class AppSettings {
|
||||
geoIncludeCity = defaults.bool(forKey: Keys.geoIncludeCity)
|
||||
geoAutoUpdate = defaults.object(forKey: Keys.geoAutoUpdate) as? Bool ?? true
|
||||
autoCheckUpdates = defaults.object(forKey: Keys.autoCheckUpdates) as? Bool ?? true
|
||||
historyEnabled = defaults.object(forKey: Keys.historyEnabled) as? Bool ?? true
|
||||
enabledAlerts = Set(defaults.stringArray(forKey: Keys.enabledAlerts)?.compactMap(AlertKind.init(rawValue:)) ?? [])
|
||||
alertCPUTemperature = Self.alertTemperatureOptions.contains(defaults.integer(forKey: Keys.alertCPUTemperature))
|
||||
? defaults.integer(forKey: Keys.alertCPUTemperature) : 95
|
||||
@@ -490,6 +497,7 @@ public final class AppSettings {
|
||||
static let geoIncludeCity = "geoIncludeCity"
|
||||
static let geoAutoUpdate = "geoAutoUpdate"
|
||||
static let autoCheckUpdates = "autoCheckUpdates"
|
||||
static let historyEnabled = "historyEnabled"
|
||||
static let enabledAlerts = "enabledAlerts"
|
||||
static let alertCPUTemperature = "alertCPUTemperature"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
import Metrics
|
||||
import Observation
|
||||
|
||||
/// 把采样按分钟写入本机历史库,并为历史页查询数据
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class HistoryRecorder {
|
||||
public enum Range: String, CaseIterable, Identifiable, Sendable {
|
||||
case hour, day, week
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .hour: "1 小时"
|
||||
case .day: "24 小时"
|
||||
case .week: "7 天"
|
||||
}
|
||||
}
|
||||
|
||||
var duration: TimeInterval {
|
||||
switch self {
|
||||
case .hour: 60 * 60
|
||||
case .day: 24 * 60 * 60
|
||||
case .week: 7 * 24 * 60 * 60
|
||||
}
|
||||
}
|
||||
|
||||
/// 每个点代表的秒数:1 小时逐分钟,24 小时每 5 分钟,7 天每 30 分钟
|
||||
var bucket: Int {
|
||||
switch self {
|
||||
case .hour: 60
|
||||
case .day: 5 * 60
|
||||
case .week: 30 * 60
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public private(set) var points: [HistoryPoint] = []
|
||||
public private(set) var range: Range = .day
|
||||
public private(set) var rangeStart = Date()
|
||||
public private(set) var isQuerying = false
|
||||
public private(set) var recordCount = 0
|
||||
|
||||
@ObservationIgnored private let settings: AppSettings
|
||||
@ObservationIgnored private var database: HistoryDatabase?
|
||||
@ObservationIgnored private var accumulator = HistoryAccumulator()
|
||||
@ObservationIgnored private var lastPrune = Date.distantPast
|
||||
|
||||
init(settings: AppSettings, databaseURL: URL? = HistoryDatabase.defaultURL) {
|
||||
self.settings = settings
|
||||
if let databaseURL {
|
||||
database = try? HistoryDatabase(url: databaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// 每次采样后调用;只记录这次快照里真实采到的指标
|
||||
func record(_ snapshot: MetricsSnapshot) {
|
||||
guard settings.historyEnabled, let database else { return }
|
||||
let finished = accumulator.add(date: snapshot.date,
|
||||
cpu: snapshot.cpu?.total,
|
||||
memory: snapshot.memory?.usedFraction,
|
||||
pressure: snapshot.memory?.pressure.rawValue,
|
||||
download: snapshot.network?.downloadBytesPerSecond,
|
||||
upload: snapshot.network?.uploadBytesPerSecond,
|
||||
gpu: snapshot.gpu?.utilization,
|
||||
temperature: snapshot.sensors?.temperature(.cpu)?.maximum,
|
||||
power: snapshot.power?.system)
|
||||
guard let finished else { return }
|
||||
let now = Date()
|
||||
let prune = now.timeIntervalSince(lastPrune) > 60 * 60
|
||||
if prune { lastPrune = now }
|
||||
Task {
|
||||
await database.insert(finished)
|
||||
if prune { await database.prune(before: now.addingTimeInterval(-HistoryDatabase.retention)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 暂停采样(睡眠、锁屏)或退出前写入当前这一分钟
|
||||
func flush() {
|
||||
guard let database, let record = accumulator.flush() else { return }
|
||||
Task { await database.insert(record) }
|
||||
}
|
||||
|
||||
func load(_ range: Range? = nil) {
|
||||
if let range { self.range = range }
|
||||
guard let database else { return }
|
||||
let range = self.range
|
||||
let end = Date()
|
||||
let start = end.addingTimeInterval(-range.duration)
|
||||
isQuerying = true
|
||||
Task {
|
||||
let points = await database.query(from: start, to: end, bucket: range.bucket)
|
||||
let count = await database.count()
|
||||
guard self.range == range else { return }
|
||||
self.points = points
|
||||
rangeStart = start
|
||||
recordCount = count
|
||||
isQuerying = false
|
||||
}
|
||||
}
|
||||
|
||||
func clear() {
|
||||
guard let database else { return }
|
||||
accumulator = HistoryAccumulator()
|
||||
Task {
|
||||
await database.clear()
|
||||
load()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Metrics
|
||||
|
||||
@Suite struct HistoryDatabaseTests {
|
||||
@Test func accumulatesPerMinute() {
|
||||
var accumulator = HistoryAccumulator()
|
||||
let base = Date(timeIntervalSince1970: 1_800_000_000 / 60 * 60)
|
||||
#expect(accumulator.add(date: base, cpu: 0.2, memory: 0.5, pressure: 1, download: 100, upload: 10, gpu: nil, temperature: 50, power: nil) == nil)
|
||||
#expect(accumulator.add(date: base.addingTimeInterval(30), cpu: 0.6, memory: 0.7, pressure: 2, download: 300, upload: 30, gpu: nil, temperature: 60, power: nil) == nil)
|
||||
let record = accumulator.add(date: base.addingTimeInterval(61), cpu: 0.1, memory: 0.1, pressure: 1, download: 0, upload: 0, gpu: nil, temperature: 40, power: nil)
|
||||
#expect(record?.minute == Int(base.timeIntervalSince1970))
|
||||
#expect(abs((record?.cpu ?? 0) - 0.4) < 1e-9)
|
||||
#expect(record?.cpuMax == 0.6)
|
||||
#expect(record?.pressure == 2)
|
||||
#expect(record?.download == 200)
|
||||
#expect(record?.temperature == 60)
|
||||
#expect(record?.gpu == nil)
|
||||
#expect(accumulator.flush()?.minute == Int(base.timeIntervalSince1970) + 60)
|
||||
#expect(accumulator.flush() == nil)
|
||||
}
|
||||
|
||||
@Test func storesQueriesAndPrunes() async throws {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("history-\(UUID().uuidString).sqlite")
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
let database = try HistoryDatabase(url: url)
|
||||
let start = 1_800_000_000 / 3600 * 3600
|
||||
for index in 0..<120 {
|
||||
await database.insert(HistoryRecord(minute: start + index * 60, cpu: Double(index % 2), cpuMax: 1, memory: 0.5,
|
||||
pressure: index == 5 ? 4 : 1, download: 10, upload: nil))
|
||||
}
|
||||
#expect(await database.count() == 120)
|
||||
|
||||
let hourly = await database.query(from: Date(timeIntervalSince1970: TimeInterval(start)),
|
||||
to: Date(timeIntervalSince1970: TimeInterval(start + 7200)), bucket: 3600)
|
||||
#expect(hourly.count == 2)
|
||||
#expect(hourly.first?.cpu == 0.5)
|
||||
#expect(hourly.first?.pressure == 4)
|
||||
#expect(hourly.last?.pressure == 1)
|
||||
#expect(hourly.first?.upload == nil)
|
||||
|
||||
await database.prune(before: Date(timeIntervalSince1970: TimeInterval(start + 3600)))
|
||||
#expect(await database.count() == 60)
|
||||
await database.clear()
|
||||
#expect(await database.count() == 0)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ private func isolatedDefaults() -> UserDefaults {
|
||||
@MainActor
|
||||
@Suite struct DemandTests {
|
||||
private func model() -> AppModel {
|
||||
AppModel(settings: AppSettings(defaults: isolatedDefaults()))
|
||||
AppModel(settings: AppSettings(defaults: isolatedDefaults()), historyURL: nil)
|
||||
}
|
||||
|
||||
@Test func menuBarOnlySamplesAtUserInterval() {
|
||||
|
||||
Reference in new issue
Block a user