Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ final class AppState {
private var registry: ProjectRegistry
private var pollTask: Task<Void, Never>?
private var panelVisible = false
private var sampler = VitalsSampler()

var runningCount: Int { containers.filter { $0.status == .running }.count }
var isServiceRunning: Bool { serviceStatus == .running }
Expand Down Expand Up @@ -93,36 +94,50 @@ 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
}
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()
}
Expand Down
1 change: 1 addition & 0 deletions App/Shots/MockEngines.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 4 additions & 4 deletions App/Shots/ShotRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
28 changes: 19 additions & 9 deletions App/Views/ContainerRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
28 changes: 28 additions & 0 deletions ConsaiCore/Sources/ConsaiCore/Engines/VitalsSampler.swift
Original file line number Diff line number Diff line change
@@ -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<String>) {
samples = samples.filter { ids.contains($0.key) }
}
}
13 changes: 13 additions & 0 deletions ConsaiCore/Sources/ConsaiCore/Models/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions ConsaiCore/Tests/ConsaiCoreTests/VitalsTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Binary file modified docs/screenshots/panel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 5 additions & 5 deletions scripts/bundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,23 @@ 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"
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')"
Loading