Increment 1: persistent metric history + extended time-range charts
- MetricsStore: thread-safe on-disk ring buffer (Application Support), throttled background saves, flush on quit/background, 24h/20k cap - MetricsMath: pure trim + downsample (unit-tested) - HistoryRange (Live/5m/30m/1h/24h) + HistoryRangePicker + MetricTrendChart - Wire recording into SystemMonitor; range picker in CPU/Memory/Network tabs - Fix durability: flush history on applicationWillTerminate + scenePhase.background - 8 new tests (30 total green); runtime smoke: no crash, 7 samples persisted Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
184
TopManager/Services/MetricsStore.swift
Normal file
184
TopManager/Services/MetricsStore.swift
Normal file
@@ -0,0 +1,184 @@
|
||||
import Foundation
|
||||
|
||||
/// A single point-in-time snapshot of system-wide metrics, persisted to disk so
|
||||
/// history survives app restarts (the live in-RAM histories only hold ~60 points).
|
||||
struct MetricsSample: Codable, Identifiable, Equatable {
|
||||
let t: Date // timestamp
|
||||
let cpu: Double // global CPU usage %
|
||||
let memUsed: UInt64
|
||||
let memTotal: UInt64
|
||||
let netDown: Double // bytes/sec
|
||||
let netUp: Double // bytes/sec
|
||||
|
||||
var id: Date { t }
|
||||
|
||||
var memPercent: Double {
|
||||
memTotal > 0 ? Double(memUsed) / Double(memTotal) * 100 : 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure, side-effect-free math over samples. Kept separate from `MetricsStore`
|
||||
/// so it is trivially unit-testable without touching disk or the clock.
|
||||
enum MetricsMath {
|
||||
/// Drop samples older than `maxAge` (relative to `now`) and cap total count,
|
||||
/// keeping the most recent `maxCount`.
|
||||
static func trim(_ samples: [MetricsSample], now: Date, maxAge: TimeInterval, maxCount: Int) -> [MetricsSample] {
|
||||
let cutoff = now.addingTimeInterval(-maxAge)
|
||||
var result = samples.filter { $0.t >= cutoff }
|
||||
if result.count > maxCount {
|
||||
result = Array(result.suffix(maxCount))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Average samples into fixed-width time buckets so long ranges (e.g. 24h)
|
||||
/// plot ~100 points instead of tens of thousands. `bucket <= 0` returns input.
|
||||
static func downsample(_ samples: [MetricsSample], bucket: TimeInterval) -> [MetricsSample] {
|
||||
guard bucket > 0, samples.count > 1 else { return samples }
|
||||
|
||||
var groups: [Int64: [MetricsSample]] = [:]
|
||||
for s in samples {
|
||||
let key = Int64((s.t.timeIntervalSince1970 / bucket).rounded(.down))
|
||||
groups[key, default: []].append(s)
|
||||
}
|
||||
|
||||
return groups.keys.sorted().map { key in
|
||||
let group = groups[key]!
|
||||
let n = Double(group.count)
|
||||
let midpoint = Date(timeIntervalSince1970: (Double(key) + 0.5) * bucket)
|
||||
return MetricsSample(
|
||||
t: midpoint,
|
||||
cpu: group.reduce(0) { $0 + $1.cpu } / n,
|
||||
memUsed: UInt64(group.reduce(0.0) { $0 + Double($1.memUsed) } / n),
|
||||
memTotal: group.map { $0.memTotal }.max() ?? 0,
|
||||
netDown: group.reduce(0) { $0 + $1.netDown } / n,
|
||||
netUp: group.reduce(0) { $0 + $1.netUp } / n
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Selectable time window for trend charts.
|
||||
enum HistoryRange: String, CaseIterable, Identifiable {
|
||||
case live = "Live"
|
||||
case m5 = "5m"
|
||||
case m30 = "30m"
|
||||
case h1 = "1h"
|
||||
case h24 = "24h"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Window length in seconds; `nil` for the live (in-RAM) view.
|
||||
var seconds: TimeInterval? {
|
||||
switch self {
|
||||
case .live: return nil
|
||||
case .m5: return 5 * 60
|
||||
case .m30: return 30 * 60
|
||||
case .h1: return 60 * 60
|
||||
case .h24: return 24 * 60 * 60
|
||||
}
|
||||
}
|
||||
|
||||
/// Downsample bucket size chosen to keep ~100–150 plotted points.
|
||||
var bucket: TimeInterval {
|
||||
switch self {
|
||||
case .live: return 0
|
||||
case .m5: return 0 // ~raw (refresh cadence already ~3s)
|
||||
case .m30: return 15
|
||||
case .h1: return 30
|
||||
case .h24: return 600
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe on-disk ring buffer of `MetricsSample`s.
|
||||
///
|
||||
/// Access to the backing array is guarded by a lock; file writes happen on a
|
||||
/// background queue from a snapshot so the recording thread never blocks on I/O.
|
||||
final class MetricsStore {
|
||||
static let shared = MetricsStore()
|
||||
|
||||
private let lock = NSLock()
|
||||
private var _samples: [MetricsSample] = []
|
||||
private let fileURL: URL
|
||||
private let maxAge: TimeInterval
|
||||
private let maxCount: Int
|
||||
private let saveEvery: Int
|
||||
private var unsaved = 0
|
||||
private let ioQueue = DispatchQueue(label: "com.topmanager.metricsstore.io", qos: .utility)
|
||||
|
||||
init(fileURL: URL? = nil,
|
||||
maxAge: TimeInterval = 24 * 60 * 60,
|
||||
maxCount: Int = 20_000,
|
||||
saveEvery: Int = 10) {
|
||||
self.fileURL = fileURL ?? Self.defaultFileURL()
|
||||
self.maxAge = maxAge
|
||||
self.maxCount = maxCount
|
||||
self.saveEvery = saveEvery
|
||||
load()
|
||||
}
|
||||
|
||||
static func defaultFileURL() -> URL {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
let dir = base.appendingPathComponent("TopManager", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent("metrics-history.json")
|
||||
}
|
||||
|
||||
/// Append a sample, trim, and persist on a throttled cadence.
|
||||
func record(_ sample: MetricsSample) {
|
||||
lock.lock()
|
||||
_samples.append(sample)
|
||||
_samples = MetricsMath.trim(_samples, now: sample.t, maxAge: maxAge, maxCount: maxCount)
|
||||
unsaved += 1
|
||||
let snapshot: [MetricsSample]? = unsaved >= saveEvery ? _samples : nil
|
||||
if snapshot != nil { unsaved = 0 }
|
||||
lock.unlock()
|
||||
|
||||
if let snapshot { persist(snapshot) }
|
||||
}
|
||||
|
||||
func samples(since date: Date) -> [MetricsSample] {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return _samples.filter { $0.t >= date }
|
||||
}
|
||||
|
||||
var count: Int {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return _samples.count
|
||||
}
|
||||
|
||||
/// Samples for a chart range, downsampled for long windows.
|
||||
func downsampled(for range: HistoryRange, now: Date = Date()) -> [MetricsSample] {
|
||||
guard let secs = range.seconds else { return [] }
|
||||
let windowed = samples(since: now.addingTimeInterval(-secs))
|
||||
return MetricsMath.downsample(windowed, bucket: range.bucket)
|
||||
}
|
||||
|
||||
/// Force an immediate save (e.g. on stop / quit).
|
||||
func flush() {
|
||||
lock.lock()
|
||||
let snapshot = _samples
|
||||
unsaved = 0
|
||||
lock.unlock()
|
||||
persist(snapshot)
|
||||
}
|
||||
|
||||
private func persist(_ snapshot: [MetricsSample]) {
|
||||
ioQueue.async { [fileURL] in
|
||||
guard let data = try? JSONEncoder().encode(snapshot) else { return }
|
||||
try? data.write(to: fileURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let decoded = try? JSONDecoder().decode([MetricsSample].self, from: data) else {
|
||||
return
|
||||
}
|
||||
lock.lock()
|
||||
_samples = MetricsMath.trim(decoded, now: Date(), maxAge: maxAge, maxCount: maxCount)
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ final class SystemMonitor: ObservableObject {
|
||||
@MainActor func stopMonitoring() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
MetricsStore.shared.flush()
|
||||
}
|
||||
|
||||
private func refreshAllBackground() {
|
||||
@@ -177,6 +178,18 @@ final class SystemMonitor: ObservableObject {
|
||||
if let info = gpu {
|
||||
gpuInfo = info
|
||||
}
|
||||
|
||||
// Persist a system-wide sample so history survives restarts and long ranges.
|
||||
if let c = cpuInfo, let m = memoryInfo {
|
||||
MetricsStore.shared.record(MetricsSample(
|
||||
t: c.timestamp,
|
||||
cpu: c.globalUsage,
|
||||
memUsed: m.usedMemory,
|
||||
memTotal: m.totalMemory,
|
||||
netDown: networkInfo?.totalDownloadRate ?? 0,
|
||||
netUp: networkInfo?.totalUploadRate ?? 0
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Process control methods with PID validation
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Flushes persisted metrics history when the app is quit so history isn't lost
|
||||
/// between the throttled background saves.
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
MetricsStore.shared.flush()
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct TopManagerApp: App {
|
||||
@StateObject private var monitor = SystemMonitor.shared
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
@@ -14,6 +24,11 @@ struct TopManagerApp: App {
|
||||
}
|
||||
.windowStyle(.automatic)
|
||||
.defaultSize(width: 1000, height: 700)
|
||||
.onChange(of: scenePhase) { phase in
|
||||
if phase == .background {
|
||||
MetricsStore.shared.flush()
|
||||
}
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) { }
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ struct PerformanceView: View {
|
||||
|
||||
struct CPUTabView: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
@State private var range: HistoryRange = .live
|
||||
|
||||
private let coreColumns = [
|
||||
GridItem(.flexible()),
|
||||
@@ -48,6 +49,7 @@ struct CPUTabView: View {
|
||||
HStack {
|
||||
Text("Total CPU Usage")
|
||||
.font(.headline)
|
||||
HistoryRangePicker(range: $range)
|
||||
Spacer()
|
||||
if let cpu = monitor.cpuInfo {
|
||||
Text(String(format: "%.1f%%", cpu.globalUsage))
|
||||
@@ -57,8 +59,13 @@ struct CPUTabView: View {
|
||||
}
|
||||
}
|
||||
|
||||
CPULineChart(history: monitor.cpuHistory)
|
||||
.frame(height: 120)
|
||||
if range == .live {
|
||||
CPULineChart(history: monitor.cpuHistory)
|
||||
.frame(height: 120)
|
||||
} else {
|
||||
MetricTrendChart(samples: MetricsStore.shared.downsampled(for: range), metric: .cpu)
|
||||
.frame(height: 120)
|
||||
}
|
||||
|
||||
// Legend
|
||||
HStack(spacing: 20) {
|
||||
@@ -201,6 +208,7 @@ struct CoreLineChartView: View {
|
||||
|
||||
struct MemoryTabView: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
@State private var range: HistoryRange = .live
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -230,6 +238,7 @@ struct MemoryTabView: View {
|
||||
HStack {
|
||||
Text("Memory Usage Over Time")
|
||||
.font(.headline)
|
||||
HistoryRangePicker(range: $range)
|
||||
Spacer()
|
||||
if let mem = monitor.memoryInfo {
|
||||
Text(String(format: "%.1f%%", mem.usagePercentage))
|
||||
@@ -239,8 +248,13 @@ struct MemoryTabView: View {
|
||||
}
|
||||
}
|
||||
|
||||
MemoryLineChart(history: monitor.memoryHistory)
|
||||
.frame(height: 150)
|
||||
if range == .live {
|
||||
MemoryLineChart(history: monitor.memoryHistory)
|
||||
.frame(height: 150)
|
||||
} else {
|
||||
MetricTrendChart(samples: MetricsStore.shared.downsampled(for: range), metric: .memory)
|
||||
.frame(height: 150)
|
||||
}
|
||||
|
||||
if let mem = monitor.memoryInfo {
|
||||
HStack {
|
||||
@@ -394,6 +408,7 @@ struct MemoryStatItem: View {
|
||||
|
||||
struct NetworkTabView: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
@State private var range: HistoryRange = .live
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -405,6 +420,7 @@ struct NetworkTabView: View {
|
||||
Label("Download", systemImage: "arrow.down.circle")
|
||||
.font(.headline)
|
||||
.foregroundColor(.blue)
|
||||
HistoryRangePicker(range: $range)
|
||||
Spacer()
|
||||
if let net = monitor.networkInfo {
|
||||
Text(formatBytesPerSecond(net.totalDownloadRate))
|
||||
@@ -414,8 +430,13 @@ struct NetworkTabView: View {
|
||||
}
|
||||
}
|
||||
|
||||
DownloadLineChart(history: monitor.networkHistory)
|
||||
.frame(height: 150)
|
||||
if range == .live {
|
||||
DownloadLineChart(history: monitor.networkHistory)
|
||||
.frame(height: 150)
|
||||
} else {
|
||||
MetricTrendChart(samples: MetricsStore.shared.downsampled(for: range), metric: .download)
|
||||
.frame(height: 150)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
@@ -436,8 +457,13 @@ struct NetworkTabView: View {
|
||||
}
|
||||
}
|
||||
|
||||
UploadLineChart(history: monitor.networkHistory)
|
||||
.frame(height: 150)
|
||||
if range == .live {
|
||||
UploadLineChart(history: monitor.networkHistory)
|
||||
.frame(height: 150)
|
||||
} else {
|
||||
MetricTrendChart(samples: MetricsStore.shared.downsampled(for: range), metric: .upload)
|
||||
.frame(height: 150)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
105
TopManager/Views/Performance/TrendChartView.swift
Normal file
105
TopManager/Views/Performance/TrendChartView.swift
Normal file
@@ -0,0 +1,105 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
/// A range selector segmented control used above the live/trend charts.
|
||||
struct HistoryRangePicker: View {
|
||||
@Binding var range: HistoryRange
|
||||
|
||||
var body: some View {
|
||||
Picker("", selection: $range) {
|
||||
ForEach(HistoryRange.allCases) { r in
|
||||
Text(r.rawValue).tag(r)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
/// Store-backed trend chart. Plots one metric of `MetricsSample` over a chosen
|
||||
/// window. Used for the non-live ranges (5m / 30m / 1h / 24h).
|
||||
struct MetricTrendChart: View {
|
||||
enum Metric { case cpu, memory, download, upload }
|
||||
|
||||
let samples: [MetricsSample]
|
||||
let metric: Metric
|
||||
|
||||
private var color: Color {
|
||||
switch metric {
|
||||
case .cpu: return .gray
|
||||
case .memory: return .purple
|
||||
case .download: return .blue
|
||||
case .upload: return .green
|
||||
}
|
||||
}
|
||||
|
||||
private func value(_ s: MetricsSample) -> Double {
|
||||
switch metric {
|
||||
case .cpu: return s.cpu
|
||||
case .memory: return s.memPercent
|
||||
case .download: return s.netDown
|
||||
case .upload: return s.netUp
|
||||
}
|
||||
}
|
||||
|
||||
private var isPercent: Bool {
|
||||
metric == .cpu || metric == .memory
|
||||
}
|
||||
|
||||
private var yDomain: ClosedRange<Double> {
|
||||
if isPercent { return -5...105 }
|
||||
let maxV = max(samples.map(value).max() ?? 0, 1024)
|
||||
return 0...(maxV * 1.1)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if samples.isEmpty {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("Collecting history…")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(samples) { point in
|
||||
LineMark(
|
||||
x: .value("Time", point.t),
|
||||
y: .value("Value", value(point))
|
||||
)
|
||||
.foregroundStyle(color)
|
||||
.lineStyle(StrokeStyle(lineWidth: 1.5))
|
||||
|
||||
AreaMark(
|
||||
x: .value("Time", point.t),
|
||||
y: .value("Value", value(point))
|
||||
)
|
||||
.foregroundStyle(color.opacity(0.12))
|
||||
}
|
||||
}
|
||||
.chartYScale(domain: yDomain)
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let v = value.as(Double.self) {
|
||||
Text(isPercent ? "\(Int(v))%" : formatBytesPerSecond(v))
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .automatic(desiredCount: 4)) { _ in
|
||||
AxisGridLine()
|
||||
}
|
||||
}
|
||||
.drawingGroup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user