diff --git a/App/AppState.swift b/App/AppState.swift index 97167a2..3fe762b 100644 --- a/App/AppState.swift +++ b/App/AppState.swift @@ -23,6 +23,7 @@ final class AppState { private var registry: ProjectRegistry private var pollTask: Task? private var panelVisible = false + private var sampler = VitalsSampler() var runningCount: Int { containers.filter { $0.status == .running }.count } var isServiceRunning: Bool { serviceStatus == .running } @@ -93,10 +94,13 @@ final class AppState { // Preserve an in-flight container's optimistic status so a poll mid-action // doesn't flicker it back to the pre-action state. Carry over last-known memory // so vitals don't blink to empty between fetches. + // Carry last-known vitals so they don't blink to empty between fetches. let priorMemory = Dictionary(containers.map { ($0.id, $0.memoryBytes) }, uniquingKeysWith: { a, _ in a }) + let priorCPU = Dictionary(containers.map { ($0.id, $0.cpuPercent) }, uniquingKeysWith: { a, _ in a }) containers = fresh.map { incoming in var c = incoming - c.memoryBytes = priorMemory[incoming.id] ?? nil + if c.memoryBytes == nil { c.memoryBytes = priorMemory[incoming.id] ?? nil } + if c.cpuPercent == nil { c.cpuPercent = priorCPU[incoming.id] ?? nil } if inFlight.contains(incoming.id), let existing = containers.first(where: { $0.id == incoming.id }) { c.status = existing.status @@ -104,25 +108,36 @@ final class AppState { return c } reassemble() - await fetchMemory() + await fetchVitals() } catch { lastError = describe(error) } } - /// Concurrently fetch live memory for running containers, then re-assemble. Best-effort. - private func fetchMemory() async { + /// Concurrently fetch live memory + CPU for running containers, then re-assemble. + /// Best-effort: missing values leave the carried-over value in place. + private func fetchVitals() async { let engine = containerEngine let running = containers.filter { $0.status == .running }.map(\.id) + sampler.retain(ids: Set(running)) guard !running.isEmpty else { return } - let results = await withTaskGroup(of: (String, UInt64?).self) { group -> [(String, UInt64?)] in - for id in running { group.addTask { (id, await engine.memoryUsage(id: id)) } } - var out: [(String, UInt64?)] = [] + + let results = await withTaskGroup(of: (String, UInt64?, UInt64?).self) { group -> [(String, UInt64?, UInt64?)] in + for id in running { + group.addTask { (id, await engine.memoryUsage(id: id), await engine.cpuUsage(id: id)) } + } + var out: [(String, UInt64?, UInt64?)] = [] for await r in group { out.append(r) } return out } - for (id, mem) in results { - if let idx = containers.firstIndex(where: { $0.id == id }) { containers[idx].memoryBytes = mem } + + let now = Date() + for (id, mem, cpuUsec) in results { + guard let idx = containers.firstIndex(where: { $0.id == id }) else { continue } + if let mem { containers[idx].memoryBytes = mem } + if let cpuUsec, let pct = sampler.recordCPU(id: id, cumulativeUsec: cpuUsec, at: now) { + containers[idx].cpuPercent = pct + } } reassemble() } diff --git a/App/Shots/MockEngines.swift b/App/Shots/MockEngines.swift index 99ae5a0..4fcac2a 100644 --- a/App/Shots/MockEngines.swift +++ b/App/Shots/MockEngines.swift @@ -12,6 +12,7 @@ struct MockContainerEngine: ContainerEngine { func restart(id: String) async throws {} func delete(id: String) async throws {} func memoryUsage(id: String) async -> UInt64? { containers.first { $0.id == id }?.memoryBytes } + func cpuUsage(id: String) async -> UInt64? { nil } // cpu% needs sampling; mocks preset cpuPercent } struct MockComposeEngine: ComposeEngine { diff --git a/App/Shots/ShotRenderer.swift b/App/Shots/ShotRenderer.swift index 57932d3..478cdde 100644 --- a/App/Shots/ShotRenderer.swift +++ b/App/Shots/ShotRenderer.swift @@ -23,10 +23,10 @@ enum ShotRenderer { let mb: (Int) -> UInt64 = { UInt64($0) * 1_048_576 } let containers = [ - Container(id: "shop-web", name: "shop-web", image: "docker.io/library/nginx:latest", status: .running, ipAddress: "10.0.1.4", memoryBytes: mb(38)), - Container(id: "shop-api", name: "shop-api", image: "ghcr.io/acme/api:1.4", status: .running, ipAddress: "10.0.1.5", memoryBytes: mb(196)), - Container(id: "shop-db", name: "shop-db", image: "postgres:17", status: .running, ipAddress: "10.0.1.6", memoryBytes: mb(178)), - Container(id: "cache", name: "cache", image: "docker.io/library/redis:7", status: .running, ipAddress: "192.168.64.2", memoryBytes: mb(24)), + Container(id: "shop-web", name: "shop-web", image: "docker.io/library/nginx:latest", status: .running, ipAddress: "10.0.1.4", memoryBytes: mb(38), cpuPercent: 2), + Container(id: "shop-api", name: "shop-api", image: "ghcr.io/acme/api:1.4", status: .running, ipAddress: "10.0.1.5", memoryBytes: mb(196), cpuPercent: 5), + Container(id: "shop-db", name: "shop-db", image: "postgres:17", status: .running, ipAddress: "10.0.1.6", memoryBytes: mb(178), cpuPercent: 1), + Container(id: "cache", name: "cache", image: "docker.io/library/redis:7", status: .running, ipAddress: "192.168.64.2", memoryBytes: mb(24), cpuPercent: 3), Container(id: "scratch", name: "scratch", image: "docker.io/library/alpine:latest", status: .stopped), ] let state = AppState( diff --git a/App/Views/ContainerRow.swift b/App/Views/ContainerRow.swift index eca28f8..186e66e 100644 --- a/App/Views/ContainerRow.swift +++ b/App/Views/ContainerRow.swift @@ -36,6 +36,8 @@ struct ContainerRow: View { actions } else { vitals + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) // keep vitals one line; image truncates } } .padding(.vertical, 7) @@ -51,15 +53,23 @@ struct ContainerRow: View { private var vitals: some View { switch container.status { case .running: - HStack(spacing: 5) { - if let ip = container.ipAddress { - Text(ip).font(Theme.mono(10)).foregroundStyle(Theme.ip) - } - if let mem = container.memoryBytes { - if container.ipAddress != nil { Text("·").font(Theme.mono(10)).foregroundStyle(Theme.dim2) } - Text(formatBytes(mem)).font(Theme.mono(10)).foregroundStyle(Theme.dim) - } else if container.ipAddress == nil { - Text("alive").font(Theme.mono(10)).foregroundStyle(Theme.jade) + let extras = [ + container.cpuPercent.map { "\(Int($0.rounded()))%" }, + container.memoryBytes.map(formatBytes), + ].compactMap { $0 } + if container.ipAddress == nil && extras.isEmpty { + Text("alive").font(Theme.mono(10)).foregroundStyle(Theme.jade) + } else { + HStack(spacing: 4) { + if let ip = container.ipAddress { + Text(ip).font(Theme.mono(10)).foregroundStyle(Theme.ip) + } + ForEach(Array(extras.enumerated()), id: \.offset) { index, value in + if index > 0 || container.ipAddress != nil { + Text("·").font(Theme.mono(10)).foregroundStyle(Theme.dim2) + } + Text(value).font(Theme.mono(10)).foregroundStyle(Theme.dim) + } } } case .stopped: diff --git a/ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift b/ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift index 7c2ea8f..b843404 100644 --- a/ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift +++ b/ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift @@ -10,6 +10,8 @@ public protocol ContainerEngine: Sendable { func delete(id: String) async throws /// Best-effort live resident memory in bytes; nil if unavailable. func memoryUsage(id: String) async -> UInt64? + /// Best-effort cumulative CPU time in microseconds (for delta-based CPU%); nil if unavailable. + func cpuUsage(id: String) async -> UInt64? } /// Orchestrates compose stacks by shelling out to the `container-compose` CLI. diff --git a/ConsaiCore/Sources/ConsaiCore/Engines/SDKContainerEngine.swift b/ConsaiCore/Sources/ConsaiCore/Engines/SDKContainerEngine.swift index 552aaa4..18ac564 100644 --- a/ConsaiCore/Sources/ConsaiCore/Engines/SDKContainerEngine.swift +++ b/ConsaiCore/Sources/ConsaiCore/Engines/SDKContainerEngine.swift @@ -61,6 +61,10 @@ public struct SDKContainerEngine: ContainerEngine { try? await ContainerClient().stats(id: id).memoryUsageBytes } + public func cpuUsage(id: String) async -> UInt64? { + try? await ContainerClient().stats(id: id).cpuUsageUsec + } + // MARK: - Mapping (SDK → Consai) /// In this SDK the container id *is* its display name (`configuration.id`). diff --git a/ConsaiCore/Sources/ConsaiCore/Engines/VitalsSampler.swift b/ConsaiCore/Sources/ConsaiCore/Engines/VitalsSampler.swift new file mode 100644 index 0000000..7b14453 --- /dev/null +++ b/ConsaiCore/Sources/ConsaiCore/Engines/VitalsSampler.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Turns successive cumulative `cpuUsageUsec` readings into a CPU percentage. Pure, stateful +/// value type (no I/O) so the sampling logic lives in the domain layer and is unit-testable, +/// instead of as ad-hoc state inside the view model. +public struct VitalsSampler: Sendable { + private struct Sample { let usec: UInt64; let at: Date } + private var samples: [String: Sample] = [:] + + public init() {} + + /// Record a cumulative cpu-usec reading at `now`; returns CPU% versus the previous + /// reading for `id`, or nil on the first reading (no window yet). + public mutating func recordCPU(id: String, cumulativeUsec: UInt64, at now: Date) -> Double? { + defer { samples[id] = Sample(usec: cumulativeUsec, at: now) } + guard let previous = samples[id] else { return nil } + return cpuPercent( + previousUsec: previous.usec, + currentUsec: cumulativeUsec, + elapsedSeconds: now.timeIntervalSince(previous.at) + ) + } + + /// Drop samples for ids no longer present (e.g. stopped/removed containers). + public mutating func retain(ids: Set) { + samples = samples.filter { ids.contains($0.key) } + } +} diff --git a/ConsaiCore/Sources/ConsaiCore/Models/Models.swift b/ConsaiCore/Sources/ConsaiCore/Models/Models.swift index 98da9a1..9318748 100644 --- a/ConsaiCore/Sources/ConsaiCore/Models/Models.swift +++ b/ConsaiCore/Sources/ConsaiCore/Models/Models.swift @@ -16,6 +16,8 @@ public struct Container: Identifiable, Hashable, Sendable { public var ipAddress: String? /// Live resident memory in bytes (best-effort; nil until fetched / when stopped). public var memoryBytes: UInt64? + /// Live CPU percentage (total across cores; nil until two samples are available). + public var cpuPercent: Double? public var labels: [String: String] public init( @@ -25,6 +27,7 @@ public struct Container: Identifiable, Hashable, Sendable { status: ContainerStatus, ipAddress: String? = nil, memoryBytes: UInt64? = nil, + cpuPercent: Double? = nil, labels: [String: String] = [:] ) { self.id = id @@ -33,10 +36,20 @@ public struct Container: Identifiable, Hashable, Sendable { self.status = status self.ipAddress = ipAddress self.memoryBytes = memoryBytes + self.cpuPercent = cpuPercent self.labels = labels } } +/// CPU percentage from two cumulative `cpuUsageUsec` samples over an elapsed wall-clock +/// window. Total across cores (can exceed 100% on multi-core). Pure for testing. +public func cpuPercent(previousUsec: UInt64, currentUsec: UInt64, elapsedSeconds: Double) -> Double? { + guard elapsedSeconds > 0, currentUsec >= previousUsec else { return nil } + let deltaUsec = Double(currentUsec - previousUsec) + let windowUsec = elapsedSeconds * 1_000_000 + return (deltaUsec / windowUsec) * 100 +} + /// Human-readable byte formatting for vitals ("178 MB", "1.0 GB"). public func formatBytes(_ bytes: UInt64) -> String { let mb = Double(bytes) / 1_048_576 diff --git a/ConsaiCore/Tests/ConsaiCoreTests/VitalsTests.swift b/ConsaiCore/Tests/ConsaiCoreTests/VitalsTests.swift new file mode 100644 index 0000000..68437d8 --- /dev/null +++ b/ConsaiCore/Tests/ConsaiCoreTests/VitalsTests.swift @@ -0,0 +1,34 @@ +import Testing +import Foundation +@testable import ConsaiCore + +@Suite struct VitalsTests { + + @Test func cpuPercentComputesFromDelta() { + #expect(cpuPercent(previousUsec: 0, currentUsec: 1_000_000, elapsedSeconds: 1) == 100) + #expect(cpuPercent(previousUsec: 0, currentUsec: 500_000, elapsedSeconds: 1) == 50) + #expect(cpuPercent(previousUsec: 0, currentUsec: 2_000_000, elapsedSeconds: 1) == 200) // multi-core + } + + @Test func cpuPercentGuardsInvalidInputs() { + #expect(cpuPercent(previousUsec: 0, currentUsec: 100, elapsedSeconds: 0) == nil) // no window + #expect(cpuPercent(previousUsec: 100, currentUsec: 0, elapsedSeconds: 1) == nil) // counter reset + } + + @Test func samplerFirstReadingIsNilThenComputes() { + var sampler = VitalsSampler() + #expect(sampler.recordCPU(id: "a", cumulativeUsec: 1_000_000, at: Date(timeIntervalSince1970: 0)) == nil) + #expect(sampler.recordCPU(id: "a", cumulativeUsec: 1_500_000, at: Date(timeIntervalSince1970: 1)) == 50) + } + + @Test func samplerRetainDropsOthers() { + var sampler = VitalsSampler() + let t0 = Date(timeIntervalSince1970: 0) + _ = sampler.recordCPU(id: "a", cumulativeUsec: 0, at: t0) + _ = sampler.recordCPU(id: "b", cumulativeUsec: 0, at: t0) + sampler.retain(ids: ["a"]) + let t1 = Date(timeIntervalSince1970: 1) + #expect(sampler.recordCPU(id: "b", cumulativeUsec: 1_000_000, at: t1) == nil) // dropped → first again + #expect(sampler.recordCPU(id: "a", cumulativeUsec: 1_000_000, at: t1) == 100) // retained → computes + } +} diff --git a/docs/screenshots/panel.png b/docs/screenshots/panel.png index 80a8867..dbec232 100644 Binary files a/docs/screenshots/panel.png and b/docs/screenshots/panel.png differ diff --git a/scripts/bundle.sh b/scripts/bundle.sh index 9f45866..f181f1a 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -7,14 +7,14 @@ CONFIG="${1:-release}" ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" -echo "▸ Building Consai ($CONFIG, arm64)…" +echo "Building Consai (${CONFIG}, arm64)..." swift build -c "$CONFIG" --arch arm64 BIN="$ROOT/.build/release/Consai" [ "$CONFIG" = debug ] && BIN="$ROOT/.build/debug/Consai" APP="$ROOT/Consai.app" -echo "▸ Assembling $APP…" +echo "Assembling ${APP} ..." rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$BIN" "$APP/Contents/MacOS/Consai" @@ -22,8 +22,8 @@ cp "$ROOT/App/Info.plist" "$APP/Contents/Info.plist" [ -f "$ROOT/App/Resources/AppIcon.icns" ] && cp "$ROOT/App/Resources/AppIcon.icns" "$APP/Contents/Resources/AppIcon.icns" # Ad-hoc sign so Gatekeeper/launch accepts it locally (real releases: Developer ID + notarize). -echo "▸ Ad-hoc signing…" +echo "Ad-hoc signing..." codesign --force --deep --sign - "$APP" -echo "✓ Built $APP" -echo " Run: open '$APP' (or: '$APP/Contents/MacOS/Consai')" +echo "Built ${APP}" +echo "Run: open '${APP}' (or '${APP}/Contents/MacOS/Consai')"