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:
@@ -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 = "<group>"; };
|
||||
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsStoreTests.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskMonitor.swift; sourceTree = "<group>"; };
|
||||
@@ -74,6 +79,7 @@
|
||||
8F402CE13EF67DE550786B79 /* MemoryDonutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryDonutView.swift; sourceTree = "<group>"; };
|
||||
94D5A13CC5B9CF1B90CC2214 /* CPUChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUChartView.swift; sourceTree = "<group>"; };
|
||||
95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskUsageView.swift; sourceTree = "<group>"; };
|
||||
9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendChartView.swift; sourceTree = "<group>"; };
|
||||
9D71CC63FC2FEF52A069372E /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
@@ -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 = "<group>";
|
||||
@@ -201,6 +209,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
301352EB5B362C732CE1A246 /* FormattingTests.swift */,
|
||||
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */,
|
||||
);
|
||||
path = TopManagerTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
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 {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
106
TopManagerTests/MetricsStoreTests.swift
Normal file
106
TopManagerTests/MetricsStoreTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user