From 8bbfdb39a490e7ba653fb16e118eb7632adbdb42 Mon Sep 17 00:00:00 2001 From: Christophe Henner Date: Sat, 10 Oct 2026 20:36:13 +0200 Subject: [PATCH] perf(monitor): keep refresh work compact and observable Problem: Routine Monitor refreshes decode large full snapshots, while failed refresh telemetry loses phase-attempt metadata and can distort phase means. Decision: Use compact process-row snapshots and deltas for the default flat Monitor path, with full-snapshot fallback when needed. Record nullable phase timings, separate attempts from measured samples, and carry partial timings through failed or cancelled refreshes. Rationale: Avoid unnecessary deep FFI decoding without changing rich-view behavior, preserve the legacy bridge_fetch_millis scope, and never treat an unmeasured duration as zero. Validation: - `swift test --package-path macos --filter 'MonitorLiveRowsTests|SnapshotRefreshTelemetryTests|MonitorViewModelTests'` passed (12/12). - `swift test --package-path macos` passed (212/212). - SwiftLint strict passed on 12 modified Swift files (0 violations). - `cargo fmt --all --manifest-path rust/Cargo.toml --check` passed. - `git diff --check` passed. - Independent review returned PASS; it noted that failure telemetry aggregation is tested with synthetic timings, not end-to-end through AppState. - The affected Aethyme `cargo-test` gate was not run because `.aethyme/gates.toml` has `reviewed=false`. Alternatives considered: Continue fetching full snapshots on every Monitor refresh; rejected because it preserves unnecessary deep decoding. Risks: Monitor payload errors remain swallowed by `try?` and may be classified as no-change. Release build and local app rebuild remain unverified. Memory: No new persistent state or external data collection. --- docs/observability.md | 29 ++ macos/Sources/AetowerApp/AetowerApp.swift | 1 - .../Sources/AetowerBindings/aetower_ffi.swift | 10 +- macos/Sources/AetowerUI/AppState.swift | 177 +++++++++-- macos/Sources/AetowerUI/MainListView.swift | 197 +++++++++++- macos/Sources/AetowerUI/MonitorLiveRows.swift | 276 +++++++++++++++++ .../AetowerUI/RuntimeDiagnosticsModels.swift | 18 +- .../AetowerUI/SnapshotRefreshTelemetry.swift | 291 ++++++++++++++++++ .../AetowerUI/SnapshotRefreshWorker.swift | 130 ++++++-- macos/Sources/AetowerUI/SnapshotSlices.swift | 32 +- .../AetowerUITests/MonitorLiveRowsTests.swift | 164 ++++++++++ .../MonitorViewModelTests.swift | 66 ++++ .../SnapshotRefreshTelemetryTests.swift | 122 ++++++++ rust/crates/aetower-ffi/src/lib.rs | 2 + 14 files changed, 1454 insertions(+), 61 deletions(-) create mode 100644 macos/Sources/AetowerUI/MonitorLiveRows.swift create mode 100644 macos/Sources/AetowerUI/SnapshotRefreshTelemetry.swift create mode 100644 macos/Tests/AetowerUITests/MonitorLiveRowsTests.swift create mode 100644 macos/Tests/AetowerUITests/SnapshotRefreshTelemetryTests.swift diff --git a/docs/observability.md b/docs/observability.md index b5d75bd8..d984dbf5 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -78,6 +78,35 @@ series; size your collector/Prometheus retention accordingly. The metric-name constants are the single source of truth in `rust/crates/aetower-telemetry/src/lib.rs` (`pub mod metric_names`). +## Local UI refresh diagnostics + +Aetower emits a bounded `ui-refresh-slow` diagnostic when a UI refresh takes at +least 250 ms or publication takes at least 120 ms, and a periodic +`ui-refresh-sample` for the intervening refreshes. The sample window records its +actual `window_duration_millis` and `window_refreshes`; counts include updated, +no-change, failed, and cancelled cycles, so a slow-event count can be compared +with the observed denominator. + +Per-refresh timings include `full_snapshot_fetch_millis`, +`evaluator_snapshot_fetch_millis`, `monitor_payload_fetch_millis`, +`runtime_lag_fetch_millis`, `operator_state_fetch_millis`, +`worker_total_millis`, `monitor_decode_millis`, `monitor_row_build_millis`, +`publish_millis`, and `ui_refresh_millis`. Window `*_fetches` and +`window_worker_total_attempts` count attempts; their `*_timing_samples` fields +(and `window_worker_samples`) count only calls with measured durations. Means +use timing-sample counts, not attempt counts; when no samples exist, the mean is +`n/a`. An attempted call that throws therefore increments its attempt counter; its +elapsed time is recorded when measurable, otherwise it contributes no fabricated +zero to the mean. Per-refresh phase durations are `n/a` when unavailable, and +`bridge_call_total_millis` is `n/a` if any attempted Bridge phase lacks a +duration. + +The existing runtime metric `bridge_fetch_millis` deliberately retains its +original scope: it measures only the full `SystemSnapshot` fetch. It does not +include the compact Monitor delta, runtime-lag metrics, operator-state Bridge +calls, or total refresh time. Use the local per-phase event fields to compare +those costs; do not interpret `bridge_fetch_millis` as the whole Bridge path. + ## Collector + Prometheus setup Minimal `otel-collector-config.yaml` that receives OTLP/HTTP from Aetower and diff --git a/macos/Sources/AetowerApp/AetowerApp.swift b/macos/Sources/AetowerApp/AetowerApp.swift index 56e98378..39814f19 100644 --- a/macos/Sources/AetowerApp/AetowerApp.swift +++ b/macos/Sources/AetowerApp/AetowerApp.swift @@ -1137,7 +1137,6 @@ struct AetowerApp: App { WindowGroup { TabView(selection: $nav.workspace) { MainListView(state: state, settings: settings) - .demandsFullSnapshot(from: state, reason: "monitor") .tabItem { Label(WorkspaceTab.monitor.title, systemImage: WorkspaceTab.monitor.systemImage) } diff --git a/macos/Sources/AetowerBindings/aetower_ffi.swift b/macos/Sources/AetowerBindings/aetower_ffi.swift index b0971c89..24566446 100644 --- a/macos/Sources/AetowerBindings/aetower_ffi.swift +++ b/macos/Sources/AetowerBindings/aetower_ffi.swift @@ -7653,6 +7653,7 @@ public struct UiHostSummary { public var gpuPercent: Float public var gpuMemoryBytes: UInt64 public var thermalState: ThermalState + public var thermalForecast: ThermalForecast? public var onBattery: Bool public var lowPowerMode: Bool public var frontmostAppName: String? @@ -7663,7 +7664,7 @@ public struct UiHostSummary { // Default memberwise initializers are never public by default, so we // declare one manually. - public init(machineFriction: Float, cpuPercent: Float, memoryUsedBytes: UInt64, memoryTotalBytes: UInt64, compressedMemoryBytes: UInt64, swapUsedBytes: UInt64, diskReadBps: UInt64, diskWriteBps: UInt64, networkReceiveBps: UInt64, networkSendBps: UInt64, wakeupsPerSecond: Float, gpuPercent: Float, gpuMemoryBytes: UInt64, thermalState: ThermalState, onBattery: Bool, lowPowerMode: Bool, frontmostAppName: String?, frontmostWindowTitle: String?, aiAgentCount: UInt32, entityCount: UInt32, processCount: UInt32) { + public init(machineFriction: Float, cpuPercent: Float, memoryUsedBytes: UInt64, memoryTotalBytes: UInt64, compressedMemoryBytes: UInt64, swapUsedBytes: UInt64, diskReadBps: UInt64, diskWriteBps: UInt64, networkReceiveBps: UInt64, networkSendBps: UInt64, wakeupsPerSecond: Float, gpuPercent: Float, gpuMemoryBytes: UInt64, thermalState: ThermalState, thermalForecast: ThermalForecast?, onBattery: Bool, lowPowerMode: Bool, frontmostAppName: String?, frontmostWindowTitle: String?, aiAgentCount: UInt32, entityCount: UInt32, processCount: UInt32) { self.machineFriction = machineFriction self.cpuPercent = cpuPercent self.memoryUsedBytes = memoryUsedBytes @@ -7678,6 +7679,7 @@ public struct UiHostSummary { self.gpuPercent = gpuPercent self.gpuMemoryBytes = gpuMemoryBytes self.thermalState = thermalState + self.thermalForecast = thermalForecast self.onBattery = onBattery self.lowPowerMode = lowPowerMode self.frontmostAppName = frontmostAppName @@ -7737,6 +7739,9 @@ extension UiHostSummary: Equatable, Hashable { if lhs.thermalState != rhs.thermalState { return false } + if lhs.thermalForecast != rhs.thermalForecast { + return false + } if lhs.onBattery != rhs.onBattery { return false } @@ -7776,6 +7781,7 @@ extension UiHostSummary: Equatable, Hashable { hasher.combine(gpuPercent) hasher.combine(gpuMemoryBytes) hasher.combine(thermalState) + hasher.combine(thermalForecast) hasher.combine(onBattery) hasher.combine(lowPowerMode) hasher.combine(frontmostAppName) @@ -7809,6 +7815,7 @@ public struct FfiConverterTypeUiHostSummary: FfiConverterRustBuffer { gpuPercent: FfiConverterFloat.read(from: &buf), gpuMemoryBytes: FfiConverterUInt64.read(from: &buf), thermalState: FfiConverterTypeThermalState.read(from: &buf), + thermalForecast: FfiConverterOptionTypeThermalForecast.read(from: &buf), onBattery: FfiConverterBool.read(from: &buf), lowPowerMode: FfiConverterBool.read(from: &buf), frontmostAppName: FfiConverterOptionString.read(from: &buf), @@ -7834,6 +7841,7 @@ public struct FfiConverterTypeUiHostSummary: FfiConverterRustBuffer { FfiConverterFloat.write(value.gpuPercent, into: &buf) FfiConverterUInt64.write(value.gpuMemoryBytes, into: &buf) FfiConverterTypeThermalState.write(value.thermalState, into: &buf) + FfiConverterOptionTypeThermalForecast.write(value.thermalForecast, into: &buf) FfiConverterBool.write(value.onBattery, into: &buf) FfiConverterBool.write(value.lowPowerMode, into: &buf) FfiConverterOptionString.write(value.frontmostAppName, into: &buf) diff --git a/macos/Sources/AetowerUI/AppState.swift b/macos/Sources/AetowerUI/AppState.swift index b18d3b06..e5e6b04c 100644 --- a/macos/Sources/AetowerUI/AppState.swift +++ b/macos/Sources/AetowerUI/AppState.swift @@ -894,6 +894,8 @@ public final class AppState { @ObservationIgnored private var lastUiRefreshTelemetryEventMillis: UInt64 = 0 @ObservationIgnored + private var uiRefreshTelemetryWindow = UiRefreshTelemetryWindow() + @ObservationIgnored private let uiRefreshTelemetryEventIntervalMillis: UInt64 = 120_000 public init( @@ -1990,6 +1992,15 @@ public final class AppState { await MainActor.run { guard let self else { return } if wasCancelled { + self.recordUiRefreshTelemetry( + outcome: .cancelled, + timings: result.timings, + payload: nil, + monitorFetchDiagnostics: nil, + publishMillis: 0, + uiRefreshMillis: (CFAbsoluteTimeGetCurrent() - refreshStartedAt) * 1_000, + hotSlicesPublished: false + ) self.completeSnapshotRefresh() } else { self.applySnapshotRefreshResult( @@ -2001,12 +2012,33 @@ public final class AppState { } } catch { let wasCancelled = Task.isCancelled + let refreshFailure = error as? SnapshotRefreshFailure + let failureTimings = refreshFailure?.timings ?? .zero + let failureMessage = refreshFailure?.message ?? error.localizedDescription await MainActor.run { guard let self else { return } if wasCancelled { + self.recordUiRefreshTelemetry( + outcome: .cancelled, + timings: failureTimings, + payload: nil, + monitorFetchDiagnostics: nil, + publishMillis: 0, + uiRefreshMillis: (CFAbsoluteTimeGetCurrent() - refreshStartedAt) * 1_000, + hotSlicesPublished: false + ) self.completeSnapshotRefresh() } else { - self.lastError = error.localizedDescription + self.recordUiRefreshTelemetry( + outcome: .failed, + timings: failureTimings, + payload: nil, + monitorFetchDiagnostics: nil, + publishMillis: 0, + uiRefreshMillis: (CFAbsoluteTimeGetCurrent() - refreshStartedAt) * 1_000, + hotSlicesPublished: false + ) + self.lastError = failureMessage self.completeSnapshotRefresh() } } @@ -2039,7 +2071,20 @@ public final class AppState { ) { refreshLiveStorageVolumeCapacityIfNeeded(force: force) switch result { - case .noChange: + case let .noChange(timings, monitorPayload, updatedRuntimeLagMetrics): + let monitorFetchDiagnostics = monitorPayload.map(applyMonitorUiPayload) + if runtimeLagMetrics != updatedRuntimeLagMetrics { + runtimeLagMetrics = updatedRuntimeLagMetrics + } + recordUiRefreshTelemetry( + outcome: .noChange, + timings: timings, + payload: nil, + monitorFetchDiagnostics: monitorFetchDiagnostics, + publishMillis: 0, + uiRefreshMillis: (CFAbsoluteTimeGetCurrent() - refreshStartedAt) * 1_000, + hotSlicesPublished: false + ) completeSnapshotRefresh() return case let .updated(payload): @@ -2093,10 +2138,12 @@ public final class AppState { evaluateTimelineNotifications(snapshot: evaluatorInput) } - if lagMonitoringActive, let refreshedSnapshot = payload.snapshot { + if lagMonitoringActive, + let refreshedSnapshot = payload.snapshot, + let bridgeFetchMillis = payload.timings.fullSnapshotFetchMillis { publishUiLagMetrics( snapshot: refreshedSnapshot, - bridgeFetchMillis: payload.bridgeFetchMillis, + bridgeFetchMillis: bridgeFetchMillis, uiRefreshMillis: (CFAbsoluteTimeGetCurrent() - refreshStartedAt) * 1000.0, refreshStartedAt: refreshStartedAt ) @@ -2116,6 +2163,8 @@ public final class AppState { let publishMillis = (CFAbsoluteTimeGetCurrent() - publishStartedAt) * 1000.0 let uiRefreshMillis = (CFAbsoluteTimeGetCurrent() - refreshStartedAt) * 1000.0 recordUiRefreshTelemetry( + outcome: .updated, + timings: payload.timings, payload: payload, monitorFetchDiagnostics: monitorFetchDiagnostics, publishMillis: publishMillis, @@ -3184,61 +3233,147 @@ public final class AppState { } private func recordUiRefreshTelemetry( - payload: SnapshotRefreshPayload, + outcome: SnapshotRefreshOutcome, + timings: SnapshotRefreshPhaseTimings, + payload: SnapshotRefreshPayload?, monitorFetchDiagnostics: MonitorUiPayloadFetchDiagnostics?, publishMillis: Double, uiRefreshMillis: Double, hotSlicesPublished: Bool ) { let demandSummary = fullSnapshotDemandSummary() + let monitorPayloadKind = monitorFetchDiagnostics?.compactPayloadKind ?? "none" + let fullSnapshotPublished = payload?.snapshot != nil + let nowMillis = UInt64(Date().timeIntervalSince1970 * 1_000) + uiRefreshTelemetryWindow.record( + outcome: outcome, + timings: timings, + fullSnapshotPublished: fullSnapshotPublished, + monitorPayloadKind: monitorPayloadKind, + publishMillis: publishMillis, + uiRefreshMillis: uiRefreshMillis, + atMillis: nowMillis + ) + let window = uiRefreshTelemetryWindow.snapshot + func formatMilliseconds(_ duration: Double?) -> String { + guard let duration else { return "n/a" } + return String(format: "%.1f", duration) + } + let entityCount = payload?.snapshot?.entities.count + ?? payload?.evaluatorSnapshot?.entities.count + ?? entitiesState.count uiRefreshTelemetry = UiRefreshTelemetrySummary( updatedAt: Date(), uiRefreshMillis: uiRefreshMillis, publishMillis: publishMillis, - bridgeFetchMillis: payload.bridgeFetchMillis, + bridgeCallMillis: timings.bridgeCallMillis, + fullSnapshotFetchMillis: timings.fullSnapshotFetchMillis, + evaluatorSnapshotFetchMillis: timings.evaluatorSnapshotFetchMillis, + monitorPayloadFetchMillis: timings.monitorPayloadFetchMillis, + runtimeLagFetchMillis: timings.runtimeLagFetchMillis, + operatorStateFetchMillis: timings.operatorStateFetchMillis, + workerTotalMillis: timings.workerTotalMillis, + window: window, fullSnapshotDemandCount: fullSnapshotDemandReasons.count, fullSnapshotDemandSummary: demandSummary, - fullSnapshotPublished: payload.snapshot != nil, - evaluatorSnapshotApplied: payload.evaluatorSnapshot != nil && payload.snapshot == nil, + fullSnapshotPublished: fullSnapshotPublished, + evaluatorSnapshotApplied: payload?.evaluatorSnapshot != nil && payload?.snapshot == nil, hotSlicesPublished: hotSlicesPublished, - monitorPayloadKind: monitorFetchDiagnostics?.compactPayloadKind ?? "none", + monitorPayloadKind: monitorPayloadKind, monitorReturnedRowCount: monitorFetchDiagnostics?.returnedRowCount ?? 0, - entityCount: payload.snapshot?.entities.count ?? payload.evaluatorSnapshot?.entities.count ?? entitiesState.count, + entityCount: entityCount, repositoryRuntimeEntityCount: repositoryRuntimeContextState.entities.count ) - let nowMillis = UInt64(Date().timeIntervalSince1970 * 1000) let slowUiPath = uiRefreshMillis >= 250 || publishMillis >= 120 let elapsedSinceTelemetryEvent = nowMillis >= lastUiRefreshTelemetryEventMillis ? nowMillis - lastUiRefreshTelemetryEventMillis : 0 - let periodicDemandSample = !fullSnapshotDemandReasons.isEmpty - && elapsedSinceTelemetryEvent >= uiRefreshTelemetryEventIntervalMillis - guard slowUiPath || periodicDemandSample else { return } + let periodicSampleDue = lastUiRefreshTelemetryEventMillis == 0 + || elapsedSinceTelemetryEvent >= uiRefreshTelemetryEventIntervalMillis + guard slowUiPath || periodicSampleDue else { return } + let outcomeName: String + switch outcome { + case .updated: outcomeName = "updated" + case .noChange: outcomeName = "no-change" + case .failed: outcomeName = "failed" + case .cancelled: outcomeName = "cancelled" + } lastUiRefreshTelemetryEventMillis = nowMillis recordLocalDiagnosticsEvent( level: slowUiPath ? .warn : .info, subsystem: .ui, - eventType: slowUiPath ? "ui-refresh-slow" : "ui-refresh-demand-sample", + eventType: slowUiPath ? "ui-refresh-slow" : "ui-refresh-sample", message: slowUiPath ? "Applied a UI refresh slower than the local budget." - : "Sampled active full-snapshot UI demand.", + : "Sampled every UI refresh since the previous telemetry window.", fields: [ + DiagnosticsField(key: "outcome", value: outcomeName), DiagnosticsField(key: "ui_refresh_millis", value: String(format: "%.1f", uiRefreshMillis)), DiagnosticsField(key: "publish_millis", value: String(format: "%.1f", publishMillis)), - DiagnosticsField(key: "bridge_fetch_millis", value: String(format: "%.1f", payload.bridgeFetchMillis)), + DiagnosticsField(key: "full_snapshot_fetch_attempted", value: String(timings.fullSnapshotFetchAttempted)), + DiagnosticsField(key: "evaluator_snapshot_fetch_attempted", value: String(timings.evaluatorSnapshotFetchAttempted)), + DiagnosticsField(key: "monitor_payload_fetch_attempted", value: String(timings.monitorPayloadFetchAttempted)), + DiagnosticsField(key: "runtime_lag_fetch_attempted", value: String(timings.runtimeLagFetchAttempted)), + DiagnosticsField(key: "operator_state_fetch_attempted", value: String(timings.operatorStateFetchAttempted)), + DiagnosticsField(key: "worker_total_attempted", value: String(timings.workerTotalAttempted)), + DiagnosticsField(key: "full_snapshot_fetch_millis", value: formatMilliseconds(timings.fullSnapshotFetchMillis)), + DiagnosticsField(key: "evaluator_snapshot_fetch_millis", value: formatMilliseconds(timings.evaluatorSnapshotFetchMillis)), + DiagnosticsField(key: "monitor_payload_fetch_millis", value: formatMilliseconds(timings.monitorPayloadFetchMillis)), + DiagnosticsField(key: "runtime_lag_fetch_millis", value: formatMilliseconds(timings.runtimeLagFetchMillis)), + DiagnosticsField(key: "operator_state_fetch_millis", value: formatMilliseconds(timings.operatorStateFetchMillis)), + DiagnosticsField(key: "worker_total_millis", value: formatMilliseconds(timings.workerTotalMillis)), + DiagnosticsField(key: "bridge_call_total_millis", value: formatMilliseconds(timings.bridgeCallMillis)), + DiagnosticsField(key: "monitor_decode_millis", value: String(format: "%.1f", uiPerformanceBudgetDiagnostics.decodeMillis)), + DiagnosticsField(key: "monitor_row_build_millis", value: String(format: "%.1f", uiPerformanceBudgetDiagnostics.rowBuildMillis)), DiagnosticsField(key: "full_snapshot_demands", value: String(fullSnapshotDemandReasons.count)), DiagnosticsField(key: "full_snapshot_demand_reasons", value: demandSummary), - DiagnosticsField(key: "full_snapshot_published", value: payload.snapshot == nil ? "false" : "true"), - DiagnosticsField(key: "evaluator_snapshot_applied", value: payload.evaluatorSnapshot == nil ? "false" : "true"), + DiagnosticsField(key: "full_snapshot_published", value: fullSnapshotPublished ? "true" : "false"), + DiagnosticsField(key: "evaluator_snapshot_applied", value: payload?.evaluatorSnapshot == nil ? "false" : "true"), DiagnosticsField(key: "hot_slices_published", value: hotSlicesPublished ? "true" : "false"), - DiagnosticsField(key: "monitor_payload_kind", value: monitorFetchDiagnostics?.compactPayloadKind ?? "none"), + DiagnosticsField(key: "monitor_payload_kind", value: monitorPayloadKind), DiagnosticsField(key: "monitor_rows", value: String(monitorFetchDiagnostics?.returnedRowCount ?? 0)), - DiagnosticsField(key: "entity_count", value: String(uiRefreshTelemetry.entityCount)), + DiagnosticsField(key: "entity_count", value: String(entityCount)), DiagnosticsField(key: "repo_runtime_entities", value: String(repositoryRuntimeContextState.entities.count)), + DiagnosticsField(key: "window_duration_millis", value: String(window.windowDurationMillis)), + DiagnosticsField(key: "window_refreshes", value: String(window.refreshCount)), + DiagnosticsField(key: "window_updated", value: String(window.updatedCount)), + DiagnosticsField(key: "window_no_change", value: String(window.noChangeCount)), + DiagnosticsField(key: "window_failed", value: String(window.failedCount)), + DiagnosticsField(key: "window_cancelled", value: String(window.cancelledCount)), + DiagnosticsField(key: "window_slow", value: String(window.slowCount)), + DiagnosticsField(key: "window_full_snapshot_fetches", value: String(window.fullSnapshotFetchCount)), + DiagnosticsField(key: "window_full_snapshot_fetch_timing_samples", value: String(window.fullSnapshotFetchTimingSampleCount)), + DiagnosticsField(key: "window_full_snapshots_published", value: String(window.fullSnapshotPublishedCount)), + DiagnosticsField(key: "window_evaluator_snapshot_fetches", value: String(window.evaluatorSnapshotFetchCount)), + DiagnosticsField(key: "window_evaluator_snapshot_fetch_timing_samples", value: String(window.evaluatorSnapshotFetchTimingSampleCount)), + DiagnosticsField(key: "window_monitor_payload_fetches", value: String(window.monitorPayloadFetchCount)), + DiagnosticsField(key: "window_monitor_payload_fetch_timing_samples", value: String(window.monitorPayloadFetchTimingSampleCount)), + DiagnosticsField(key: "window_monitor_deltas", value: String(window.monitorDeltaCount)), + DiagnosticsField(key: "window_monitor_snapshots", value: String(window.monitorSnapshotCount)), + DiagnosticsField(key: "window_runtime_lag_fetches", value: String(window.runtimeLagFetchCount)), + DiagnosticsField(key: "window_runtime_lag_fetch_timing_samples", value: String(window.runtimeLagFetchTimingSampleCount)), + DiagnosticsField(key: "window_operator_state_fetches", value: String(window.operatorStateFetchCount)), + DiagnosticsField(key: "window_operator_state_fetch_timing_samples", value: String(window.operatorStateFetchTimingSampleCount)), + DiagnosticsField(key: "window_worker_total_attempts", value: String(window.workerTotalCount)), + DiagnosticsField(key: "window_worker_samples", value: String(window.workerTotalTimingSampleCount)), + DiagnosticsField(key: "window_mean_full_snapshot_fetch_millis", value: formatMilliseconds(window.meanFullSnapshotFetchMillis)), + DiagnosticsField(key: "window_mean_evaluator_snapshot_fetch_millis", value: formatMilliseconds(window.meanEvaluatorSnapshotFetchMillis)), + DiagnosticsField(key: "window_mean_monitor_payload_fetch_millis", value: formatMilliseconds(window.meanMonitorPayloadFetchMillis)), + DiagnosticsField(key: "window_mean_runtime_lag_fetch_millis", value: formatMilliseconds(window.meanRuntimeLagFetchMillis)), + DiagnosticsField(key: "window_mean_operator_state_fetch_millis", value: formatMilliseconds(window.meanOperatorStateFetchMillis)), + DiagnosticsField(key: "window_mean_worker_total_millis", value: formatMilliseconds(window.meanWorkerTotalMillis)), + DiagnosticsField(key: "window_mean_publish_millis", value: formatMilliseconds(window.meanPublishMillis)), + DiagnosticsField(key: "window_mean_ui_refresh_millis", value: formatMilliseconds(window.meanUiRefreshMillis)), + DiagnosticsField(key: "window_max_ui_refresh_millis", value: String(format: "%.1f", window.maxUiRefreshMillis)), + DiagnosticsField(key: "window_ui_refresh_lt50", value: String(window.under50MillisCount)), + DiagnosticsField(key: "window_ui_refresh_50_250", value: String(window.from50To250MillisCount)), + DiagnosticsField(key: "window_ui_refresh_250_1000", value: String(window.from250To1000MillisCount)), + DiagnosticsField(key: "window_ui_refresh_gte1000", value: String(window.over1000MillisCount)), ] ) + uiRefreshTelemetryWindow.reset() } private func fullSnapshotDemandSummary() -> String { diff --git a/macos/Sources/AetowerUI/MainListView.swift b/macos/Sources/AetowerUI/MainListView.swift index fdbfbf9f..7059e880 100644 --- a/macos/Sources/AetowerUI/MainListView.swift +++ b/macos/Sources/AetowerUI/MainListView.swift @@ -274,7 +274,7 @@ public struct MainListView: View { @State private var originFilter: ProcessOriginFilter = .all @State private var sortKey: SortKey = .friction @State private var focusedIndex: Int = 0 - @State private var listMode: ListMode = .grouped + @State private var listMode: ListMode = .flat @State private var groupedEntitiesCache: [GroupingCacheKey: [EntityGroup]] = [:] @State private var expandedGroupIDs = Set() @State private var groupingTask: Task<[EntityGroup], Never>? @@ -289,6 +289,7 @@ public struct MainListView: View { @StateObject private var processOriginCacheStore = ProcessOriginSnapshotCacheStore() @StateObject private var monitorSectionCacheStore = MonitorEntitySectionCacheStore() @StateObject private var monitorGroupRowCacheStore = MonitorGroupRowCacheStore() + @StateObject private var liveRowsProjectionStore = MonitorLiveRowsStore() @FocusState private var searchFieldFocused: Bool public init(state: AppState, settings: SettingsStore) { @@ -300,6 +301,45 @@ public struct MainListView: View { processOriginCacheStore.cache(sequence: state.snapshotSequence, entities: state.entitiesState) } + private var liveRowsProjection: MonitorLiveRowsSnapshot { + liveRowsProjectionStore.snapshot( + rows: state.monitorViewModel.processRows, + sequence: state.monitorViewModel.sequence, + query: normalizedSearchQuery, + sortKey: sortKey, + advancedFilterEntityIds: state.advancedFilterEntityIds + ) + } + + private var hasCompleteLiveRows: Bool { + state.monitorViewModel.sequence == 0 + || state.monitorViewModel.returnedEntityCount >= state.monitorViewModel.totalEntityCount + } + + private var hasDetailedMonitorFilter: Bool { + originFilter != .all + || state.advancedFilterEntityIds != nil + || !MonitorLiveRowsProjection.supportsLiveSearch(normalizedSearchQuery) + || !MonitorLiveRowsProjection.supportsLiveSort(sortKey) + } + + private var usesLightweightMonitorRows: Bool { + !isGroupedMode && originFilter == .all + && state.advancedFilterEntityIds == nil + && MonitorLiveRowsProjection.supportsLiveSearch(normalizedSearchQuery) + && MonitorLiveRowsProjection.supportsLiveSort(sortKey) + && hasCompleteLiveRows + } + + private var shouldDemandFullSnapshot: Bool { + MonitorSnapshotPolicy.requiresFullSnapshot( + isGroupedMode: isGroupedMode, + hasSelectedEntity: selectedEntityID != nil, + hasDetailedFilter: hasDetailedMonitorFilter, + hasCompleteLiveRows: hasCompleteLiveRows + ) + } + public var body: some View { VStack(alignment: .leading, spacing: AetowerDesign.Spacing.none) { monitorTabToolBand @@ -354,6 +394,7 @@ public struct MainListView: View { groupingTask = nil searchFieldFocused = false } + .demandsFullSnapshot(from: state, when: shouldDemandFullSnapshot, reason: "monitor.full-details") } @ViewBuilder @@ -419,6 +460,26 @@ public struct MainListView: View { ) .layoutPriority(1) } + } else if selectedEntityID != nil { + HSplitView { + rankingPanel + .frame( + minWidth: 420, + idealWidth: 760, + maxWidth: .infinity, + maxHeight: .infinity + ) + .layoutPriority(2) + VStack(spacing: AetowerDesign.Spacing.sm) { + ProgressView() + .controlSize(.small) + Text("Loading full entity details…") + .font(AetowerDesign.Typography.caption) + .foregroundStyle(AetowerDesign.Ink.secondary) + } + .frame(minWidth: 360, idealWidth: 760, maxWidth: .infinity, maxHeight: .infinity) + .layoutPriority(1) + } } else { rankingPanel .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -445,6 +506,7 @@ public struct MainListView: View { .pickerStyle(.segmented) .labelsHidden() .accessibilityLabel("Layout") + .help("Flat uses live compact rows; Grouped loads the full entity snapshot.") .frame(width: 86) .onChange(of: listMode) { _, _ in focusedIndex = 0 @@ -1689,7 +1751,107 @@ public struct MainListView: View { } } + private var lightweightRankedEntitiesSection: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text("") + .frame(width: 16) + MonitorHeaderButton( + column: .name, + isActive: isActiveSortColumn(.name), + indicatorSymbol: sortIndicatorSymbol(for: .name), + action: { applySort(for: .name) } + ) + MonitorHeaderButton( + column: .cpu, + isActive: isActiveSortColumn(.cpu), + indicatorSymbol: sortIndicatorSymbol(for: .cpu), + action: { applySort(for: .cpu) } + ) + MonitorHeaderButton( + column: .memory, + isActive: isActiveSortColumn(.memory), + indicatorSymbol: sortIndicatorSymbol(for: .memory), + action: { applySort(for: .memory) } + ) + MonitorHeaderButton( + column: .wakeups, + isActive: isActiveSortColumn(.wakeups), + indicatorSymbol: sortIndicatorSymbol(for: .wakeups), + action: { applySort(for: .wakeups) } + ) + MonitorHeaderButton( + column: .processCount, + isActive: isActiveSortColumn(.processCount), + indicatorSymbol: sortIndicatorSymbol(for: .processCount), + action: { applySort(for: .processCount) } + ) + MonitorHeaderButton( + column: .friction, + isActive: isActiveSortColumn(.friction), + indicatorSymbol: sortIndicatorSymbol(for: .friction), + action: { applySort(for: .friction) } + ) + Text("") + .frame(width: 16) + } + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.tertiary) + .padding(.horizontal, AetowerDesign.Spacing.sm) + .padding(.vertical, 2) + + if state.monitorViewModel.sequence == 0 { + ProgressView("Loading live process rows…") + .font(AetowerDesign.Typography.caption) + .frame(maxWidth: .infinity, minHeight: 180) + } else if liveRowsProjection.rows.isEmpty { + ContentUnavailableView( + "No processes match this filter", + systemImage: "magnifyingglass", + description: Text("Try a broader query.") + ) + } else { + LazyVStack(spacing: 2) { + listSectionHeader("Live processes") + ForEach(liveRowsProjection.rows, id: \.entityId) { row in + Button { + selectEntity(row.entityId) + } label: { + MonitorLiveEntityRow(row: row, isSelected: selectedEntityID == row.entityId) + } + .buttonStyle(.plain) + .contextMenu { + Button("Open Detail") { + selectEntity(row.entityId) + } + if let pid = row.primaryPid { + Button("Copy Process ID") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(String(pid), forType: .string) + } + } + Button("Copy Name") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(row.displayName, forType: .string) + } + } + } + } + .animation(nil, value: state.monitorViewModel.sequence) + } + } + } + + @ViewBuilder private var rankedEntitiesSection: some View { + if usesLightweightMonitorRows { + lightweightRankedEntitiesSection + } else { + fullSnapshotRankedEntitiesSection + } + } + + private var fullSnapshotRankedEntitiesSection: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { Text("") @@ -2075,9 +2237,16 @@ public struct MainListView: View { } } + private var activeThermalForecast: ThermalForecast? { + guard state.monitorViewModel.sequence > 0 else { + return state.thermalForecastState + } + return state.monitorViewModel.host?.thermalForecast + } + @ViewBuilder private var thermalForecastBanner: some View { - if let forecast = state.thermalForecastState { + if let forecast = activeThermalForecast { let tone = forecast.state == .nominal || forecast.state == .fair ? AetowerDesign.Status.warning : AetowerDesign.Status.error @@ -2617,6 +2786,7 @@ public struct MainListView: View { } private var monitorVisibleRowCount: Int { + if usesLightweightMonitorRows { return liveRowsProjection.rows.count } if isGroupedMode { return allProcessGroupRows.count + visibleExpandedProcessRowCount } @@ -2631,14 +2801,16 @@ public struct MainListView: View { } private var monitorVisibleRowBuildMillis: Double { - monitorSections.rowBuildDurationMillis + if usesLightweightMonitorRows { return liveRowsProjection.buildDurationMillis } + return monitorSections.rowBuildDurationMillis + (isGroupedMode ? allProcessGroupSection.rowBuildDurationMillis : 0) } private var monitorUiPerformanceBudgetToken: String { let durationBucket = Int((monitorVisibleRowBuildMillis * 10).rounded()) + let sequence = usesLightweightMonitorRows ? state.monitorViewModel.sequence : state.snapshotSequence return [ - "\(state.snapshotSequence)", + "\(sequence)", listMode.rawValue, "\(monitorVisibleRowCount)", "\(durationBucket)", @@ -2707,11 +2879,13 @@ public struct MainListView: View { } private var visibleEntityIDs: [String] { - let sections = monitorSections + if usesLightweightMonitorRows { + return liveRowsProjection.rows.map(\.entityId) + } if isGroupedMode { return visibleGroupedEntityIDs } - return sections.flatVisibleEntityIDs + return monitorSections.flatVisibleEntityIDs } private var visibleGroupedEntityIDs: [String] { @@ -2732,13 +2906,15 @@ public struct MainListView: View { } private var visibleProcessCount: Int { - let sections = monitorSections + if usesLightweightMonitorRows { + return liveRowsProjection.rows.reduce(0) { $0 + Int($1.processCount) } + } if isGroupedMode { return allProcessGroupRows.reduce(0) { $0 + $1.group.processCount } } - return sections.flatVisibleProcessCount + return monitorSections.flatVisibleProcessCount } private var groupedEntities: [EntityGroup] { @@ -2930,7 +3106,10 @@ public struct MainListView: View { return } - if state.entitiesState.contains(where: { $0.entityId == requestedEntityID }) { + let entityExistsInCurrentRows = state.monitorViewModel.processRows.contains { + $0.entityId == requestedEntityID + } + if entityExistsInCurrentRows || state.entitiesState.contains(where: { $0.entityId == requestedEntityID }) { originFilter = .all listMode = .flat selectedEntityID = requestedEntityID diff --git a/macos/Sources/AetowerUI/MonitorLiveRows.swift b/macos/Sources/AetowerUI/MonitorLiveRows.swift new file mode 100644 index 00000000..4fe95423 --- /dev/null +++ b/macos/Sources/AetowerUI/MonitorLiveRows.swift @@ -0,0 +1,276 @@ +import AetowerBridge +import Foundation +import SwiftUI + +/// Flat Monitor projection backed by compact UiProcessRow deltas. Detailed +/// entity searches and sorts deliberately fall back to the full snapshot path. +struct MonitorLiveRowsProjection { + static func filterAndSort( + rows: [UiProcessRow], + query: String, + sortKey: SortKey, + advancedFilterEntityIds: Set? + ) -> [UiProcessRow] { + let tokens = tokenizeSearchQuery(query) + + return rows + .filter { row in + if let advancedFilterEntityIds, + !advancedFilterEntityIds.contains(row.entityId) { + return false + } + return tokens.allSatisfy { matches($0, row: row) } + } + .sorted { lhs, rhs in + let leftValue = sortValue(lhs, key: sortKey) + let rightValue = sortValue(rhs, key: sortKey) + if leftValue != rightValue { + return leftValue > rightValue + } + let nameOrder = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) + if nameOrder != .orderedSame { + return sortKey == .alphabeticalDesc + ? nameOrder == .orderedDescending + : nameOrder == .orderedAscending + } + return lhs.entityId < rhs.entityId + } + } + + static func supportsLiveSearch(_ query: String) -> Bool { + tokenizeSearchQuery(query).allSatisfy { token in + if token.hasPrefix("/") { return false } + + if let colon = token.firstIndex(of: ":") { + let field = token[.. Bool { + switch sortKey { + case .energy, .processCount, .oldestFirst, .newestFirst: + // Energy sorts by Friction's impact score; process count uses the + // live component count. Neither is equivalent to the compact row. + false + default: + true + } + } + + private static let supportedNumericFields: Set = [ + "cpu", "mem", "memory", "ram", "energy", "wakeups", "friction", + "procs", "processes", "count", "threads", + ] + + private static let numericOperators = [">=", "<=", ">", "<", "==", "="] + + private static func numericOperatorRange(in token: String) -> Range? { + numericOperators.compactMap { token.range(of: $0) }.min { + $0.lowerBound < $1.lowerBound + } + } + + private static func numericField( + in token: String, + operatorRange: Range + ) -> String? { + let field = String(token[.. Bool { + if let colon = token.firstIndex(of: ":") { + let field = token[..": return value > threshold + case "<": return value < threshold + case ">=": return value >= threshold + case "<=": return value <= threshold + default: return value == threshold + } + } + + // This path is reached only for exact live-supported search tokens. + // Free text is intentionally routed to the full entity search. + return false + } + + private static func numericValue(for field: String, row: UiProcessRow) -> Double? { + switch field { + case "cpu": Double(row.cpuPercent) + case "mem", "memory", "ram": Double(row.memoryResidentBytes) + case "friction": Double(row.frictionScore) + case "wakeups": Double(row.wakeupsPerSecond) + case "procs", "processes", "count": Double(row.processCount) + case "threads": Double(row.threadCount) + case "energy": row.energyNjPerS + default: nil + } + } + + private static func sortValue(_ row: UiProcessRow, key: SortKey) -> Double { + switch key { + case .friction: Double(row.frictionScore) + case .cpu: Double(row.cpuPercent) + case .memory: Double(max(row.memoryResidentBytes, row.memoryPhysicalFootprintBytes)) + case .wakeups: Double(row.wakeupsPerSecond) + case .processCount: Double(row.processCount) + case .disk: Double(row.diskReadBps) + Double(row.diskWriteBps) + case .network: Double(row.networkReceiveBps) + Double(row.networkSendBps) + case .energy: row.energyNjPerS + case .alphabeticalAsc, .alphabeticalDesc, .oldestFirst, .newestFirst: 0 + } + } +} + +enum MonitorSnapshotPolicy { + static func requiresFullSnapshot( + isGroupedMode: Bool, + hasSelectedEntity: Bool, + hasDetailedFilter: Bool, + hasCompleteLiveRows: Bool + ) -> Bool { + isGroupedMode || hasSelectedEntity || hasDetailedFilter || !hasCompleteLiveRows + } +} + +struct MonitorLiveRowsSnapshot { + let rows: [UiProcessRow] + let buildDurationMillis: Double +} + +@MainActor +final class MonitorLiveRowsStore: ObservableObject { + private struct Key: Hashable { + let sequence: UInt64 + let query: String + let sortKey: String + let advancedFilterEntityIds: Set? + } + + private var key: Key? + private var cached = MonitorLiveRowsSnapshot(rows: [], buildDurationMillis: 0) + + func snapshot( + rows: [UiProcessRow], + sequence: UInt64, + query: String, + sortKey: SortKey, + advancedFilterEntityIds: Set? + ) -> MonitorLiveRowsSnapshot { + let nextKey = Key( + sequence: sequence, + query: query, + sortKey: sortKey.rawValue, + advancedFilterEntityIds: advancedFilterEntityIds + ) + guard key != nextKey else { return cached } + + let startedAt = CFAbsoluteTimeGetCurrent() + let projected = MonitorLiveRowsProjection.filterAndSort( + rows: rows, + query: query, + sortKey: sortKey, + advancedFilterEntityIds: advancedFilterEntityIds + ) + key = nextKey + cached = MonitorLiveRowsSnapshot( + rows: projected, + buildDurationMillis: (CFAbsoluteTimeGetCurrent() - startedAt) * 1_000 + ) + return cached + } +} + +struct MonitorLiveEntityRow: View { + let row: UiProcessRow + let isSelected: Bool + + var body: some View { + HStack(spacing: AetowerDesign.Spacing.md) { + Circle() + .fill(row.anomalyDetected ? AetowerDesign.Status.error : AetowerDesign.Tone.friction) + .frame(width: 7, height: 7) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: AetowerDesign.Spacing.xs) { + Text(row.displayName) + .font(AetowerDesign.Typography.controlLabel) + .foregroundStyle(AetowerDesign.Ink.primary) + .lineLimit(1) + if row.isForeground { + AetowerBadge("Foreground", tone: AetowerDesign.Tone.cpu) + } + if row.anomalyDetected { + AetowerBadge("Anomaly", tone: AetowerDesign.Status.error) + } + } + Text(row.executablePath ?? row.bundleId ?? row.entityId) + .font(AetowerDesign.Typography.metadata) + .foregroundStyle(AetowerDesign.Ink.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + + metric(String(format: "%.1f%%", row.cpuPercent), label: "CPU") + metric(formatBytes(max(row.memoryResidentBytes, row.memoryPhysicalFootprintBytes)), label: "Memory") + metric(formatWakeups(row.wakeupsPerSecond), label: "Wakeups") + metric(String(row.processCount), label: "PIDs") + metric(String(format: "%.1f", row.frictionScore), label: "Friction") + } + .padding(.horizontal, AetowerDesign.Spacing.sm) + .padding(.vertical, AetowerDesign.Spacing.xs) + .background( + isSelected ? AetowerDesign.Surface.rowSelected : Color.clear, + in: RoundedRectangle(cornerRadius: AetowerDesign.Radius.sm) + ) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(row.displayName), CPU \(String(format: "%.1f", row.cpuPercent)) percent, memory \(formatBytes(max(row.memoryResidentBytes, row.memoryPhysicalFootprintBytes)) )") + } + + private func metric(_ value: String, label: String) -> some View { + VStack(alignment: .trailing, spacing: 2) { + Text(value) + .font(AetowerDesign.Typography.compactData(size: 11, weight: .medium)) + .foregroundStyle(AetowerDesign.Ink.primary) + .monospacedDigit() + Text(label) + .font(AetowerDesign.Typography.metadata) + .foregroundStyle(AetowerDesign.Ink.tertiary) + } + .frame(minWidth: 56, alignment: .trailing) + } +} diff --git a/macos/Sources/AetowerUI/RuntimeDiagnosticsModels.swift b/macos/Sources/AetowerUI/RuntimeDiagnosticsModels.swift index 58ff0788..c8445f04 100644 --- a/macos/Sources/AetowerUI/RuntimeDiagnosticsModels.swift +++ b/macos/Sources/AetowerUI/RuntimeDiagnosticsModels.swift @@ -97,7 +97,14 @@ public struct UiRefreshTelemetrySummary: Sendable { let updatedAt: Date? let uiRefreshMillis: Double let publishMillis: Double - let bridgeFetchMillis: Double + let bridgeCallMillis: Double? + let fullSnapshotFetchMillis: Double? + let evaluatorSnapshotFetchMillis: Double? + let monitorPayloadFetchMillis: Double? + let runtimeLagFetchMillis: Double? + let operatorStateFetchMillis: Double? + let workerTotalMillis: Double? + let window: UiRefreshTelemetryWindowSnapshot let fullSnapshotDemandCount: Int let fullSnapshotDemandSummary: String let fullSnapshotPublished: Bool @@ -112,7 +119,14 @@ public struct UiRefreshTelemetrySummary: Sendable { updatedAt: nil, uiRefreshMillis: 0, publishMillis: 0, - bridgeFetchMillis: 0, + bridgeCallMillis: nil, + fullSnapshotFetchMillis: nil, + evaluatorSnapshotFetchMillis: nil, + monitorPayloadFetchMillis: nil, + runtimeLagFetchMillis: nil, + operatorStateFetchMillis: nil, + workerTotalMillis: nil, + window: UiRefreshTelemetryWindow().snapshot, fullSnapshotDemandCount: 0, fullSnapshotDemandSummary: "none", fullSnapshotPublished: false, diff --git a/macos/Sources/AetowerUI/SnapshotRefreshTelemetry.swift b/macos/Sources/AetowerUI/SnapshotRefreshTelemetry.swift new file mode 100644 index 00000000..0a2b0fa7 --- /dev/null +++ b/macos/Sources/AetowerUI/SnapshotRefreshTelemetry.swift @@ -0,0 +1,291 @@ +import Foundation + +struct SnapshotRefreshPhaseTimings: Sendable { + let fullSnapshotFetchMillis: Double? + let evaluatorSnapshotFetchMillis: Double? + let monitorPayloadFetchMillis: Double? + let runtimeLagFetchMillis: Double? + let operatorStateFetchMillis: Double? + let workerTotalMillis: Double? + let workerTotalAttempted: Bool + let fullSnapshotFetchAttempted: Bool + let evaluatorSnapshotFetchAttempted: Bool + let monitorPayloadFetchAttempted: Bool + let runtimeLagFetchAttempted: Bool + let operatorStateFetchAttempted: Bool + + /// Sum attempted Bridge phases only when every attempted phase was timed. + /// The existing Rust `bridge_fetch_millis` remains the full snapshot only. + var bridgeCallMillis: Double? { + let phases = [ + (fullSnapshotFetchAttempted, fullSnapshotFetchMillis), + (evaluatorSnapshotFetchAttempted, evaluatorSnapshotFetchMillis), + (monitorPayloadFetchAttempted, monitorPayloadFetchMillis), + (runtimeLagFetchAttempted, runtimeLagFetchMillis), + (operatorStateFetchAttempted, operatorStateFetchMillis) + ] + var total = 0.0 + var attemptedPhaseCount = 0 + for (attempted, duration) in phases where attempted { + guard let duration else { return nil } + total += duration + attemptedPhaseCount += 1 + } + return attemptedPhaseCount == 0 ? nil : total + } + + static let zero = SnapshotRefreshPhaseTimings( + fullSnapshotFetchMillis: nil, + evaluatorSnapshotFetchMillis: nil, + monitorPayloadFetchMillis: nil, + runtimeLagFetchMillis: nil, + operatorStateFetchMillis: nil, + workerTotalMillis: nil + ) + + init( + fullSnapshotFetchMillis: Double?, + evaluatorSnapshotFetchMillis: Double?, + monitorPayloadFetchMillis: Double?, + runtimeLagFetchMillis: Double?, + operatorStateFetchMillis: Double?, + workerTotalMillis: Double?, + workerTotalAttempted: Bool = false, + fullSnapshotFetchAttempted: Bool = false, + evaluatorSnapshotFetchAttempted: Bool = false, + monitorPayloadFetchAttempted: Bool = false, + runtimeLagFetchAttempted: Bool = false, + operatorStateFetchAttempted: Bool = false + ) { + self.fullSnapshotFetchMillis = fullSnapshotFetchMillis + self.evaluatorSnapshotFetchMillis = evaluatorSnapshotFetchMillis + self.monitorPayloadFetchMillis = monitorPayloadFetchMillis + self.runtimeLagFetchMillis = runtimeLagFetchMillis + self.operatorStateFetchMillis = operatorStateFetchMillis + self.workerTotalMillis = workerTotalMillis + self.workerTotalAttempted = workerTotalAttempted + self.fullSnapshotFetchAttempted = fullSnapshotFetchAttempted + self.evaluatorSnapshotFetchAttempted = evaluatorSnapshotFetchAttempted + self.monitorPayloadFetchAttempted = monitorPayloadFetchAttempted + self.runtimeLagFetchAttempted = runtimeLagFetchAttempted + self.operatorStateFetchAttempted = operatorStateFetchAttempted + } +} + +enum SnapshotRefreshOutcome: Sendable { + case updated + case noChange + case failed + case cancelled +} + +struct UiRefreshTelemetryWindowSnapshot: Sendable { + let refreshCount: Int + let updatedCount: Int + let noChangeCount: Int + let failedCount: Int + let cancelledCount: Int + let slowCount: Int + let fullSnapshotFetchCount: Int + let fullSnapshotFetchTimingSampleCount: Int + let fullSnapshotPublishedCount: Int + let evaluatorSnapshotFetchCount: Int + let evaluatorSnapshotFetchTimingSampleCount: Int + let monitorPayloadFetchCount: Int + let monitorPayloadFetchTimingSampleCount: Int + let monitorDeltaCount: Int + let monitorSnapshotCount: Int + let runtimeLagFetchCount: Int + let runtimeLagFetchTimingSampleCount: Int + let operatorStateFetchCount: Int + let operatorStateFetchTimingSampleCount: Int + let workerTotalCount: Int + let workerTotalTimingSampleCount: Int + let meanFullSnapshotFetchMillis: Double? + let meanEvaluatorSnapshotFetchMillis: Double? + let meanMonitorPayloadFetchMillis: Double? + let meanRuntimeLagFetchMillis: Double? + let meanOperatorStateFetchMillis: Double? + let meanWorkerTotalMillis: Double? + let meanPublishMillis: Double? + let meanUiRefreshMillis: Double? + let maxUiRefreshMillis: Double + let under50MillisCount: Int + let from50To250MillisCount: Int + let from250To1000MillisCount: Int + let over1000MillisCount: Int + let windowDurationMillis: UInt64 +} + +struct UiRefreshTelemetryWindow { + // The event stream retains the refresh denominator, including quick no-change + // cycles, so slow-event counts can be interpreted rather than guessed. + private(set) var refreshCount = 0 + private(set) var updatedCount = 0 + private(set) var noChangeCount = 0 + private(set) var failedCount = 0 + private(set) var cancelledCount = 0 + private(set) var slowCount = 0 + private(set) var fullSnapshotFetchCount = 0 + private(set) var fullSnapshotFetchTimingSampleCount = 0 + private(set) var fullSnapshotPublishedCount = 0 + private(set) var evaluatorSnapshotFetchCount = 0 + private(set) var evaluatorSnapshotFetchTimingSampleCount = 0 + private(set) var monitorPayloadFetchCount = 0 + private(set) var monitorPayloadFetchTimingSampleCount = 0 + private(set) var runtimeLagFetchCount = 0 + private(set) var runtimeLagFetchTimingSampleCount = 0 + private(set) var operatorStateFetchCount = 0 + private(set) var operatorStateFetchTimingSampleCount = 0 + private(set) var workerTotalCount = 0 + private(set) var workerTotalTimingSampleCount = 0 + private(set) var monitorDeltaCount = 0 + private(set) var monitorSnapshotCount = 0 + private var fullSnapshotFetchTotalMillis = 0.0 + private var evaluatorSnapshotFetchTotalMillis = 0.0 + private var monitorPayloadFetchTotalMillis = 0.0 + private var runtimeLagFetchTotalMillis = 0.0 + private var operatorStateFetchTotalMillis = 0.0 + private var workerTotalMillis = 0.0 + private var publishTotalMillis = 0.0 + private var uiRefreshTotalMillis = 0.0 + private(set) var maxUiRefreshMillis = 0.0 + private(set) var under50MillisCount = 0 + private(set) var from50To250MillisCount = 0 + private(set) var from250To1000MillisCount = 0 + private(set) var over1000MillisCount = 0 + private var startedAtMillis: UInt64? + private var endedAtMillis: UInt64? + + var snapshot: UiRefreshTelemetryWindowSnapshot { + UiRefreshTelemetryWindowSnapshot( + refreshCount: refreshCount, + updatedCount: updatedCount, + noChangeCount: noChangeCount, + failedCount: failedCount, + cancelledCount: cancelledCount, + slowCount: slowCount, + fullSnapshotFetchCount: fullSnapshotFetchCount, + fullSnapshotFetchTimingSampleCount: fullSnapshotFetchTimingSampleCount, + fullSnapshotPublishedCount: fullSnapshotPublishedCount, + evaluatorSnapshotFetchCount: evaluatorSnapshotFetchCount, + evaluatorSnapshotFetchTimingSampleCount: evaluatorSnapshotFetchTimingSampleCount, + monitorPayloadFetchCount: monitorPayloadFetchCount, + monitorPayloadFetchTimingSampleCount: monitorPayloadFetchTimingSampleCount, + monitorDeltaCount: monitorDeltaCount, + monitorSnapshotCount: monitorSnapshotCount, + runtimeLagFetchCount: runtimeLagFetchCount, + runtimeLagFetchTimingSampleCount: runtimeLagFetchTimingSampleCount, + operatorStateFetchCount: operatorStateFetchCount, + operatorStateFetchTimingSampleCount: operatorStateFetchTimingSampleCount, + workerTotalCount: workerTotalCount, + workerTotalTimingSampleCount: workerTotalTimingSampleCount, + meanFullSnapshotFetchMillis: mean(fullSnapshotFetchTotalMillis, count: fullSnapshotFetchTimingSampleCount), + meanEvaluatorSnapshotFetchMillis: mean(evaluatorSnapshotFetchTotalMillis, count: evaluatorSnapshotFetchTimingSampleCount), + meanMonitorPayloadFetchMillis: mean(monitorPayloadFetchTotalMillis, count: monitorPayloadFetchTimingSampleCount), + meanRuntimeLagFetchMillis: mean(runtimeLagFetchTotalMillis, count: runtimeLagFetchTimingSampleCount), + meanOperatorStateFetchMillis: mean(operatorStateFetchTotalMillis, count: operatorStateFetchTimingSampleCount), + meanWorkerTotalMillis: mean(workerTotalMillis, count: workerTotalTimingSampleCount), + meanPublishMillis: mean(publishTotalMillis, count: refreshCount), + meanUiRefreshMillis: mean(uiRefreshTotalMillis, count: refreshCount), + maxUiRefreshMillis: maxUiRefreshMillis, + under50MillisCount: under50MillisCount, + from50To250MillisCount: from50To250MillisCount, + from250To1000MillisCount: from250To1000MillisCount, + over1000MillisCount: over1000MillisCount, + windowDurationMillis: windowDurationMillis + ) + } + + private var windowDurationMillis: UInt64 { + guard let startedAtMillis, let endedAtMillis, endedAtMillis >= startedAtMillis else { return 0 } + return endedAtMillis - startedAtMillis + } + + mutating func record( + outcome: SnapshotRefreshOutcome, + timings: SnapshotRefreshPhaseTimings, + fullSnapshotPublished: Bool, + monitorPayloadKind: String, + publishMillis: Double, + uiRefreshMillis: Double, + atMillis: UInt64 + ) { + if startedAtMillis == nil { startedAtMillis = atMillis } + endedAtMillis = atMillis + refreshCount += 1 + switch outcome { + case .updated: updatedCount += 1 + case .noChange: noChangeCount += 1 + case .failed: failedCount += 1 + case .cancelled: cancelledCount += 1 + } + if uiRefreshMillis >= 250 || publishMillis >= 120 { slowCount += 1 } + if fullSnapshotPublished { fullSnapshotPublishedCount += 1 } + if timings.fullSnapshotFetchAttempted { + fullSnapshotFetchCount += 1 + if let duration = timings.fullSnapshotFetchMillis { + fullSnapshotFetchTimingSampleCount += 1 + fullSnapshotFetchTotalMillis += duration + } + } + if timings.evaluatorSnapshotFetchAttempted { + evaluatorSnapshotFetchCount += 1 + if let duration = timings.evaluatorSnapshotFetchMillis { + evaluatorSnapshotFetchTimingSampleCount += 1 + evaluatorSnapshotFetchTotalMillis += duration + } + } + if timings.monitorPayloadFetchAttempted { + monitorPayloadFetchCount += 1 + if let duration = timings.monitorPayloadFetchMillis { + monitorPayloadFetchTimingSampleCount += 1 + monitorPayloadFetchTotalMillis += duration + } + } + if timings.runtimeLagFetchAttempted { + runtimeLagFetchCount += 1 + if let duration = timings.runtimeLagFetchMillis { + runtimeLagFetchTimingSampleCount += 1 + runtimeLagFetchTotalMillis += duration + } + } + if timings.operatorStateFetchAttempted { + operatorStateFetchCount += 1 + if let duration = timings.operatorStateFetchMillis { + operatorStateFetchTimingSampleCount += 1 + operatorStateFetchTotalMillis += duration + } + } + if timings.workerTotalAttempted { + workerTotalCount += 1 + if let duration = timings.workerTotalMillis { + workerTotalTimingSampleCount += 1 + workerTotalMillis += duration + } + } + if monitorPayloadKind.hasPrefix("delta") { monitorDeltaCount += 1 } + if monitorPayloadKind.hasPrefix("snapshot") { monitorSnapshotCount += 1 } + publishTotalMillis += publishMillis + uiRefreshTotalMillis += uiRefreshMillis + maxUiRefreshMillis = max(maxUiRefreshMillis, uiRefreshMillis) + if uiRefreshMillis < 50 { + under50MillisCount += 1 + } else if uiRefreshMillis < 250 { + from50To250MillisCount += 1 + } else if uiRefreshMillis < 1_000 { + from250To1000MillisCount += 1 + } else { + over1000MillisCount += 1 + } + } + + mutating func reset() { + self = UiRefreshTelemetryWindow() + } + + private func mean(_ total: Double, count: Int) -> Double? { + guard count > 0 else { return nil } + return total / Double(count) + } +} diff --git a/macos/Sources/AetowerUI/SnapshotRefreshWorker.swift b/macos/Sources/AetowerUI/SnapshotRefreshWorker.swift index a2cae5cc..ae00317e 100644 --- a/macos/Sources/AetowerUI/SnapshotRefreshWorker.swift +++ b/macos/Sources/AetowerUI/SnapshotRefreshWorker.swift @@ -5,6 +5,9 @@ private enum MonitorUiPayloadSizing { /// Fixed graph budget for compact Monitor payloads. Rust resamples each /// non-empty series to exactly this count, so Swift draw cost stays stable. static let graphPointCount: UInt32 = 60 + /// Request the full current entity list. Rust clamps this to its supported + /// maximum (500); the Monitor can then stay on compact row deltas. + static let processRowLimit: UInt32 = .max } actor SnapshotRefreshWorker { @@ -27,10 +30,42 @@ actor SnapshotRefreshWorker { publishFullSnapshot: Bool = true, fetchEvaluatorSnapshot: Bool = true, monitorSelectedEntityId: String? = nil, - monitorProcessLimit: UInt32 = 160, + monitorProcessLimit: UInt32 = MonitorUiPayloadSizing.processRowLimit, monitorTrendPoints: UInt32 = MonitorUiPayloadSizing.graphPointCount ) throws -> SnapshotRefreshResult { - let fetchStartedAt = CFAbsoluteTimeGetCurrent() + let workerStartedAt = CFAbsoluteTimeGetCurrent() + var fullSnapshotFetchMillis: Double? + var evaluatorSnapshotFetchMillis: Double? + var monitorPayloadFetchMillis: Double? + var runtimeLagFetchMillis: Double? + var operatorStateFetchMillis: Double? + var fullSnapshotFetchAttempted = false + var evaluatorSnapshotFetchAttempted = false + var monitorPayloadFetchAttempted = false + var runtimeLagFetchAttempted = false + var operatorStateFetchAttempted = false + + func measuredTimings() -> SnapshotRefreshPhaseTimings { + SnapshotRefreshPhaseTimings( + fullSnapshotFetchMillis: fullSnapshotFetchMillis, + evaluatorSnapshotFetchMillis: evaluatorSnapshotFetchMillis, + monitorPayloadFetchMillis: monitorPayloadFetchMillis, + runtimeLagFetchMillis: runtimeLagFetchMillis, + operatorStateFetchMillis: operatorStateFetchMillis, + workerTotalMillis: elapsedMillis(since: workerStartedAt), + workerTotalAttempted: true, + fullSnapshotFetchAttempted: fullSnapshotFetchAttempted, + evaluatorSnapshotFetchAttempted: evaluatorSnapshotFetchAttempted, + monitorPayloadFetchAttempted: monitorPayloadFetchAttempted, + runtimeLagFetchAttempted: runtimeLagFetchAttempted, + operatorStateFetchAttempted: operatorStateFetchAttempted + ) + } + + func preservedFailure(_ error: Error) -> SnapshotRefreshFailure { + SnapshotRefreshFailure(message: error.localizedDescription, timings: measuredTimings()) + } + // The full SystemSnapshot is the expensive deep FFI decode (every // entity, component, connection, badge). UI publication and evaluator // refresh are separately gated so automation can stay fresh without @@ -38,11 +73,19 @@ actor SnapshotRefreshWorker { var refreshedSnapshot: SystemSnapshot? var evaluatorSnapshot: SystemSnapshot? if publishFullSnapshot || force { - if let updatedSnapshot = try bridge.latestSnapshotIfNewer(since: lastPublishedSnapshotSequence) { - refreshedSnapshot = updatedSnapshot - } else if force { - refreshedSnapshot = try bridge.latestSnapshot() + fullSnapshotFetchAttempted = true + let stageStartedAt = CFAbsoluteTimeGetCurrent() + do { + if let updatedSnapshot = try bridge.latestSnapshotIfNewer(since: lastPublishedSnapshotSequence) { + refreshedSnapshot = updatedSnapshot + } else if force { + refreshedSnapshot = try bridge.latestSnapshot() + } + } catch { + fullSnapshotFetchMillis = elapsedMillis(since: stageStartedAt) + throw preservedFailure(error) } + fullSnapshotFetchMillis = elapsedMillis(since: stageStartedAt) if let refreshedSnapshot { lastPublishedSnapshotSequence = refreshedSnapshot.sequence if refreshedSnapshot.sequence > lastEvaluatorSnapshotSequence { @@ -51,13 +94,22 @@ actor SnapshotRefreshWorker { } } } else if fetchEvaluatorSnapshot { - if let updatedSnapshot = try bridge.latestSnapshotIfNewer(since: lastEvaluatorSnapshotSequence) { - evaluatorSnapshot = updatedSnapshot - lastEvaluatorSnapshotSequence = updatedSnapshot.sequence + evaluatorSnapshotFetchAttempted = true + let stageStartedAt = CFAbsoluteTimeGetCurrent() + do { + if let updatedSnapshot = try bridge.latestSnapshotIfNewer(since: lastEvaluatorSnapshotSequence) { + evaluatorSnapshot = updatedSnapshot + lastEvaluatorSnapshotSequence = updatedSnapshot.sequence + } + } catch { + evaluatorSnapshotFetchMillis = elapsedMillis(since: stageStartedAt) + throw preservedFailure(error) } + evaluatorSnapshotFetchMillis = elapsedMillis(since: stageStartedAt) } - let bridgeFetchMillis = (CFAbsoluteTimeGetCurrent() - fetchStartedAt) * 1000.0 + monitorPayloadFetchAttempted = true + let monitorFetchStartedAt = CFAbsoluteTimeGetCurrent() let monitorPayload = try? fetchMonitorPayload( force: force, processLimit: monitorProcessLimit, @@ -65,14 +117,25 @@ actor SnapshotRefreshWorker { selectedEntityId: monitorSelectedEntityId, collectPayloadDiagnostics: collectPayloadDiagnostics ) + monitorPayloadFetchMillis = elapsedMillis(since: monitorFetchStartedAt) - if refreshedSnapshot == nil, evaluatorSnapshot == nil, monitorPayload == nil, !includeOperatorState { - return .noChange + runtimeLagFetchAttempted = true + let runtimeLagStartedAt = CFAbsoluteTimeGetCurrent() + let runtimeLagMetrics = bridge.latestRuntimeLagMetrics() + runtimeLagFetchMillis = elapsedMillis(since: runtimeLagStartedAt) + + if refreshedSnapshot == nil, + evaluatorSnapshot == nil, + monitorPayload?.hasUpdate != true, + !includeOperatorState { + return .noChange(measuredTimings(), monitorPayload, runtimeLagMetrics) } - let runtimeLagMetrics = bridge.latestRuntimeLagMetrics() - let operatorState = includeOperatorState - ? SnapshotOperatorRefreshPayload( + var operatorState: SnapshotOperatorRefreshPayload? + if includeOperatorState { + operatorStateFetchAttempted = true + let operatorStateStartedAt = CFAbsoluteTimeGetCurrent() + operatorState = SnapshotOperatorRefreshPayload( diagnosticsOverview: bridge.diagnosticsOverview(), historyStoreSummary: bridge.historyRangeSummary( startMillis: 0, @@ -83,15 +146,17 @@ actor SnapshotRefreshWorker { ) ) ) - : nil + operatorStateFetchMillis = elapsedMillis(since: operatorStateStartedAt) + } + let timings = measuredTimings() return .updated(SnapshotRefreshPayload( snapshot: refreshedSnapshot, evaluatorSnapshot: evaluatorSnapshot, runtimeLagMetrics: runtimeLagMetrics, operatorState: operatorState, monitorPayload: monitorPayload, - bridgeFetchMillis: bridgeFetchMillis + timings: timings )) } @@ -171,8 +236,20 @@ actor SnapshotRefreshWorker { } enum SnapshotRefreshResult: Sendable { - case noChange + case noChange(SnapshotRefreshPhaseTimings, MonitorUiRefreshPayload?, RuntimeLagMetrics) case updated(SnapshotRefreshPayload) + + var timings: SnapshotRefreshPhaseTimings { + switch self { + case let .noChange(timings, _, _): timings + case let .updated(payload): payload.timings + } + } +} + +struct SnapshotRefreshFailure: Error, Sendable { + let message: String + let timings: SnapshotRefreshPhaseTimings } struct SnapshotRefreshPayload: Sendable { @@ -186,7 +263,7 @@ struct SnapshotRefreshPayload: Sendable { let runtimeLagMetrics: RuntimeLagMetrics let operatorState: SnapshotOperatorRefreshPayload? let monitorPayload: MonitorUiRefreshPayload? - let bridgeFetchMillis: Double + let timings: SnapshotRefreshPhaseTimings } struct SnapshotOperatorRefreshPayload: Sendable { @@ -197,6 +274,21 @@ struct SnapshotOperatorRefreshPayload: Sendable { enum MonitorUiRefreshPayload: Sendable { case snapshot(UiSnapshot, MonitorUiPayloadFetchDiagnostics) case delta(UiSnapshotDelta, MonitorUiPayloadFetchDiagnostics) + + var diagnostics: MonitorUiPayloadFetchDiagnostics { + switch self { + case let .snapshot(_, diagnostics), let .delta(_, diagnostics): diagnostics + } + } + + var hasUpdate: Bool { + switch self { + case .snapshot: + true + case let .delta(delta, _): + delta.updated + } + } } struct MonitorUiPayloadFetchDiagnostics: Sendable { diff --git a/macos/Sources/AetowerUI/SnapshotSlices.swift b/macos/Sources/AetowerUI/SnapshotSlices.swift index 4c2f9de4..3f9166c3 100644 --- a/macos/Sources/AetowerUI/SnapshotSlices.swift +++ b/macos/Sources/AetowerUI/SnapshotSlices.swift @@ -201,26 +201,42 @@ struct SnapshotHotSliceSignature: Equatable { private struct FullSnapshotDemandModifier: ViewModifier { let state: AppState let reason: String + let isDemanded: Bool @State private var token: UUID? func body(content: Content) -> some View { content .onAppear { - if token == nil { - token = state.beginFullSnapshotDemand(reason: reason) - } + reconcileDemand(isDemanded) + } + .onChange(of: isDemanded) { _, newValue in + reconcileDemand(newValue) } .onDisappear { - if let token { - state.endFullSnapshotDemand(token) - } - token = nil + reconcileDemand(false) } } + + private func reconcileDemand(_ demanded: Bool) { + if demanded, token == nil { + token = state.beginFullSnapshotDemand(reason: reason) + } else if !demanded, let token { + state.endFullSnapshotDemand(token) + self.token = nil + } + } } extension View { public func demandsFullSnapshot(from state: AppState, reason: String = "unspecified") -> some View { - modifier(FullSnapshotDemandModifier(state: state, reason: reason)) + demandsFullSnapshot(from: state, when: true, reason: reason) + } + + public func demandsFullSnapshot( + from state: AppState, + when isDemanded: Bool, + reason: String = "unspecified" + ) -> some View { + modifier(FullSnapshotDemandModifier(state: state, reason: reason, isDemanded: isDemanded)) } } diff --git a/macos/Tests/AetowerUITests/MonitorLiveRowsTests.swift b/macos/Tests/AetowerUITests/MonitorLiveRowsTests.swift new file mode 100644 index 00000000..5100b8cc --- /dev/null +++ b/macos/Tests/AetowerUITests/MonitorLiveRowsTests.swift @@ -0,0 +1,164 @@ +import XCTest +import AetowerBridge +@testable import AetowerUI + +final class MonitorLiveRowsTests: XCTestCase { + func testLiveProjectionFiltersSupportedBundleAndNumericTokensThenSortsByMetric() { + let rows = [ + processRow(id: "z", name: "Zulu Agent", cpu: 72, friction: 10), + processRow(id: "a", name: "Alpha Agent", cpu: 80, friction: 2), + processRow(id: "b", name: "Beta Helper", cpu: 30, friction: 30), + ] + + let projected = MonitorLiveRowsProjection.filterAndSort( + rows: rows, + query: "bundle:org.example cpu>50", + sortKey: .cpu, + advancedFilterEntityIds: nil + ) + + XCTAssertEqual(projected.map(\.entityId), ["a", "z"]) + } + + func testMemoryFilterPreservesEntitySearchBinaryUnitsAndResidentSemantics() { + let rows = [ + processRow( + id: "resident-under", + name: "Under Resident Threshold", + cpu: 1, + friction: 1, + memoryResidentBytes: 100_000_000, + memoryPhysicalFootprintBytes: 200_000_000 + ), + processRow( + id: "resident-over", + name: "Over Resident Threshold", + cpu: 1, + friction: 1, + memoryResidentBytes: 110_000_000, + memoryPhysicalFootprintBytes: 110_000_000 + ), + ] + + let projected = MonitorLiveRowsProjection.filterAndSort( + rows: rows, + query: "memory>100MB", + sortKey: .cpu, + advancedFilterEntityIds: nil + ) + + XCTAssertEqual(projected.map(\.entityId), ["resident-over"]) + } + + func testLiveProjectionHonorsAdvancedFilterAndStableNameTieBreak() { + let rows = [ + processRow(id: "z", name: "Same", cpu: 20, friction: 5), + processRow(id: "a", name: "Same", cpu: 20, friction: 5), + processRow(id: "b", name: "Other", cpu: 20, friction: 5), + ] + + let projected = MonitorLiveRowsProjection.filterAndSort( + rows: rows, + query: "", + sortKey: .friction, + advancedFilterEntityIds: ["z", "a"] + ) + + XCTAssertEqual(projected.map(\.entityId), ["a", "z"]) + } + + func testLiveProjectionRetainsTheFullFiveHundredRowBridgeLimit() { + let rows = (0..<500).map { index in + processRow(id: "pid:\(index)", name: "Process \(index)", cpu: Float(index), friction: Float(index % 100)) + } + + let projected = MonitorLiveRowsProjection.filterAndSort( + rows: rows, + query: "", + sortKey: .cpu, + advancedFilterEntityIds: nil + ) + + XCTAssertEqual(projected.count, 500) + XCTAssertEqual(projected.first?.entityId, "pid:499") + } + + func testUnsupportedSearchAndSortFallBackToFullSnapshotPath() { + XCTAssertFalse(MonitorLiveRowsProjection.supportsLiveSearch("origin:cli")) + XCTAssertFalse(MonitorLiveRowsProjection.supportsLiveSearch("name:agent")) + XCTAssertFalse(MonitorLiveRowsProjection.supportsLiveSearch("Agent")) + XCTAssertFalse(MonitorLiveRowsProjection.supportsLiveSearch("/agent.*/")) + XCTAssertFalse(MonitorLiveRowsProjection.supportsLiveSearch("pid=42")) + XCTAssertTrue(MonitorLiveRowsProjection.supportsLiveSearch("bundle:org.example")) + XCTAssertTrue(MonitorLiveRowsProjection.supportsLiveSearch("cpu>=50 memory>100MB")) + XCTAssertFalse(MonitorLiveRowsProjection.supportsLiveSort(.processCount)) + XCTAssertFalse(MonitorLiveRowsProjection.supportsLiveSort(.energy)) + XCTAssertFalse(MonitorLiveRowsProjection.supportsLiveSort(.newestFirst)) + XCTAssertTrue(MonitorLiveRowsProjection.supportsLiveSort(.cpu)) + } + + func testFlatCompleteUnfilteredMonitorDoesNotDemandFullSnapshot() { + XCTAssertFalse(MonitorSnapshotPolicy.requiresFullSnapshot( + isGroupedMode: false, + hasSelectedEntity: false, + hasDetailedFilter: false, + hasCompleteLiveRows: true + )) + } + + func testGroupedDetailFilteredOrIncompleteMonitorDemandsFullSnapshot() { + let cases: [(Bool, Bool, Bool, Bool)] = [ + (true, false, false, true), + (false, true, false, true), + (false, false, true, true), + (false, false, false, false), + ] + + for (isGrouped, hasSelection, hasFilter, hasCompleteRows) in cases { + XCTAssertTrue(MonitorSnapshotPolicy.requiresFullSnapshot( + isGroupedMode: isGrouped, + hasSelectedEntity: hasSelection, + hasDetailedFilter: hasFilter, + hasCompleteLiveRows: hasCompleteRows + )) + } + } + + private func processRow( + id: String, + name: String, + cpu: Float, + friction: Float, + memoryResidentBytes: UInt64 = 10_000, + memoryPhysicalFootprintBytes: UInt64 = 12_000 + ) -> UiProcessRow { + UiProcessRow( + entityId: id, + displayName: name, + entityKind: .unknown, + bundleId: "org.example.\(id)", + executablePath: "/Applications/\(name).app/Contents/MacOS/\(name)", + primaryPid: nil, + processCount: 1, + threadCount: 2, + cpuPercent: cpu, + memoryResidentBytes: memoryResidentBytes, + memoryPhysicalFootprintBytes: memoryPhysicalFootprintBytes, + diskReadBps: 0, + diskWriteBps: 0, + networkReceiveBps: 0, + networkSendBps: 0, + wakeupsPerSecond: 0, + energyNjPerS: 0, + estimatedGpuPercent: 0, + frictionScore: friction, + isForeground: false, + anomalyDetected: false, + activeWindowTitle: nil, + recentChangeSummary: nil, + signingClassification: "signed", + isAdhoc: false, + appVersion: nil + ) + } +} diff --git a/macos/Tests/AetowerUITests/MonitorViewModelTests.swift b/macos/Tests/AetowerUITests/MonitorViewModelTests.swift index 2fbb57e2..80442f80 100644 --- a/macos/Tests/AetowerUITests/MonitorViewModelTests.swift +++ b/macos/Tests/AetowerUITests/MonitorViewModelTests.swift @@ -83,6 +83,72 @@ final class MonitorViewModelTests: XCTestCase { XCTAssertEqual(model.lastDeltaRemovedRowCount, 0) } + func testHostDeltaRefreshesCompactThermalForecast() { + var model = MonitorViewModel() + model.host = hostSummary(forecast: nil) + let forecast = ThermalForecast( + minutesToThrottle: 4.5, + trendCelsiusPerMin: 1.2, + state: .serious, + topContributorEntityId: "bundle:com.example.app", + topContributorPid: 42, + topContributorLabel: "Example App" + ) + + model.apply(delta: UiSnapshotDelta( + updated: true, + sequence: 1, + baseSequence: 0, + capturedAtMillis: 1_000, + processLimit: 500, + trendPoints: 60, + baseAvailable: true, + host: hostSummary(forecast: forecast), + hostTrend: nil, + changedMetricCards: [], + changedProcessRows: [], + removedEntityIds: [], + selectedEntity: nil, + selectedEntityChanged: false, + selectedEntityRemoved: false, + totalEntityCount: 0, + returnedEntityCount: 0, + totalProcessCount: 0, + timelineWarningCount: 0, + timelineCriticalCount: 0 + )) + + XCTAssertEqual(model.host?.thermalForecast?.minutesToThrottle, 4.5) + XCTAssertEqual(model.host?.thermalForecast?.topContributorLabel, "Example App") + } + + private func hostSummary(forecast: ThermalForecast?) -> UiHostSummary { + UiHostSummary( + machineFriction: 0, + cpuPercent: 0, + memoryUsedBytes: 0, + memoryTotalBytes: 0, + compressedMemoryBytes: 0, + swapUsedBytes: 0, + diskReadBps: 0, + diskWriteBps: 0, + networkReceiveBps: 0, + networkSendBps: 0, + wakeupsPerSecond: 0, + gpuPercent: 0, + gpuMemoryBytes: 0, + thermalState: .nominal, + thermalForecast: forecast, + onBattery: false, + lowPowerMode: false, + frontmostAppName: nil, + frontmostWindowTitle: nil, + aiAgentCount: 0, + entityCount: 0, + processCount: 0 + ) + } + private func metricCard(id: String, displayValue: String) -> UiMetricCard { UiMetricCard( id: id, diff --git a/macos/Tests/AetowerUITests/SnapshotRefreshTelemetryTests.swift b/macos/Tests/AetowerUITests/SnapshotRefreshTelemetryTests.swift new file mode 100644 index 00000000..e18aa502 --- /dev/null +++ b/macos/Tests/AetowerUITests/SnapshotRefreshTelemetryTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import AetowerUI + +final class SnapshotRefreshTelemetryTests: XCTestCase { + func testWindowCountsAttemptsSeparatelyFromMeasuredTimings() { + var window = UiRefreshTelemetryWindow() + window.record( + outcome: .updated, + timings: SnapshotRefreshPhaseTimings( + fullSnapshotFetchMillis: 100, + evaluatorSnapshotFetchMillis: 0, + monitorPayloadFetchMillis: 30, + runtimeLagFetchMillis: 2, + operatorStateFetchMillis: 0, + workerTotalMillis: 140, + workerTotalAttempted: true, + fullSnapshotFetchAttempted: true, + evaluatorSnapshotFetchAttempted: false, + monitorPayloadFetchAttempted: true, + runtimeLagFetchAttempted: true, + operatorStateFetchAttempted: false + ), + fullSnapshotPublished: true, + monitorPayloadKind: "delta", + publishMillis: 25, + uiRefreshMillis: 300, + atMillis: 1_000 + ) + window.record( + outcome: .noChange, + timings: SnapshotRefreshPhaseTimings( + fullSnapshotFetchMillis: nil, + evaluatorSnapshotFetchMillis: nil, + monitorPayloadFetchMillis: 0, + runtimeLagFetchMillis: 0, + operatorStateFetchMillis: nil, + workerTotalMillis: 20, + workerTotalAttempted: true, + fullSnapshotFetchAttempted: false, + evaluatorSnapshotFetchAttempted: false, + monitorPayloadFetchAttempted: true, + runtimeLagFetchAttempted: true, + operatorStateFetchAttempted: false + ), + fullSnapshotPublished: false, + monitorPayloadKind: "delta-no-update", + publishMillis: 0, + uiRefreshMillis: 20, + atMillis: 1_010 + ) + let failedTimings = SnapshotRefreshPhaseTimings( + fullSnapshotFetchMillis: nil, + evaluatorSnapshotFetchMillis: nil, + monitorPayloadFetchMillis: nil, + runtimeLagFetchMillis: nil, + operatorStateFetchMillis: nil, + workerTotalMillis: 80, + workerTotalAttempted: true, + fullSnapshotFetchAttempted: true + ) + XCTAssertNil(failedTimings.bridgeCallMillis) + window.record( + outcome: .failed, + timings: failedTimings, + fullSnapshotPublished: false, + monitorPayloadKind: "none", + publishMillis: 0, + uiRefreshMillis: 80, + atMillis: 1_020 + ) + + let sample = window.snapshot + XCTAssertEqual(sample.refreshCount, 3) + XCTAssertEqual(sample.updatedCount, 1) + XCTAssertEqual(sample.noChangeCount, 1) + XCTAssertEqual(sample.failedCount, 1) + XCTAssertEqual(sample.slowCount, 1) + XCTAssertEqual(sample.fullSnapshotFetchCount, 2) + XCTAssertEqual(sample.fullSnapshotFetchTimingSampleCount, 1) + XCTAssertEqual(sample.fullSnapshotPublishedCount, 1) + XCTAssertEqual(sample.monitorPayloadFetchCount, 2) + XCTAssertEqual(sample.monitorPayloadFetchTimingSampleCount, 2) + XCTAssertEqual(sample.monitorDeltaCount, 2) + XCTAssertEqual(sample.runtimeLagFetchCount, 2) + XCTAssertEqual(sample.runtimeLagFetchTimingSampleCount, 2) + XCTAssertEqual(sample.operatorStateFetchCount, 0) + XCTAssertEqual(sample.operatorStateFetchTimingSampleCount, 0) + XCTAssertEqual(sample.workerTotalCount, 3) + XCTAssertEqual(sample.workerTotalTimingSampleCount, 3) + XCTAssertEqual(sample.meanFullSnapshotFetchMillis ?? -1, 100, accuracy: 0.001) + XCTAssertNil(sample.meanEvaluatorSnapshotFetchMillis) + XCTAssertEqual(sample.meanMonitorPayloadFetchMillis ?? -1, 15, accuracy: 0.001) + XCTAssertEqual(sample.meanRuntimeLagFetchMillis ?? -1, 1, accuracy: 0.001) + XCTAssertNil(sample.meanOperatorStateFetchMillis) + XCTAssertEqual(sample.meanWorkerTotalMillis ?? -1, 80, accuracy: 0.001) + XCTAssertEqual(sample.under50MillisCount, 1) + XCTAssertEqual(sample.from50To250MillisCount, 1) + XCTAssertEqual(sample.from250To1000MillisCount, 1) + XCTAssertEqual(sample.over1000MillisCount, 0) + XCTAssertEqual(sample.windowDurationMillis, 20) + } + + func testResetStartsANewIndependentWindow() { + var window = UiRefreshTelemetryWindow() + window.record( + outcome: .updated, + timings: .zero, + fullSnapshotPublished: false, + monitorPayloadKind: "delta", + publishMillis: 1, + uiRefreshMillis: 40, + atMillis: 1_000 + ) + + window.reset() + + XCTAssertEqual(window.snapshot.refreshCount, 0) + XCTAssertEqual(window.snapshot.slowCount, 0) + XCTAssertNil(window.snapshot.meanUiRefreshMillis) + XCTAssertEqual(window.snapshot.windowDurationMillis, 0) + } +} diff --git a/rust/crates/aetower-ffi/src/lib.rs b/rust/crates/aetower-ffi/src/lib.rs index 186774d8..00baa5c5 100644 --- a/rust/crates/aetower-ffi/src/lib.rs +++ b/rust/crates/aetower-ffi/src/lib.rs @@ -766,6 +766,7 @@ pub struct UiHostSummary { pub gpu_percent: f32, pub gpu_memory_bytes: u64, pub thermal_state: ThermalState, + pub thermal_forecast: Option, pub on_battery: bool, pub low_power_mode: bool, pub frontmost_app_name: Option, @@ -2535,6 +2536,7 @@ fn ui_host_summary_from_snapshot( gpu_percent: snapshot.host.gpu_percent, gpu_memory_bytes: snapshot.host.gpu_memory_bytes, thermal_state: snapshot.host.thermal_state.into(), + thermal_forecast: snapshot.thermal_forecast.clone().map(Into::into), on_battery: snapshot.host.on_battery, low_power_mode: snapshot.host.low_power_mode, frontmost_app_name: snapshot.host.frontmost_app_name.clone(),