From 9e236670e5f005ca3caf780d9808a0927bb3aa20 Mon Sep 17 00:00:00 2001 From: gentpan Date: Sun, 13 Sep 2026 08:46:58 +0500 Subject: [PATCH] =?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=9B=9E=E7=9C=8B=EF=BC=9A?= =?UTF-8?q?=E6=AF=8F=E5=88=86=E9=92=9F=E8=AE=B0=E5=BD=95=E5=88=B0=E6=9C=AC?= =?UTF-8?q?=E6=9C=BA=E6=95=B0=E6=8D=AE=E5=BA=93=EF=BC=8C1=20=E5=B0=8F?= =?UTF-8?q?=E6=97=B6=20/=2024=20=E5=B0=8F=E6=97=B6=20/=207=20=E5=A4=A9?= =?UTF-8?q?=E5=9B=BE=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../Sources/Metrics/HistoryDatabase.swift | 219 ++++++++++++++ .../OpenStatsUI/AppKit/AppController.swift | 1 + .../OpenStatsUI/Panel/HistoryPage.swift | 282 ++++++++++++++++++ .../OpenStatsUI/Panel/MainWindowView.swift | 1 + .../Snapshot/SnapshotRenderer.swift | 19 +- .../Sources/OpenStatsUI/State/AppModel.swift | 5 +- .../OpenStatsUI/State/AppSettings.swift | 12 +- .../OpenStatsUI/State/HistoryRecorder.swift | 112 +++++++ .../MetricsTests/HistoryDatabaseTests.swift | 47 +++ .../OpenStatsUITests/OpenStatsUITests.swift | 2 +- 11 files changed, 696 insertions(+), 5 deletions(-) create mode 100644 Packages/OpenStatsKit/Sources/Metrics/HistoryDatabase.swift create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/HistoryPage.swift create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/State/HistoryRecorder.swift create mode 100644 Packages/OpenStatsKit/Tests/MetricsTests/HistoryDatabaseTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef0061..12fdc1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - 磁盘页(主窗口侧边栏“磁盘”):启动磁盘容量、所有磁盘合计的实时读写速度与 60 秒走势、SSD 健康(通过系统自带的 NVMe SMART 接口读取剩余寿命、累计读写量、备用空间、温度、通电时间与次数、异常断电和介质错误,出现警告时提示备份)、读写磁盘最多的应用;磁盘空间不足的通知改为打开磁盘页。 - 卸载应用(主窗口工具分组):列出“应用程序”里的第三方应用及其体积,选中或把应用拖进来后找出它留下的应用数据、缓存、偏好设置、沙盒容器、窗口状态、日志、网页数据和登录启动项,可逐项取消勾选;确认后连同应用一起移到废纸篓(可放回),并可同时从程序坞移除图标。只匹配应用包名与同名目录,系统自带和 Apple 的应用不列出,正在运行时提示先退出。 - 启动项(主窗口工具分组):列出当前用户、所有用户的 LaunchAgents 和系统 LaunchDaemons,显示所属应用、可执行文件、是否登录时运行 / 保持运行,以及运行中(PID)、已加载、已停用状态;当前用户的启动项可以直接停用或重新启用(写入 launchd 停用记录并卸载,不删除文件),其余只读并提供登录项设置入口。 +- 历史(主窗口监控分组):每分钟把 CPU 平均与峰值、内存与内存压力、网络上下行、GPU、CPU 最高温度和整机功耗写入本机 SQLite 数据库,保留 7 天;可回看最近 1 小时 / 24 小时 / 7 天,睡眠等没有记录的时段线条断开,内存压力严重的时段标红,鼠标移到图上查看那一刻的数值;可关闭记录或清除历史,数据不上传。 #### 调整 diff --git a/Packages/OpenStatsKit/Sources/Metrics/HistoryDatabase.swift b/Packages/OpenStatsKit/Sources/Metrics/HistoryDatabase.swift new file mode 100644 index 0000000..3d4e7dc --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/HistoryDatabase.swift @@ -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")) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift index eeb746e..3201927 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -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) } } }) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/HistoryPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/HistoryPage.swift new file mode 100644 index 0000000..1ff7c32 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/HistoryPage.swift @@ -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 + 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] + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift index 4d83b8a..1fa8c69 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift @@ -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() } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift index e566258..d364044 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift @@ -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)) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index 81d18c4..f5dae56 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -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) } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index 809a34f..64979db 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -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" } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HistoryRecorder.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HistoryRecorder.swift new file mode 100644 index 0000000..a637ef6 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HistoryRecorder.swift @@ -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() + } + } +} diff --git a/Packages/OpenStatsKit/Tests/MetricsTests/HistoryDatabaseTests.swift b/Packages/OpenStatsKit/Tests/MetricsTests/HistoryDatabaseTests.swift new file mode 100644 index 0000000..c00c475 --- /dev/null +++ b/Packages/OpenStatsKit/Tests/MetricsTests/HistoryDatabaseTests.swift @@ -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) + } +} diff --git a/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift b/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift index d421721..e231d88 100644 --- a/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift +++ b/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift @@ -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() {