From 222638fc989b4430620eba8deb4ae3620214334f Mon Sep 17 00:00:00 2001 From: hariel1985 Date: Thu, 2 Jul 2026 19:59:05 +0200 Subject: [PATCH] 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 --- TopManager.xcodeproj/project.pbxproj | 12 ++ TopManager/Services/MetricsStore.swift | 184 ++++++++++++++++++ TopManager/Services/SystemMonitor.swift | 13 ++ TopManager/TopManagerApp.swift | 15 ++ .../Views/Performance/PerformanceView.swift | 42 +++- .../Views/Performance/TrendChartView.swift | 105 ++++++++++ TopManagerTests/MetricsStoreTests.swift | 106 ++++++++++ 7 files changed, 469 insertions(+), 8 deletions(-) create mode 100644 TopManager/Services/MetricsStore.swift create mode 100644 TopManager/Views/Performance/TrendChartView.swift create mode 100644 TopManagerTests/MetricsStoreTests.swift diff --git a/TopManager.xcodeproj/project.pbxproj b/TopManager.xcodeproj/project.pbxproj index 25ca6c0..f4dd3f8 100644 --- a/TopManager.xcodeproj/project.pbxproj +++ b/TopManager.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 136DAD1FE90FD682DF666667 /* TopManager_icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 8E4A6E8E2305CEDC119AC6BC /* TopManager_icon.png */; }; 1F212CE158B7D14B29077B18 /* CPUInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44281079654C7E1C9E16806C /* CPUInfo.swift */; }; 1F25A84B560709AE71C27D23 /* GPUInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F481045B4FC6017F50849FF1 /* GPUInfo.swift */; }; + 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */; }; 266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */; }; 341C12CED58388D32ED6C5BB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F1ACD0492CAB741595DEAA /* ContentView.swift */; }; 35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */; }; @@ -23,9 +24,11 @@ 833976FD8DC4CCC45078CF9A /* DiskMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */; }; 8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */; }; 9146403BC02D18A234444DF6 /* ProcessMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 295232B4C746511757DCF8FE /* ProcessMonitor.swift */; }; + 9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */; }; A0649565A902BA48DB10F592 /* CPUMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19ED582C412C63A0A2E2103E /* CPUMonitor.swift */; }; A7EC9FB55D5088D669491C11 /* AppsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813543165B39EBD213EB3BC7 /* AppsView.swift */; }; B62B18DF32A8734D5908B16B /* DiskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */; }; + BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */; }; C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110A314A63AE355A9E04990D /* ProcessDetailView.swift */; }; CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFADE83965AA863B775A910 /* NetworkChartView.swift */; }; D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A404EBD588B7ACD36079A95D /* TopManagerApp.swift */; }; @@ -50,6 +53,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsStore.swift; sourceTree = ""; }; + 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsStoreTests.swift; sourceTree = ""; }; 0F5A69E0EE7FDD44157911D7 /* TopManagerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TopManagerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 110A314A63AE355A9E04990D /* ProcessDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDetailView.swift; sourceTree = ""; }; 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskMonitor.swift; sourceTree = ""; }; @@ -74,6 +79,7 @@ 8F402CE13EF67DE550786B79 /* MemoryDonutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryDonutView.swift; sourceTree = ""; }; 94D5A13CC5B9CF1B90CC2214 /* CPUChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUChartView.swift; sourceTree = ""; }; 95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskUsageView.swift; sourceTree = ""; }; + 9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendChartView.swift; sourceTree = ""; }; 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; A3A3D0B1EB522C9975F71DA8 /* TopManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TopManager.app; sourceTree = BUILT_PRODUCTS_DIR; }; A404EBD588B7ACD36079A95D /* TopManagerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopManagerApp.swift; sourceTree = ""; }; @@ -94,6 +100,7 @@ 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */, BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */, 27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */, + 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */, 347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */, 295232B4C746511757DCF8FE /* ProcessMonitor.swift */, 279B972C08A878053821285D /* SystemMonitor.swift */, @@ -147,6 +154,7 @@ 7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */, BBFADE83965AA863B775A910 /* NetworkChartView.swift */, F80A817DCDA254349DEA6F06 /* PerformanceView.swift */, + 9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */, ); path = Performance; sourceTree = ""; @@ -201,6 +209,7 @@ isa = PBXGroup; children = ( 301352EB5B362C732CE1A246 /* FormattingTests.swift */, + 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */, ); path = TopManagerTests; sourceTree = ""; @@ -319,6 +328,7 @@ buildActionMask = 2147483647; files = ( 49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */, + 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -342,6 +352,7 @@ 266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */, F5722D2B18EF7ABF43847EE4 /* MemoryMonitor.swift in Sources */, 35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */, + BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */, CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */, E4AF7F7CA1E1FE8638CA5A77 /* NetworkInfo.swift in Sources */, EB0D84848F42134927F9C9B1 /* NetworkMonitor.swift in Sources */, @@ -353,6 +364,7 @@ 0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */, DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */, D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */, + 9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/TopManager/Services/MetricsStore.swift b/TopManager/Services/MetricsStore.swift new file mode 100644 index 0000000..4f3b2fc --- /dev/null +++ b/TopManager/Services/MetricsStore.swift @@ -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() + } +} diff --git a/TopManager/Services/SystemMonitor.swift b/TopManager/Services/SystemMonitor.swift index 7544263..aa0fc50 100644 --- a/TopManager/Services/SystemMonitor.swift +++ b/TopManager/Services/SystemMonitor.swift @@ -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 diff --git a/TopManager/TopManagerApp.swift b/TopManager/TopManagerApp.swift index 0a26dfc..d976e5d 100644 --- a/TopManager/TopManagerApp.swift +++ b/TopManager/TopManagerApp.swift @@ -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) { } } diff --git a/TopManager/Views/Performance/PerformanceView.swift b/TopManager/Views/Performance/PerformanceView.swift index a3cfd9d..f6a0965 100644 --- a/TopManager/Views/Performance/PerformanceView.swift +++ b/TopManager/Views/Performance/PerformanceView.swift @@ -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) } diff --git a/TopManager/Views/Performance/TrendChartView.swift b/TopManager/Views/Performance/TrendChartView.swift new file mode 100644 index 0000000..bb81936 --- /dev/null +++ b/TopManager/Views/Performance/TrendChartView.swift @@ -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 { + 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() + } + } + } +} diff --git a/TopManagerTests/MetricsStoreTests.swift b/TopManagerTests/MetricsStoreTests.swift new file mode 100644 index 0000000..e0ac239 --- /dev/null +++ b/TopManagerTests/MetricsStoreTests.swift @@ -0,0 +1,106 @@ +import XCTest +@testable import TopManager + +final class MetricsStoreTests: XCTestCase { + + private func sample(_ secondsAgo: TimeInterval, cpu: Double = 0, memUsed: UInt64 = 0, + memTotal: UInt64 = 100, down: Double = 0, up: Double = 0, + now: Date) -> MetricsSample { + MetricsSample(t: now.addingTimeInterval(-secondsAgo), cpu: cpu, + memUsed: memUsed, memTotal: memTotal, netDown: down, netUp: up) + } + + // MARK: - trim + + func testTrimDropsOldSamples() { + let now = Date() + let samples = [ + sample(10_000, now: now), // older than 1h + sample(100, now: now), // within 1h + sample(50, now: now) + ] + let trimmed = MetricsMath.trim(samples, now: now, maxAge: 3600, maxCount: 100) + XCTAssertEqual(trimmed.count, 2) + XCTAssertFalse(trimmed.contains { $0.t < now.addingTimeInterval(-3600) }) + } + + func testTrimCapsCountKeepingNewest() { + let now = Date() + // 10 samples, 0..9 seconds ago; keep newest 5 + let samples = (0..<10).map { sample(Double($0), cpu: Double($0), now: now) } + let trimmed = MetricsMath.trim(samples, now: now, maxAge: 3600, maxCount: 5) + XCTAssertEqual(trimmed.count, 5) + // suffix keeps the last 5 of the input order (cpu 5..9) + XCTAssertEqual(trimmed.map { $0.cpu }, [5, 6, 7, 8, 9]) + } + + // MARK: - downsample + + func testDownsampleZeroBucketIsPassthrough() { + let now = Date() + let samples = (0..<5).map { sample(Double($0), cpu: Double($0), now: now) } + XCTAssertEqual(MetricsMath.downsample(samples, bucket: 0).count, 5) + } + + func testDownsampleAveragesWithinBucket() { + // Two samples 0s and 5s into the same 60s bucket → averaged to one point. + let epoch = Date(timeIntervalSince1970: 1_000_000) // aligned into bucket 16666 + let s1 = MetricsSample(t: epoch, cpu: 20, memUsed: 40, memTotal: 100, netDown: 100, netUp: 10) + let s2 = MetricsSample(t: epoch.addingTimeInterval(5), cpu: 40, memUsed: 60, memTotal: 100, netDown: 300, netUp: 30) + let out = MetricsMath.downsample([s1, s2], bucket: 60) + XCTAssertEqual(out.count, 1) + XCTAssertEqual(out[0].cpu, 30, accuracy: 0.001) + XCTAssertEqual(out[0].memUsed, 50) + XCTAssertEqual(out[0].netDown, 200, accuracy: 0.001) + XCTAssertEqual(out[0].netUp, 20, accuracy: 0.001) + } + + func testDownsampleReducesPointCountForLongRange() { + let base = Date(timeIntervalSince1970: 2_000_000) + // 600 samples one second apart → into 60s buckets ≈ 10 points + let samples = (0..<600).map { + MetricsSample(t: base.addingTimeInterval(Double($0)), cpu: 1, memUsed: 1, memTotal: 100, netDown: 0, netUp: 0) + } + let out = MetricsMath.downsample(samples, bucket: 60) + XCTAssertLessThanOrEqual(out.count, 11) + XCTAssertGreaterThanOrEqual(out.count, 10) + // output must be time-sorted + XCTAssertEqual(out, out.sorted { $0.t < $1.t }) + } + + // MARK: - persistence round-trip + + func testStorePersistsAndReloads() { + let tmp = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("tm-test-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: tmp) } + + let store = MetricsStore(fileURL: tmp, maxAge: 3600, maxCount: 100, saveEvery: 1) + let now = Date() + store.record(MetricsSample(t: now, cpu: 42, memUsed: 5, memTotal: 10, netDown: 1, netUp: 2)) + + // saveEvery: 1 persists on the background io queue; wait for the file to appear. + let deadline = Date().addingTimeInterval(2) + while !FileManager.default.fileExists(atPath: tmp.path), Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.02)) + } + XCTAssertTrue(FileManager.default.fileExists(atPath: tmp.path), "history file was not written") + + let reloaded = MetricsStore(fileURL: tmp, maxAge: 3600, maxCount: 100, saveEvery: 1) + XCTAssertEqual(reloaded.count, 1) + XCTAssertEqual(reloaded.samples(since: now.addingTimeInterval(-1)).first?.cpu, 42) + } + + // MARK: - range config sanity + + func testHistoryRangeLiveHasNoWindow() { + XCTAssertNil(HistoryRange.live.seconds) + XCTAssertEqual(HistoryRange.live.bucket, 0) + } + + func testHistoryRangeWindowsIncrease() { + let windows = HistoryRange.allCases.compactMap { $0.seconds } + XCTAssertEqual(windows, windows.sorted()) + XCTAssertEqual(HistoryRange.h24.seconds, 86_400) + } +}