//===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ArgumentParser import ContainerAPIClient import ContainerResource import ContainerizationError import ContainerizationExtras import Foundation extension Application { public struct ContainerStats: AsyncParsableCommand { public static let configuration = CommandConfiguration( commandName: "stats", abstract: "Display resource usage statistics for containers") @Argument(help: "Container ID or name (optional, shows all running containers if not specified)") var containers: [String] = [] @Option(name: .long, help: "Format of the output") var format: ListFormat = .table @Flag(name: .long, help: "Disable streaming stats and only pull the first result") var noStream = false @OptionGroup var global: Flags.Global public init() {} public func run() async throws { if format == .json || noStream { // Static mode - get stats once and exit try await runStatic() } else { // Streaming mode - continuously update like top // Enter alternate screen buffer and hide cursor print("\u{001B}[?1049h\u{001B}[?25l", terminator: "") fflush(stdout) defer { // Exit alternate screen buffer and show cursor again print("\u{001B}[?25h\u{001B}[?1049l", terminator: "") fflush(stdout) } try await runStreaming() } } private func runStatic() async throws { let allContainers = try await ClientContainer.list() let containersToShow: [ClientContainer] if containers.isEmpty { // No containers specified - show all running containers containersToShow = allContainers.filter { $0.status == .running } } else { // Validate all specified containers exist before proceeding var found: [ClientContainer] = [] for containerId in containers { guard let container = allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) else { throw ContainerizationError( .notFound, message: "no such container: \(containerId)" ) } found.append(container) } containersToShow = found } let statsData = try await collectStats(for: containersToShow) if format == .json { let jsonStats = statsData.map { $0.stats2 } let data = try JSONEncoder().encode(jsonStats) print(String(data: data, encoding: .utf8)!) return } printStatsTable(statsData) } private func runStreaming() async throws { // If containers were specified, validate they all exist upfront if !containers.isEmpty { let allContainers = try await ClientContainer.list() for containerId in containers { guard allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) != nil else { throw ContainerizationError( .notFound, message: "no such container: \(containerId)" ) } } } clearScreen() // Show header right away. printStatsTable([]) while true { do { let allContainers = try await ClientContainer.list() let containersToShow: [ClientContainer] if containers.isEmpty { containersToShow = allContainers.filter { $0.status == .running } } else { var found: [ClientContainer] = [] for containerId in containers { if let container = allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) { found.append(container) } } containersToShow = found } let statsData = try await collectStats(for: containersToShow) // Clear screen and reprint clearScreen() printStatsTable(statsData) if statsData.isEmpty { try await Task.sleep(for: .seconds(2)) } } catch { clearScreen() print("error collecting stats: \(error)") try await Task.sleep(for: .seconds(2)) } } } private struct StatsSnapshot { let container: ClientContainer let stats1: ContainerResource.ContainerStats let stats2: ContainerResource.ContainerStats } private func collectStats(for containers: [ClientContainer]) async throws -> [StatsSnapshot] { var snapshots: [StatsSnapshot] = [] // First sample for container in containers { guard container.status == .running else { continue } do { let stats1 = try await container.stats() snapshots.append(StatsSnapshot(container: container, stats1: stats1, stats2: stats1)) } catch { // Skip containers that error out continue } } // Wait 2 seconds for CPU delta calculation if !snapshots.isEmpty { try await Task.sleep(for: .seconds(2)) // Second sample for i in 0.. Double { let cpuDelta = cpuUsageUsec2 > cpuUsageUsec1 ? cpuUsageUsec2 - cpuUsageUsec1 : 0 return (Double(cpuDelta) / Double(timeDeltaUsec)) * 100.0 } static func formatBytes(_ bytes: UInt64) -> String { let kib = 1024.0 let mib = kib * 1024.0 let gib = mib * 1024.0 let value = Double(bytes) if value >= gib { return String(format: "%.2f GiB", value / gib) } else if value >= mib { return String(format: "%.2f MiB", value / mib) } else { return String(format: "%.2f KiB", value / kib) } } private func printStatsTable(_ statsData: [StatsSnapshot]) { let header = [["Container ID", "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"]] var rows = header for snapshot in statsData { let stats1 = snapshot.stats1 let stats2 = snapshot.stats2 let cpuPercent = Self.calculateCPUPercent( cpuUsageUsec1: stats1.cpuUsageUsec, cpuUsageUsec2: stats2.cpuUsageUsec, timeDeltaUsec: 2_000_000 // 2 seconds in microseconds ) let cpuStr = String(format: "%.2f%%", cpuPercent) let memUsageStr = "\(Self.formatBytes(stats2.memoryUsageBytes)) / \(Self.formatBytes(stats2.memoryLimitBytes))" let netStr = "\(Self.formatBytes(stats2.networkRxBytes)) / \(Self.formatBytes(stats2.networkTxBytes))" let blockStr = "\(Self.formatBytes(stats2.blockReadBytes)) / \(Self.formatBytes(stats2.blockWriteBytes))" let pidsStr = "\(stats2.numProcesses)" rows.append([snapshot.container.id, cpuStr, memUsageStr, netStr, blockStr, pidsStr]) } // Always print header, even if no containers let formatter = TableOutput(rows: rows) print(formatter.format()) } private func clearScreen() { // Move cursor to home position and clear from cursor to end of screen print("\u{001B}[H\u{001B}[J", terminator: "") fflush(stdout) } } }