diff --git a/TCPViewer/Features/NetworkInspector/Services/TCPViewerStatusMetricsService.swift b/TCPViewer/Features/NetworkInspector/Services/TCPViewerStatusMetricsService.swift new file mode 100644 index 0000000..4739d62 --- /dev/null +++ b/TCPViewer/Features/NetworkInspector/Services/TCPViewerStatusMetricsService.swift @@ -0,0 +1,494 @@ +// +// TCPViewerStatusMetricsService.swift +// TCPViewer +// +// Created by Proxyman LLC on 11/6/26. +// + +import Darwin.Mach +import Foundation +import PcapPlusPlusCore + +struct TCPViewerStatusMetricsSnapshot: Equatable, Sendable { + let memoryBytes: UInt64 + let uploadBytesPerSecond: UInt64 + let downloadBytesPerSecond: UInt64 + + static let empty = TCPViewerStatusMetricsSnapshot( + memoryBytes: 0, + uploadBytesPerSecond: 0, + downloadBytesPerSecond: 0 + ) +} + +struct TCPViewerCapturedTrafficSample: Equatable, Sendable { + let uploadBytes: UInt64 + let downloadBytes: UInt64 + + static let zero = TCPViewerCapturedTrafficSample(uploadBytes: 0, downloadBytes: 0) + + var isEmpty: Bool { + uploadBytes == 0 && downloadBytes == 0 + } + + func adding(_ sample: TCPViewerCapturedTrafficSample) -> TCPViewerCapturedTrafficSample { + TCPViewerCapturedTrafficSample( + uploadBytes: uploadBytes &+ sample.uploadBytes, + downloadBytes: downloadBytes &+ sample.downloadBytes + ) + } +} + +enum TCPViewerStatusMetricsFormatter { + static func displayText(for snapshot: TCPViewerStatusMetricsSnapshot) -> String { + "• \(memoryText(bytes: snapshot.memoryBytes)) ↑ \(speedText(bytesPerSecond: snapshot.uploadBytesPerSecond)) ↓ \(speedText(bytesPerSecond: snapshot.downloadBytesPerSecond))" + } + + static func memoryText(bytes: UInt64) -> String { + let gigabyte: UInt64 = 1_024 * 1_024 * 1_024 + let megabyte: UInt64 = 1_024 * 1_024 + if bytes >= gigabyte { + return "\(ceilDivide(bytes, by: gigabyte)) GB" + } + + return "\(ceilDivide(bytes, by: megabyte)) MB" + } + + static func speedText(bytesPerSecond: UInt64) -> String { + guard bytesPerSecond > 0 else { + return "0 KB/s" + } + + let megabyte: UInt64 = 1_024 * 1_024 + let kilobyte: UInt64 = 1_024 + if bytesPerSecond >= megabyte { + return "\(ceilDivide(bytesPerSecond, by: megabyte)) MB/s" + } + + return "\(ceilDivide(bytesPerSecond, by: kilobyte)) KB/s" + } + + private static func ceilDivide(_ value: UInt64, by unit: UInt64) -> UInt64 { + guard value > 0 else { + return 0 + } + + return (value / unit) + (value.isMultiple(of: unit) ? 0 : 1) + } +} + +final class TCPViewerStatusMetricsService { + typealias SnapshotHandler = (TCPViewerStatusMetricsSnapshot) -> Void + typealias MemorySampler = () -> UInt64? + typealias DateProvider = () -> Date + + private struct State { + var pendingTraffic = TCPViewerCapturedTrafficSample.zero + var pendingDirectionalPacketIDs: Set = [] + var pendingDirectionalPacketIDsByStreamID: [UInt32: [PacketSummary.ID]] = [:] + var monitoredInterfaceID: String? + var monitoredLocalAddresses: Set = [] + var latestSnapshot = TCPViewerStatusMetricsSnapshot.empty + var lastSampleDate: Date? + var observedPacketRevision: UInt64 = 0 + var observedPacketLineageRevision: UInt64 = 0 + } + + var snapshotHandler: SnapshotHandler? + + private let timerInterval: TimeInterval + private let memorySampler: MemorySampler + private let dateProvider: DateProvider + private let timerQueue: DispatchQueue + private let callbackQueue: DispatchQueue + private let state = Protected(State()) + private var timer: DispatchSourceTimer? + + // Mirror the metadata backfill cap so delayed direction bookkeeping stays bounded per flow. + private static let maxPendingDirectionalPacketIDsPerStream = 128 + + init( + timerInterval: TimeInterval = 2, + memorySampler: @escaping MemorySampler = TCPViewerStatusMetricsService.currentMemoryFootprintBytes, + dateProvider: @escaping DateProvider = Date.init, + timerQueue: DispatchQueue = DispatchQueue(label: "com.proxyman.tcpviewer.StatusMetrics", qos: .utility), + callbackQueue: DispatchQueue = .main + ) { + self.timerInterval = max(timerInterval, 0.1) + self.memorySampler = memorySampler + self.dateProvider = dateProvider + self.timerQueue = timerQueue + self.callbackQueue = callbackQueue + } + + deinit { + stop() + } + + var snapshot: TCPViewerStatusMetricsSnapshot { + state.read(\.latestSnapshot) + } + + var isMonitoring: Bool { + state.read { $0.monitoredInterfaceID != nil } + } + + var isSampling: Bool { + timer != nil + } + + // Enable or disable network counters from the current recording state and interface. + @discardableResult + func updateMonitoring( + interfaceID: String?, + localAddresses: Set = [], + baselineIngestState: PacketIngestState, + startsTimer: Bool = true + ) -> TCPViewerStatusMetricsSnapshot { + let trimmedInterfaceID = interfaceID?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedInterfaceID = trimmedInterfaceID?.isEmpty == false ? trimmedInterfaceID : nil + let normalizedLocalAddresses = Set(localAddresses.compactMap(Self.normalizedAddress)) + + let nextSnapshot = state.write { state -> TCPViewerStatusMetricsSnapshot in + guard state.monitoredInterfaceID != normalizedInterfaceID || + state.monitoredLocalAddresses != normalizedLocalAddresses else { + return state.latestSnapshot + } + + state.monitoredInterfaceID = normalizedInterfaceID + state.monitoredLocalAddresses = normalizedInterfaceID == nil ? [] : normalizedLocalAddresses + resetTraffic(in: &state) + state.observedPacketRevision = baselineIngestState.packetRevision + state.observedPacketLineageRevision = baselineIngestState.packetLineageRevision + return state.latestSnapshot + } + + if startsTimer { + start() + } + + return nextSnapshot + } + + // Start the lightweight timer and publish an immediate baseline sample. + func start() { + guard timer == nil else { + return + } + + sampleNow() + let timer = DispatchSource.makeTimerSource(queue: timerQueue) + timer.schedule( + deadline: .now() + timerInterval, + repeating: timerInterval, + leeway: .milliseconds(250) + ) + timer.setEventHandler { [weak self] in + self?.sampleNow() + } + self.timer = timer + timer.resume() + } + + // Stop sampling when the owning view model goes away. + func stop() { + timer?.setEventHandler {} + timer?.cancel() + timer = nil + } + + // Accumulate only new live append ranges from the ingest state. + func recordPacketIngestState(_ ingestState: PacketIngestState) { + state.write { state in + guard let monitoredInterfaceID = state.monitoredInterfaceID else { + return + } + let monitoredLocalAddresses = state.monitoredLocalAddresses + + if ingestState.packetLineageRevision != state.observedPacketLineageRevision { + resetTraffic(in: &state) + } + + defer { + state.observedPacketRevision = ingestState.packetRevision + state.observedPacketLineageRevision = ingestState.packetLineageRevision + } + + guard ingestState.source == .live, + ingestState.packetRevision != state.observedPacketRevision else { + return + } + + switch ingestState.lastMutation { + case .append(let range): + guard range.lowerBound >= 0, + range.upperBound <= ingestState.packets.count else { + return + } + + let sample = Self.trafficSample( + for: ingestState.packets[range], + monitoredInterfaceID: monitoredInterfaceID, + monitoredLocalAddresses: monitoredLocalAddresses, + state: &state + ) + state.pendingTraffic = state.pendingTraffic.adding(sample) + case .appendWithMetadataUpdates(let range, let updatedPacketIDs): + guard range.lowerBound >= 0, + range.upperBound <= ingestState.packets.count else { + return + } + + let appendedSample = Self.trafficSample( + for: ingestState.packets[range], + monitoredInterfaceID: monitoredInterfaceID, + monitoredLocalAddresses: monitoredLocalAddresses, + state: &state + ) + let updatedSample = Self.trafficSample( + forUpdatedPacketIDs: updatedPacketIDs, + ingestState: ingestState, + monitoredInterfaceID: monitoredInterfaceID, + monitoredLocalAddresses: monitoredLocalAddresses, + state: &state + ) + let sample = appendedSample.adding(updatedSample) + state.pendingTraffic = state.pendingTraffic.adding(sample) + case .reset, .replace: + resetTraffic(in: &state) + case .metadataUpdate(let packetIDs): + let sample = Self.trafficSample( + forUpdatedPacketIDs: packetIDs, + ingestState: ingestState, + monitoredInterfaceID: monitoredInterfaceID, + monitoredLocalAddresses: monitoredLocalAddresses, + state: &state + ) + state.pendingTraffic = state.pendingTraffic.adding(sample) + case .none: + break + } + } + } + + // Reset pending traffic while preserving the current memory reading. + func resetTraffic() { + state.write { state in + resetTraffic(in: &state) + } + } + + // Produce a new snapshot from pending bytes and the current memory footprint. + @discardableResult + func sampleNow(notifiesHandler: Bool = true) -> TCPViewerStatusMetricsSnapshot { + let now = dateProvider() + let memoryBytes = memorySampler() + let nextSnapshot = state.write { state -> TCPViewerStatusMetricsSnapshot in + let previousDate = state.lastSampleDate + let elapsed = previousDate.map { now.timeIntervalSince($0) } ?? 0 + let isMonitoring = state.monitoredInterfaceID != nil + let uploadBytesPerSecond = isMonitoring ? Self.bytesPerSecond(state.pendingTraffic.uploadBytes, elapsed: elapsed) : 0 + let downloadBytesPerSecond = isMonitoring ? Self.bytesPerSecond(state.pendingTraffic.downloadBytes, elapsed: elapsed) : 0 + let snapshot = TCPViewerStatusMetricsSnapshot( + memoryBytes: memoryBytes ?? state.latestSnapshot.memoryBytes, + uploadBytesPerSecond: uploadBytesPerSecond, + downloadBytesPerSecond: downloadBytesPerSecond + ) + state.pendingTraffic = .zero + state.latestSnapshot = snapshot + state.lastSampleDate = now + return snapshot + } + + if notifiesHandler { + deliver(nextSnapshot) + } + return nextSnapshot + } + + // Read the current process footprint so the value stays close to Activity Monitor's Memory column. + private static func currentMemoryFootprintBytes() -> UInt64? { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { reboundPointer in + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), reboundPointer, &count) + } + } + + guard result == KERN_SUCCESS else { + return nil + } + + return UInt64(info.phys_footprint) + } + + // Convert an appended packet slice into directional byte counts. + private static func trafficSample( + for packets: ArraySlice, + monitoredInterfaceID: String, + monitoredLocalAddresses: Set, + state: inout State + ) -> TCPViewerCapturedTrafficSample { + packets.reduce(into: TCPViewerCapturedTrafficSample.zero) { sample, packet in + guard packetMatchesMonitoredInterface(packet, interfaceID: monitoredInterfaceID) else { + return + } + + let byteCount = UInt64(max(packet.originalLength, 0)) + switch trafficDirection(for: packet, localAddresses: monitoredLocalAddresses) { + case .outbound: + removePendingDirectionalPacket(packet, state: &state) + sample = sample.adding(TCPViewerCapturedTrafficSample(uploadBytes: byteCount, downloadBytes: 0)) + case .inbound: + removePendingDirectionalPacket(packet, state: &state) + sample = sample.adding(TCPViewerCapturedTrafficSample(uploadBytes: 0, downloadBytes: byteCount)) + case .local: + removePendingDirectionalPacket(packet, state: &state) + case .unknown, nil: + appendPendingDirectionalPacketIfNeeded(packet, state: &state) + break + @unknown default: + break + } + } + } + + // Count packets whose direction arrived after append, without recounting already handled packets. + private static func trafficSample( + forUpdatedPacketIDs packetIDs: [PacketSummary.ID], + ingestState: PacketIngestState, + monitoredInterfaceID: String, + monitoredLocalAddresses: Set, + state: inout State + ) -> TCPViewerCapturedTrafficSample { + packetIDs.reduce(into: TCPViewerCapturedTrafficSample.zero) { sample, packetID in + guard state.pendingDirectionalPacketIDs.contains(packetID), + let packet = ingestState.packet(withID: packetID), + packetMatchesMonitoredInterface(packet, interfaceID: monitoredInterfaceID) else { + return + } + + let byteCount = UInt64(max(packet.originalLength, 0)) + switch trafficDirection(for: packet, localAddresses: monitoredLocalAddresses) { + case .outbound: + removePendingDirectionalPacket(packet, state: &state) + sample = sample.adding(TCPViewerCapturedTrafficSample(uploadBytes: byteCount, downloadBytes: 0)) + case .inbound: + removePendingDirectionalPacket(packet, state: &state) + sample = sample.adding(TCPViewerCapturedTrafficSample(uploadBytes: 0, downloadBytes: byteCount)) + case .local: + removePendingDirectionalPacket(packet, state: &state) + case .unknown, nil: + break + @unknown default: + break + } + } + } + + private static func bytesPerSecond(_ bytes: UInt64, elapsed: TimeInterval) -> UInt64 { + guard bytes > 0, elapsed > 0 else { + return 0 + } + + return UInt64(ceil(Double(bytes) / elapsed)) + } + + private static func packetMatchesMonitoredInterface(_ packet: PacketSummary, interfaceID: String) -> Bool { + packet.interfaceID == interfaceID || packet.captureMetadata.interfaceName == interfaceID + } + + private static func trafficDirection(for packet: PacketSummary, localAddresses: Set) -> PacketDirection? { + endpointTrafficDirection(for: packet, localAddresses: localAddresses) ?? packet.direction + } + + private static func endpointTrafficDirection(for packet: PacketSummary, localAddresses: Set) -> PacketDirection? { + guard !localAddresses.isEmpty else { + return nil + } + + let sourceIsLocal = normalizedAddress(packet.endpoints.source.address).map(localAddresses.contains) ?? false + let destinationIsLocal = normalizedAddress(packet.endpoints.destination.address).map(localAddresses.contains) ?? false + + switch (sourceIsLocal, destinationIsLocal) { + case (true, false): + return .outbound + case (false, true): + return .inbound + case (true, true): + return .local + case (false, false): + return nil + } + } + + private static func normalizedAddress(_ address: String?) -> String? { + guard var value = address?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { + return nil + } + + if value.first == "[", let closingIndex = value.firstIndex(of: "]") { + value = String(value[value.index(after: value.startIndex).. 0, + let streamID = packet.streamID, + state.pendingDirectionalPacketIDs.insert(packet.id).inserted else { + return + } + + var streamPacketIDs = state.pendingDirectionalPacketIDsByStreamID[streamID] ?? [] + streamPacketIDs.append(packet.id) + if streamPacketIDs.count > maxPendingDirectionalPacketIDsPerStream { + let removedCount = streamPacketIDs.count - maxPendingDirectionalPacketIDsPerStream + for packetID in streamPacketIDs.prefix(removedCount) { + state.pendingDirectionalPacketIDs.remove(packetID) + } + streamPacketIDs.removeFirst(removedCount) + } + state.pendingDirectionalPacketIDsByStreamID[streamID] = streamPacketIDs + } + + private static func removePendingDirectionalPacket(_ packet: PacketSummary, state: inout State) { + guard state.pendingDirectionalPacketIDs.remove(packet.id) != nil, + let streamID = packet.streamID, + var streamPacketIDs = state.pendingDirectionalPacketIDsByStreamID[streamID] else { + return + } + + streamPacketIDs.removeAll { $0 == packet.id } + if streamPacketIDs.isEmpty { + state.pendingDirectionalPacketIDsByStreamID.removeValue(forKey: streamID) + } else { + state.pendingDirectionalPacketIDsByStreamID[streamID] = streamPacketIDs + } + } + + private func resetTraffic(in state: inout State) { + state.pendingTraffic = .zero + state.pendingDirectionalPacketIDs.removeAll(keepingCapacity: false) + state.pendingDirectionalPacketIDsByStreamID.removeAll(keepingCapacity: false) + state.latestSnapshot = TCPViewerStatusMetricsSnapshot( + memoryBytes: state.latestSnapshot.memoryBytes, + uploadBytesPerSecond: 0, + downloadBytesPerSecond: 0 + ) + state.lastSampleDate = dateProvider() + } + + private func deliver(_ snapshot: TCPViewerStatusMetricsSnapshot) { + callbackQueue.async { [weak self] in + self?.snapshotHandler?(snapshot) + } + } +} diff --git a/TCPViewer/Features/NetworkInspector/ViewModels/NetworkInspectorViewModel.swift b/TCPViewer/Features/NetworkInspector/ViewModels/NetworkInspectorViewModel.swift index 373dac0..2dd8b18 100644 --- a/TCPViewer/Features/NetworkInspector/ViewModels/NetworkInspectorViewModel.swift +++ b/TCPViewer/Features/NetworkInspector/ViewModels/NetworkInspectorViewModel.swift @@ -907,6 +907,11 @@ struct NetworkInspectorMemoryDebugSnapshot: Equatable { protocol NetworkInspectorViewModelDelegate: AnyObject { func networkInspectorViewModelDidChange(_ viewModel: NetworkInspectorViewModel) + func networkInspectorViewModelDidUpdateStatusMetrics(_ viewModel: NetworkInspectorViewModel) +} + +extension NetworkInspectorViewModelDelegate { + func networkInspectorViewModelDidUpdateStatusMetrics(_ viewModel: NetworkInspectorViewModel) {} } final class NetworkInspectorViewModel { @@ -917,6 +922,7 @@ final class NetworkInspectorViewModel { delegate?.networkInspectorViewModelDidChange(self) } } + private(set) var statusMetricsSnapshot: TCPViewerStatusMetricsSnapshot = .empty private let controller: TCPViewerWorkspaceController private let preferences: NetworkInspectorPreferences @@ -928,6 +934,7 @@ final class NetworkInspectorViewModel { private let structuredFilterService: PacketStructuredFilterService private let structuredFilterStore: PacketStructuredFilterStore private let packetExportService: PacketExportService + private let statusMetricsService: TCPViewerStatusMetricsService private let packetTableFilterQueue = DispatchQueue(label: "com.proxyman.TCPViewer.packet-table-filter") private let packetTableAsyncRebuildThreshold: Int private let packetTableFilterBuildHook: (@Sendable () -> Void)? @@ -972,6 +979,7 @@ final class NetworkInspectorViewModel { customFilterService: PacketCustomFilterService = PacketCustomFilterService(), structuredFilterService: PacketStructuredFilterService = PacketStructuredFilterService(), packetExportService: PacketExportService? = nil, + statusMetricsService: TCPViewerStatusMetricsService = TCPViewerStatusMetricsService(), packetTableAsyncRebuildThreshold: Int = 5_000, packetTableFilterBuildHook: (@Sendable () -> Void)? = nil ) { @@ -988,6 +996,7 @@ final class NetworkInspectorViewModel { self.structuredFilterService = structuredFilterService self.structuredFilterStore = PacketStructuredFilterStore(defaults: userDefaults) self.packetExportService = packetExportService ?? PacketExportService(defaults: userDefaults) + self.statusMetricsService = statusMetricsService self.packetTableAsyncRebuildThreshold = max(1, packetTableAsyncRebuildThreshold) self.packetTableFilterBuildHook = packetTableFilterBuildHook self.inspectorPlacement = preferences.inspectorPlacement @@ -1043,6 +1052,12 @@ final class NetworkInspectorViewModel { ) controller.delegate = self + self.statusMetricsSnapshot = statusMetricsService.snapshot + self.statusMetricsService.snapshotHandler = { [weak self] metrics in + self?.applyStatusMetricsSnapshot(metrics) + } + self.statusMetricsService.start() + syncStatusMetricsMonitoring(from: controller.snapshot) } func performInitialLoadIfNeeded(completion: (() -> Void)? = nil) { @@ -2173,9 +2188,55 @@ final class NetworkInspectorViewModel { rebuildGeneration += 1 } + private func applyStatusMetricsSnapshot(_ metrics: TCPViewerStatusMetricsSnapshot) { + guard statusMetricsSnapshot != metrics else { + return + } + + statusMetricsSnapshot = metrics + delegate?.networkInspectorViewModelDidUpdateStatusMetrics(self) + } + + private func syncStatusMetricsMonitoring(from base: TCPViewerWindowSnapshot) { + let target = monitoredStatusMetricsTarget(for: base) + let metrics = statusMetricsService.updateMonitoring( + interfaceID: target?.interfaceID, + localAddresses: target?.localAddresses ?? [], + baselineIngestState: base.packetIngestState + ) + applyStatusMetricsSnapshot(metrics) + } + + private func monitoredStatusMetricsTarget(for base: TCPViewerWindowSnapshot) -> (interfaceID: String, localAddresses: Set)? { + guard base.sessionState.phase == .running, + base.packetIngestState.source == .live, + let interface = base.sessionState.selectedInterface else { + return nil + } + + let localAddresses = interface.addresses.reduce(into: Set()) { result, address in + switch address.family { + case .ipv4, .ipv6: + result.insert(address.value) + case .linkLayer, .unknown: + break + @unknown default: + break + } + } + return (interface.id, localAddresses) + } + + private func recordStatusMetrics(from base: TCPViewerWindowSnapshot) { + syncStatusMetricsMonitoring(from: base) + statusMetricsService.recordPacketIngestState(base.packetIngestState) + statusMetricsSnapshot = statusMetricsService.snapshot + } + deinit { pendingRebuildWorkItem?.cancel() activePacketTableFilterJob?.cancellationToken.cancel() + statusMetricsService.stop() } #if DEBUG @@ -2253,6 +2314,7 @@ final class NetworkInspectorViewModel { extension NetworkInspectorViewModel: TCPViewerWorkspaceControllerDelegate { func tcpViewerWorkspaceControllerDidChange(_ controller: TCPViewerWorkspaceController) { + recordStatusMetrics(from: controller.snapshot) scheduleCoalescedRebuild() } } diff --git a/TCPViewer/Features/NetworkInspector/Views/StatusStripViewController.swift b/TCPViewer/Features/NetworkInspector/Views/StatusStripViewController.swift index 86beb6d..7745e22 100644 --- a/TCPViewer/Features/NetworkInspector/Views/StatusStripViewController.swift +++ b/TCPViewer/Features/NetworkInspector/Views/StatusStripViewController.swift @@ -19,6 +19,7 @@ final class StatusStripViewModel { private(set) var canCancelLoad = false private(set) var canClear = false private(set) var isStructuredFilterVisible = false + private(set) var metricsText = TCPViewerStatusMetricsFormatter.displayText(for: .empty) // Build the compact bottom strip controls from the current packet/capture snapshot. func render(snapshot: NetworkInspectorSnapshot) { @@ -28,6 +29,11 @@ final class StatusStripViewModel { canClear = snapshot.visiblePacketCount > 0 && !canCancelLoad isStructuredFilterVisible = snapshot.isStructuredFilterVisible } + + // Format the lightweight process and captured-traffic metrics for the strip. + func render(metrics: TCPViewerStatusMetricsSnapshot) { + metricsText = TCPViewerStatusMetricsFormatter.displayText(for: metrics) + } } final class StatusStripViewController: NSViewController { @@ -42,6 +48,11 @@ final class StatusStripViewController: NSViewController { font: .monospacedDigitSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular), color: .secondaryLabelColor ) + private let metricsLabel = TCPViewerUI.label( + "", + font: .monospacedDigitSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular), + color: .secondaryLabelColor + ) override func loadView() { view = NSView() @@ -60,13 +71,20 @@ final class StatusStripViewController: NSViewController { filterButton.action = #selector(toggleStructuredFilter(_:)) } - func render(snapshot: NetworkInspectorSnapshot) { + func render(snapshot: NetworkInspectorSnapshot, metrics: TCPViewerStatusMetricsSnapshot = .empty) { viewModel.render(snapshot: snapshot) + viewModel.render(metrics: metrics) cancelButton.isHidden = !viewModel.canCancelLoad clearButton.isHidden = viewModel.canCancelLoad clearButton.isEnabled = viewModel.canClear filterButton.state = viewModel.isStructuredFilterVisible ? .on : .off totalLabel.stringValue = viewModel.totalText + metricsLabel.stringValue = viewModel.metricsText + } + + func render(metrics: TCPViewerStatusMetricsSnapshot) { + viewModel.render(metrics: metrics) + metricsLabel.stringValue = viewModel.metricsText } private func setupLayout() { @@ -88,6 +106,11 @@ final class StatusStripViewController: NSViewController { totalLabel.alignment = .center totalLabel.translatesAutoresizingMaskIntoConstraints = false + metricsLabel.alignment = .right + metricsLabel.toolTip = "App memory and captured upload/download speed" + metricsLabel.translatesAutoresizingMaskIntoConstraints = false + metricsLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + let controlStack = NSStackView(views: [ cancelButton, clearButton, @@ -102,6 +125,10 @@ final class StatusStripViewController: NSViewController { view.addSubview(separator) view.addSubview(controlStack) view.addSubview(totalLabel) + view.addSubview(metricsLabel) + + let totalCenterConstraint = totalLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor) + totalCenterConstraint.priority = .defaultHigh NSLayoutConstraint.activate([ view.heightAnchor.constraint(equalToConstant: 33), @@ -113,11 +140,15 @@ final class StatusStripViewController: NSViewController { controlStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 14), controlStack.centerYAnchor.constraint(equalTo: view.centerYAnchor), - totalLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), totalLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), totalLabel.leadingAnchor.constraint(greaterThanOrEqualTo: controlStack.trailingAnchor, constant: 12), - totalLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -14), + totalLabel.trailingAnchor.constraint(lessThanOrEqualTo: metricsLabel.leadingAnchor, constant: -12), + + metricsLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -14), + metricsLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + metricsLabel.leadingAnchor.constraint(greaterThanOrEqualTo: controlStack.trailingAnchor, constant: 12), ]) + totalCenterConstraint.isActive = true } @objc private func cancelLoad(_ sender: Any?) { diff --git a/TCPViewer/Features/NetworkInspector/Views/TCPViewerRootViewController.swift b/TCPViewer/Features/NetworkInspector/Views/TCPViewerRootViewController.swift index a8cb6e1..acabb17 100644 --- a/TCPViewer/Features/NetworkInspector/Views/TCPViewerRootViewController.swift +++ b/TCPViewer/Features/NetworkInspector/Views/TCPViewerRootViewController.swift @@ -367,7 +367,7 @@ final class TCPViewerRootViewController: NSViewController { sidebarViewController.render(snapshot: snapshot) workspaceViewController.render(snapshot: snapshot) inspectorViewController.render(snapshot: snapshot) - statusStripViewController.render(snapshot: snapshot) + statusStripViewController.render(snapshot: snapshot, metrics: viewModel.statusMetricsSnapshot) applyInspectorLayout(snapshot) delegate?.tcpviewerRootViewControllerDidChangeToolbarState(self) @@ -1022,6 +1022,10 @@ extension TCPViewerRootViewController: NetworkInspectorViewModelDelegate { func networkInspectorViewModelDidChange(_ viewModel: NetworkInspectorViewModel) { render() } + + func networkInspectorViewModelDidUpdateStatusMetrics(_ viewModel: NetworkInspectorViewModel) { + statusStripViewController.render(metrics: viewModel.statusMetricsSnapshot) + } } extension TCPViewerRootViewController: SidebarViewControllerDelegate { diff --git a/TCPViewerTests/Features/NetworkInspector/NetworkInspectorViewModelTests.swift b/TCPViewerTests/Features/NetworkInspector/NetworkInspectorViewModelTests.swift index 073eb18..2d7150f 100644 --- a/TCPViewerTests/Features/NetworkInspector/NetworkInspectorViewModelTests.swift +++ b/TCPViewerTests/Features/NetworkInspector/NetworkInspectorViewModelTests.swift @@ -158,6 +158,60 @@ struct NetworkInspectorViewModelTests { #expect(viewModel.snapshot.packetRows.map(\.id).contains(stalePacket.id) == false) } + @Test func statusMetricsMonitoringRunsOnlyWhileLiveCaptureIsRunning() async { + let liveSession = InspectorFakeLiveSession() + let metricsService = TCPViewerStatusMetricsService( + timerInterval: 60, + memorySampler: { 323 * 1_024 * 1_024 }, + callbackQueue: .main + ) + let viewModel = NetworkInspectorViewModel( + services: TCPViewerServiceRegistry(core: InspectorFakeCore( + interfaces: [makeInterface(id: "en0", displayName: "Wi-Fi")], + liveSession: liveSession + )), + userDefaults: isolatedDefaults(), + statusMetricsService: metricsService + ) + + #expect(metricsService.isSampling) + #expect(!metricsService.isMonitoring) + await viewModel.performInitialLoadIfNeeded() + #expect(metricsService.isSampling) + #expect(!metricsService.isMonitoring) + + await viewModel.toggleLiveCapture() + #expect(metricsService.isSampling) + #expect(!metricsService.isMonitoring) + + liveSession.send(.liveStateChanged(phase: .running, message: "Capture running.")) + await waitUntil { + viewModel.snapshot.base.sessionState.phase == .running && + metricsService.isMonitoring + } + + liveSession.send(.liveStateChanged(phase: .paused, message: "Capture paused.")) + await waitUntil { + viewModel.snapshot.base.sessionState.phase == .paused && + metricsService.isSampling && + !metricsService.isMonitoring + } + + liveSession.send(.liveStateChanged(phase: .running, message: "Capture resumed.")) + await waitUntil { + viewModel.snapshot.base.sessionState.phase == .running && + metricsService.isMonitoring + } + + viewModel.stopLiveCapture() + liveSession.send(.liveStateChanged(phase: .stopped, message: "Live capture stopped.")) + await waitUntil { + viewModel.snapshot.base.sessionState.phase == .stopped && + metricsService.isSampling && + !metricsService.isMonitoring + } + } + @Test func offlineOpenSaveAndSaveAsFlowThroughCoreDocument() async { let openURL = URL(fileURLWithPath: "/tmp/inspector-fixture.pcapng") let saveURL = URL(fileURLWithPath: "/tmp/inspector-export.pcap") @@ -579,6 +633,19 @@ struct NetworkInspectorViewModelTests { #expect(!labels.contains("Stopped")) } + @Test func statusStripRendersProcessAndCapturedTrafficMetrics() { + let viewModel = StatusStripViewModel() + let metrics = TCPViewerStatusMetricsSnapshot( + memoryBytes: 323 * 1_024 * 1_024 + 1, + uploadBytesPerSecond: 1_025, + downloadBytesPerSecond: 0 + ) + + viewModel.render(metrics: metrics) + + #expect(viewModel.metricsText == "• 324 MB ↑ 2 KB/s ↓ 0 KB/s") + } + @Test func packetRowsAreCachedAcrossNonPacketUpdates() async { let packet = makePacket(packetNumber: 1, source: .live, transportHint: .tcp) let liveSession = InspectorFakeLiveSession() diff --git a/TCPViewerTests/Features/NetworkInspector/TCPViewerStatusMetricsServiceTests.swift b/TCPViewerTests/Features/NetworkInspector/TCPViewerStatusMetricsServiceTests.swift new file mode 100644 index 0000000..928ab5d --- /dev/null +++ b/TCPViewerTests/Features/NetworkInspector/TCPViewerStatusMetricsServiceTests.swift @@ -0,0 +1,375 @@ +// +// TCPViewerStatusMetricsServiceTests.swift +// TCPViewer +// +// Created by Proxyman LLC on 11/6/26. +// + +import Foundation +import PcapPlusPlusCore +import Testing +@testable import TCPViewer + +struct TCPViewerStatusMetricsServiceTests { + @Test func capturedTrafficUsesOutboundForUploadAndInboundForDownload() { + let clock = ManualClock() + let service = makeService(clock: clock) + service.sampleNow(notifiesHandler: false) + let ingestState = liveIngestState([ + makePacket(packetNumber: 1, direction: .outbound, originalLength: 200), + makePacket(packetNumber: 2, direction: .inbound, originalLength: 80), + ]) + + service.recordPacketIngestState(ingestState) + clock.advance(by: 2) + let snapshot = service.sampleNow(notifiesHandler: false) + + #expect(snapshot.uploadBytesPerSecond == 100) + #expect(snapshot.downloadBytesPerSecond == 40) + } + + @Test func capturedTrafficDoesNotDoubleCountUnchangedPacketRevision() { + let clock = ManualClock() + let service = makeService(clock: clock) + service.sampleNow(notifiesHandler: false) + let ingestState = liveIngestState([ + makePacket(packetNumber: 1, direction: .outbound, originalLength: 200), + ]) + + service.recordPacketIngestState(ingestState) + service.recordPacketIngestState(ingestState) + clock.advance(by: 2) + let snapshot = service.sampleNow(notifiesHandler: false) + + #expect(snapshot.uploadBytesPerSecond == 100) + #expect(snapshot.downloadBytesPerSecond == 0) + } + + @Test func metadataDirectionBackfillCountsPreviouslyUncountedPacket() { + let clock = ManualClock() + let service = makeService(clock: clock) + service.sampleNow(notifiesHandler: false) + var ingestState = liveIngestState([ + makePacket(packetNumber: 1, direction: nil, originalLength: 200), + ]) + + service.recordPacketIngestState(ingestState) + ingestState.applyMetadataUpdates([ + PacketMetadataUpdate( + packetIDs: [1], + sniDomainName: nil, + client: nil, + direction: .outbound + ), + ]) + service.recordPacketIngestState(ingestState) + service.recordPacketIngestState(ingestState) + clock.advance(by: 2) + let snapshot = service.sampleNow(notifiesHandler: false) + + #expect(snapshot.uploadBytesPerSecond == 100) + #expect(snapshot.downloadBytesPerSecond == 0) + } + + @Test func localEndpointAddressesClassifyTrafficWithoutDirectionMetadata() { + let clock = ManualClock() + let service = makeService(clock: clock, localAddresses: ["10.0.0.2", "fe80::1%en0"]) + service.sampleNow(notifiesHandler: false) + let ingestState = liveIngestState([ + makePacket( + packetNumber: 1, + direction: nil, + originalLength: 2_048, + sourceAddress: "93.184.216.34", + destinationAddress: "10.0.0.2" + ), + makePacket( + packetNumber: 2, + direction: nil, + originalLength: 1_024, + sourceAddress: "[fe80::1]", + destinationAddress: "2606:2800:220:1:248:1893:25c8:1946" + ), + ]) + + service.recordPacketIngestState(ingestState) + clock.advance(by: 2) + let snapshot = service.sampleNow(notifiesHandler: false) + + #expect(snapshot.uploadBytesPerSecond == 512) + #expect(snapshot.downloadBytesPerSecond == 1_024) + } + + @Test func localEndpointClassificationDoesNotDoubleCountDirectionBackfill() { + let clock = ManualClock() + let service = makeService(clock: clock, localAddresses: ["10.0.0.2"]) + service.sampleNow(notifiesHandler: false) + var ingestState = liveIngestState([ + makePacket( + packetNumber: 1, + direction: nil, + originalLength: 200, + sourceAddress: "10.0.0.2", + destinationAddress: "93.184.216.34" + ), + ]) + + service.recordPacketIngestState(ingestState) + ingestState.applyMetadataUpdates([ + PacketMetadataUpdate( + packetIDs: [1], + sniDomainName: nil, + client: nil, + direction: .outbound + ), + ]) + service.recordPacketIngestState(ingestState) + clock.advance(by: 2) + let snapshot = service.sampleNow(notifiesHandler: false) + + #expect(snapshot.uploadBytesPerSecond == 100) + #expect(snapshot.downloadBytesPerSecond == 0) + } + + @Test func appendWithMetadataUpdatesCountsAppendedPacketsAndDirectionBackfills() { + let clock = ManualClock() + let service = makeService(clock: clock) + service.sampleNow(notifiesHandler: false) + var ingestState = liveIngestState([ + makePacket(packetNumber: 1, direction: nil, originalLength: 200), + ]) + + service.recordPacketIngestState(ingestState) + ingestState.appendAndApplyMetadataUpdates( + [makePacket(packetNumber: 2, direction: .inbound, originalLength: 80)], + metadataUpdates: [ + PacketMetadataUpdate( + packetIDs: [1], + sniDomainName: nil, + client: nil, + direction: .outbound + ), + ], + source: .live + ) + service.recordPacketIngestState(ingestState) + clock.advance(by: 2) + let snapshot = service.sampleNow(notifiesHandler: false) + + #expect(snapshot.uploadBytesPerSecond == 100) + #expect(snapshot.downloadBytesPerSecond == 40) + } + + @Test func metadataUpdateDoesNotDoubleCountPacketsAlreadyCountedOnAppend() { + let clock = ManualClock() + let service = makeService(clock: clock) + service.sampleNow(notifiesHandler: false) + var ingestState = liveIngestState([ + makePacket(packetNumber: 1, direction: .outbound, originalLength: 200), + ]) + + service.recordPacketIngestState(ingestState) + ingestState.applyMetadataUpdates([ + PacketMetadataUpdate( + packetIDs: [1], + sniDomainName: "example.com", + client: nil, + direction: .outbound + ), + ]) + service.recordPacketIngestState(ingestState) + clock.advance(by: 2) + let snapshot = service.sampleNow(notifiesHandler: false) + + #expect(snapshot.uploadBytesPerSecond == 100) + #expect(snapshot.downloadBytesPerSecond == 0) + } + + @Test func resetClearsPendingNetworkSpeed() { + let clock = ManualClock() + let service = makeService(clock: clock) + service.sampleNow(notifiesHandler: false) + var ingestState = liveIngestState([ + makePacket(packetNumber: 1, direction: .outbound, originalLength: 200), + makePacket(packetNumber: 2, direction: .inbound, originalLength: 200), + ]) + + service.recordPacketIngestState(ingestState) + ingestState.reset(source: .live, message: "Cleared.") + service.recordPacketIngestState(ingestState) + clock.advance(by: 2) + let snapshot = service.sampleNow(notifiesHandler: false) + + #expect(snapshot.uploadBytesPerSecond == 0) + #expect(snapshot.downloadBytesPerSecond == 0) + } + + @Test func localUnknownAndMissingDirectionsDoNotAffectSpeed() { + let clock = ManualClock() + let service = makeService(clock: clock) + service.sampleNow(notifiesHandler: false) + let ingestState = liveIngestState([ + makePacket(packetNumber: 1, direction: .local, originalLength: 200), + makePacket(packetNumber: 2, direction: .unknown, originalLength: 200), + makePacket(packetNumber: 3, direction: nil, originalLength: 200), + ]) + + service.recordPacketIngestState(ingestState) + clock.advance(by: 2) + let snapshot = service.sampleNow(notifiesHandler: false) + + #expect(snapshot.uploadBytesPerSecond == 0) + #expect(snapshot.downloadBytesPerSecond == 0) + } + + @Test func disabledMonitoringDoesNotAccumulateCapturedTraffic() { + let clock = ManualClock() + let service = makeService(clock: clock, monitoredInterfaceID: nil) + service.sampleNow(notifiesHandler: false) + let ingestState = liveIngestState([ + makePacket(packetNumber: 1, direction: .outbound, originalLength: 200), + ]) + + service.recordPacketIngestState(ingestState) + clock.advance(by: 2) + let snapshot = service.sampleNow(notifiesHandler: false) + + #expect(!service.isMonitoring) + #expect(snapshot.uploadBytesPerSecond == 0) + #expect(snapshot.downloadBytesPerSecond == 0) + } + + @Test func monitoringOnlyCountsTheSelectedInterface() { + let clock = ManualClock() + let service = makeService(clock: clock, monitoredInterfaceID: "en0") + service.sampleNow(notifiesHandler: false) + let ingestState = liveIngestState([ + makePacket(packetNumber: 1, direction: .outbound, originalLength: 200, interfaceID: "en1"), + makePacket(packetNumber: 2, direction: .inbound, originalLength: 80, interfaceID: "en0"), + ]) + + service.recordPacketIngestState(ingestState) + clock.advance(by: 2) + let snapshot = service.sampleNow(notifiesHandler: false) + + #expect(snapshot.uploadBytesPerSecond == 0) + #expect(snapshot.downloadBytesPerSecond == 40) + } + + @Test func disablingNetworkMonitoringKeepsTimerAndClearsPendingNetworkSpeed() { + let clock = ManualClock() + let service = makeService(clock: clock, startsTimer: true) + service.sampleNow(notifiesHandler: false) + let ingestState = liveIngestState([ + makePacket(packetNumber: 1, direction: .outbound, originalLength: 200), + ]) + + service.recordPacketIngestState(ingestState) + service.updateMonitoring(interfaceID: nil, baselineIngestState: ingestState) + clock.advance(by: 2) + let snapshot = service.sampleNow(notifiesHandler: false) + + #expect(!service.isMonitoring) + #expect(service.isSampling) + #expect(snapshot.memoryBytes == 323 * 1_024 * 1_024) + #expect(snapshot.uploadBytesPerSecond == 0) + #expect(snapshot.downloadBytesPerSecond == 0) + } + + @Test func bytesPerSecondRoundsUpAcrossSampleInterval() { + let clock = ManualClock() + let service = makeService(clock: clock) + service.sampleNow(notifiesHandler: false) + let ingestState = liveIngestState([ + makePacket(packetNumber: 1, direction: .outbound, originalLength: 201), + makePacket(packetNumber: 2, direction: .inbound, originalLength: 1), + ]) + + service.recordPacketIngestState(ingestState) + clock.advance(by: 2) + let snapshot = service.sampleNow(notifiesHandler: false) + + #expect(snapshot.uploadBytesPerSecond == 101) + #expect(snapshot.downloadBytesPerSecond == 1) + } + + @Test func formatterUsesFriendlyRoundedUnits() { + #expect(TCPViewerStatusMetricsFormatter.speedText(bytesPerSecond: 0) == "0 KB/s") + #expect(TCPViewerStatusMetricsFormatter.speedText(bytesPerSecond: 1) == "1 KB/s") + #expect(TCPViewerStatusMetricsFormatter.speedText(bytesPerSecond: 1_025) == "2 KB/s") + #expect(TCPViewerStatusMetricsFormatter.speedText(bytesPerSecond: 1_048_577) == "2 MB/s") + #expect(TCPViewerStatusMetricsFormatter.memoryText(bytes: 323 * 1_024 * 1_024 + 1) == "324 MB") + #expect(TCPViewerStatusMetricsFormatter.memoryText(bytes: 1_024 * 1_024 * 1_024 + 1) == "2 GB") + } + + private func makeService( + clock: ManualClock, + memoryBytes: UInt64 = 323 * 1_024 * 1_024, + monitoredInterfaceID: String? = "en0", + localAddresses: Set = [], + startsTimer: Bool = false + ) -> TCPViewerStatusMetricsService { + let service = TCPViewerStatusMetricsService( + memorySampler: { memoryBytes }, + dateProvider: clock.now, + callbackQueue: DispatchQueue(label: "com.proxyman.tcpviewer.StatusMetricsTests.callback") + ) + if let monitoredInterfaceID { + service.updateMonitoring( + interfaceID: monitoredInterfaceID, + localAddresses: localAddresses, + baselineIngestState: .empty, + startsTimer: startsTimer + ) + } + return service + } + + private func liveIngestState(_ packets: [PacketSummary]) -> PacketIngestState { + var state = PacketIngestState.empty + state.reset(source: .live, message: "Starting.") + state.append(packets, source: .live, message: "Captured.") + return state + } + + private func makePacket( + packetNumber: UInt64, + direction: PacketDirection?, + originalLength: Int, + interfaceID: String = "en0", + sourceAddress: String = "10.0.0.1", + destinationAddress: String = "10.0.0.2" + ) -> PacketSummary { + PacketSummary( + packetNumber: packetNumber, + timestamp: Date(timeIntervalSince1970: TimeInterval(packetNumber)), + source: .live, + interfaceID: interfaceID, + transportHint: .tcp, + endpoints: PacketEndpoints( + source: PacketEndpoint(address: sourceAddress, port: 1234), + destination: PacketEndpoint(address: destinationAddress, port: 443) + ), + originalLength: originalLength, + capturedLength: originalLength, + streamID: UInt32(packetNumber), + direction: direction, + infoSummary: "Packet \(packetNumber)", + layers: [PacketLayer(name: "Ethernet"), PacketLayer(name: "TCP")], + decodeStatus: PacketDecodeStatus(kind: .complete), + captureMetadata: PacketCaptureMetadata(linkType: .ethernet, isTruncated: false, interfaceName: interfaceID) + ) + } +} + +private final class ManualClock { + private var date = Date(timeIntervalSince1970: 0) + + func now() -> Date { + date + } + + func advance(by seconds: TimeInterval) { + date = date.addingTimeInterval(seconds) + } +}