历史回看:每分钟记录到本机数据库,1 小时 / 24 小时 / 7 天图表

This commit is contained in:
gentpan committed 2026-09-13 08:46:58 +05:00
1 parent 1a79a5d98d
commit 9e236670e5
11 files changed
+696 -5

No files matched your search

+1
View File
@@ -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() {