mirror of
https://github.com/apple/container.git
synced 2026-07-12 20:47:04 +00:00
3366916e80
## Motivation and Context This PR consolidates duplicated list-output formatting across the CLI into shared rendering infrastructure. Currently, each list command has its own copy of the same json/quiet/table branching. This PR pulls that into shared rendering infrastructure in `ContainerCommands`: - `ListDisplayable` protocol for table + quiet output - `renderJSON`, `renderTable`, `renderList` as pure functions that return strings - `emit()` as the single stdout boundary (no-ops on empty strings to avoid blank-line regressions) - `JSONOptions` so all JSON encoding goes through one path, including volume inspect's pretty + ISO 8601 case JSON encoding remains separate from display formatting: each command still chooses its own JSON model, while `ListDisplayable` is used only for table and quiet output. `ImageList` remains the intentional exception for quiet mode so it can avoid unnecessary async work. This change also replaces inline `JSONEncoder` usage with `renderJSON`, removes the old `Codable+JSON.swift` helper, and moves `TableOutput` and `ListFormat` into `ContainerCommands`. It also adds unit tests for the shared rendering helpers and expands integration coverage for image, network, and registry list formatting. ## Testing - [x] Tested locally - [x] Added/updated tests - [ ] Added/updated docs
290 lines
11 KiB
Swift
290 lines
11 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// 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 ContainerizationOS
|
|
import Foundation
|
|
|
|
extension Application {
|
|
public struct ContainerStats: AsyncLoggableCommand {
|
|
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
|
|
public var logOptions: Flags.Logging
|
|
|
|
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)
|
|
}
|
|
|
|
let containerIds = containers
|
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
|
defer { group.cancelAll() }
|
|
group.addTask {
|
|
let handler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM])
|
|
for await _ in handler.signals {
|
|
throw CancellationError()
|
|
}
|
|
}
|
|
group.addTask { [containerIds] in
|
|
try await Self.runStreaming(containerIds: containerIds)
|
|
}
|
|
do {
|
|
try await group.next()
|
|
} catch is CancellationError {
|
|
// Normal exit on signal, defer will restore the terminal
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func runStatic() async throws {
|
|
let client = ContainerClient()
|
|
|
|
let containersToShow: [ContainerSnapshot]
|
|
if containers.isEmpty {
|
|
// No containers specified - show all running containers
|
|
containersToShow = try await client.list(filters: ContainerListFilters(status: .running))
|
|
} else {
|
|
// Fetch specified containers by ID
|
|
containersToShow = try await client.list(filters: ContainerListFilters(ids: containers))
|
|
// Validate all specified containers were found
|
|
for containerId in containers {
|
|
guard containersToShow.contains(where: { $0.id == containerId }) else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "no such container: \(containerId)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
let statsData = try await Self.collectStats(client: client, for: containersToShow)
|
|
|
|
if format == .json {
|
|
let jsonStats = statsData.map { $0.stats2 }
|
|
try Output.emit(Output.renderJSON(jsonStats))
|
|
return
|
|
}
|
|
|
|
Self.printStatsTable(statsData)
|
|
}
|
|
|
|
private static func runStreaming(containerIds: [String]) async throws {
|
|
let client = ContainerClient()
|
|
|
|
// If containers were specified, validate they all exist upfront
|
|
if !containerIds.isEmpty {
|
|
let specifiedContainers = try await client.list(filters: ContainerListFilters(ids: containerIds))
|
|
for containerId in containerIds {
|
|
guard specifiedContainers.contains(where: { $0.id == containerId }) else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "no such container: \(containerId)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
clearScreen()
|
|
// Show header right away.
|
|
printStatsTable([])
|
|
|
|
while true {
|
|
do {
|
|
let containersToShow: [ContainerSnapshot]
|
|
if containerIds.isEmpty {
|
|
containersToShow = try await client.list(filters: ContainerListFilters(status: .running))
|
|
} else {
|
|
containersToShow = try await client.list(filters: ContainerListFilters(ids: containerIds))
|
|
}
|
|
|
|
let statsData = try await collectStats(client: client, 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: ContainerSnapshot
|
|
let stats1: ContainerResource.ContainerStats
|
|
let stats2: ContainerResource.ContainerStats
|
|
}
|
|
|
|
private static func collectStats(client: ContainerClient, for containers: [ContainerSnapshot]) async throws -> [StatsSnapshot] {
|
|
var snapshots: [StatsSnapshot] = []
|
|
|
|
// First sample
|
|
for container in containers {
|
|
guard container.status == .running else { continue }
|
|
do {
|
|
let stats1 = try await client.stats(id: container.id)
|
|
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..<snapshots.count {
|
|
do {
|
|
let stats2 = try await client.stats(id: snapshots[i].container.id)
|
|
snapshots[i] = StatsSnapshot(
|
|
container: snapshots[i].container,
|
|
stats1: snapshots[i].stats1,
|
|
stats2: stats2
|
|
)
|
|
} catch {
|
|
// Keep the original stats if second sample fails
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
return snapshots
|
|
}
|
|
|
|
/// Calculate CPU percentage from two stat snapshots
|
|
/// - Parameters:
|
|
/// - cpuUsageUsec1: CPU usage in microseconds from first sample
|
|
/// - cpuUsageUsec2: CPU usage in microseconds from second sample
|
|
/// - timeDeltaUsec: Time delta between samples in microseconds
|
|
/// - Returns: CPU percentage where 100% = one fully utilized core
|
|
static func calculateCPUPercent(
|
|
cpuUsage1: Duration,
|
|
cpuUsage2: Duration,
|
|
timeInterval: Duration
|
|
) -> Double {
|
|
let cpuDelta =
|
|
cpuUsage2 > cpuUsage1
|
|
? cpuUsage2 - cpuUsage1
|
|
: .seconds(0)
|
|
return (cpuDelta / timeInterval) * 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 static func printStatsTable(_ statsData: [StatsSnapshot]) {
|
|
let headerRow = ["Container ID", "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"]
|
|
let notAvailable = "--"
|
|
var rows = [headerRow]
|
|
|
|
for snapshot in statsData {
|
|
var row = [snapshot.container.id]
|
|
let stats1 = snapshot.stats1
|
|
let stats2 = snapshot.stats2
|
|
|
|
if let cpuUsageUsec1 = stats1.cpuUsageUsec, let cpuUsageUsec2 = stats2.cpuUsageUsec {
|
|
let cpuPercent = Self.calculateCPUPercent(
|
|
cpuUsage1: .microseconds(cpuUsageUsec1),
|
|
cpuUsage2: .microseconds(cpuUsageUsec2),
|
|
timeInterval: .seconds(2)
|
|
)
|
|
let cpuStr = String(format: "%.2f%%", cpuPercent)
|
|
row.append(cpuStr)
|
|
} else {
|
|
row.append(notAvailable)
|
|
}
|
|
|
|
let memUsageStr = stats2.memoryUsageBytes.map { Self.formatBytes($0) } ?? notAvailable
|
|
let memLimitStr = stats2.memoryLimitBytes.map { Self.formatBytes($0) } ?? notAvailable
|
|
row.append("\(memUsageStr) / \(memLimitStr)")
|
|
|
|
let netRxStr = stats2.networkRxBytes.map { Self.formatBytes($0) } ?? notAvailable
|
|
let netTxStr = stats2.networkTxBytes.map { Self.formatBytes($0) } ?? notAvailable
|
|
row.append("\(netRxStr) / \(netTxStr)")
|
|
|
|
let blkReadStr = stats2.blockReadBytes.map { Self.formatBytes($0) } ?? notAvailable
|
|
let blkWriteStr = stats2.blockWriteBytes.map { Self.formatBytes($0) } ?? notAvailable
|
|
row.append("\(blkReadStr) / \(blkWriteStr)")
|
|
|
|
let pidsStr = stats2.numProcesses.map { "\($0)" } ?? notAvailable
|
|
row.append(pidsStr)
|
|
|
|
rows.append(row)
|
|
}
|
|
|
|
// Always print header, even if no containers
|
|
let formatter = TableOutput(rows: rows)
|
|
print(formatter.format())
|
|
}
|
|
|
|
private static 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)
|
|
}
|
|
}
|
|
}
|