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:
hariel1985
2026-07-02 19:59:05 +02:00
szülő d59bf2b7b0
commit 222638fc98
7 fájl változott, egészen pontosan 469 új sor hozzáadva és 8 régi sor törölve

Fájl megtekintése

@@ -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()
}
}

Fájl megtekintése

@@ -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

Fájl megtekintése

@@ -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) { }
}

Fájl megtekintése

@@ -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)
}

Fájl megtekintése

@@ -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()
}
}
}
}