Files
applecontainercompose/Sources/MonitorCLI.swift
T
2026-11-09 08:20:57 +00:00

132 lines
11 KiB
Swift

import Foundation
struct MonitorCLI {
static func run(_ arguments: [String]) async throws {
var args = arguments, binary = ContainerService.defaultBinary, demo = false, project = ""
while let first = args.first, first.hasPrefix("--") {
args.removeFirst()
switch first {
case "--help": print(help); return
case "--version": print("container-compose monitor Swift 1.1.0"); return
case "--demo": demo = true
case "--container", "--project": guard !args.isEmpty else { throw ServiceError.message("\(first) needs a value") }; let value = args.removeFirst(); if first == "--container" { binary = value } else { project = value }
default: throw ServiceError.message("Unknown option \(first). Use --help.")
}
}
let command = args.isEmpty ? "help" : args.removeFirst()
let service = ContainerService(binary: binary, demo: demo)
var native: [String]
switch command {
case "help": print(help); return
case "app":
let executable = URL(fileURLWithPath: CommandLine.arguments[0]).standardizedFileURL
let adjacent = executable.deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Container Compose.app")
let embedded = executable.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
let target = embedded.pathExtension == "app" ? embedded.path : FileManager.default.fileExists(atPath: adjacent.path) ? adjacent.path : "Container Compose"
let p = Process(); p.executableURL = URL(fileURLWithPath: "/usr/bin/open"); p.arguments = ["-a", target] + (demo ? ["--args", "--demo"] : []); try p.run(); p.waitUntilExit(); if p.terminationStatus != 0 { throw ServiceError.message("Install Container Compose.app in Applications, or launch it directly.") }; return
case "watch", "monitor": try await watch(args, service: service); return
case "volume", "network", "image":
guard let verb = args.first, ["list", "inspect", "create", "delete", "prune", "pull"].contains(verb) else { throw ServiceError.message("Use container-compose monitor \(command) list|inspect|create|delete|prune [native arguments]") }
if command == "volume", verb == "list", args.contains("--all-mounts") { try await printVolumeInventory(service); return }
if verb == "prune" { guard args.contains("--yes") else { throw ServiceError.message("Resource pruning requires --yes") }; args.removeAll { $0 == "--yes" } }
if verb == "list", args.count == 1 { args += ["--format", "json"] }
native = [command] + args
case "ps": native = ["list", "--all", "--format", "json"] + args
case "volumes" where args.contains("--all-mounts"): try await printVolumeInventory(service); return
case "images", "networks", "volumes": native = [String(command.dropLast()), "list", "--format", "json"] + args
case "projects":
let resources = try await service.list(.projects, project: project).filter { !$0.project.isEmpty && (project.isEmpty || $0.project == project) }
let rows = resources.map { ["id": $0.id, "project": $0.project, "service": $0.service, "status": $0.state] }; print(ContainerService.pretty(rows)); return
case "logs", "inspect", "start", "stop", "delete", "kill": guard !args.isEmpty else { throw ServiceError.message("\(command) needs a target") }; native = [command] + args
case "stats" where args.contains("--watch"): args.removeAll { $0 == "--watch" }; try await watch(args, service: service); return
case "stats": native = ["stats", "--no-stream", "--format", "json"] + args
case "restart":
guard args.count == 1, let id = args.first, !id.isEmpty, !id.hasPrefix("-") else { throw ServiceError.message("restart requires exactly one container ID") }
let resources = try await service.list(.containers)
guard let resource = resources.first(where: { $0.id == id }) else { throw ServiceError.message("Container not found: \(id)") }
print(try await service.action(kind: .containers, action: "restart", resource: resource)); return
case "prune":
guard args.contains("--yes") else { throw ServiceError.message("Prune requires --yes. It affects ALL unused resources of the chosen type, regardless of --project.") }
let kinds = args.filter { $0 != "--yes" }; guard kinds.count <= 1, let kind = ResourceKind(rawValue: kinds.first ?? "containers"), kind != .projects, kind != .monitor else { throw ServiceError.message("Usage: container-compose monitor prune [containers|images|networks|volumes] --yes") }; native = ContainerService.actionArguments(kind: kind, action: "prune", id: "")
default: throw ServiceError.message("Unknown command: \(command). Use --help.")
}
if command == "logs", !demo {
let p = Process(); p.executableURL = URL(fileURLWithPath: binary); p.arguments = native; p.standardInput = FileHandle.standardInput; p.standardOutput = FileHandle.standardOutput; p.standardError = FileHandle.standardError
try p.run(); p.waitUntilExit(); if p.terminationStatus != 0 { exit(p.terminationStatus) }; return
}
print(try await service.run(native))
}
static func printVolumeInventory(_ service: ContainerService) async throws {
let volumes = try await service.list(.volumes), containers = try await service.list(.containers)
let rows = VolumeInventory.combine(volumes: volumes, containers: containers).map { r in ["id": r.id, "name": r.name, "type": r.storageKind, "capacity": r.capacity, "driver": r.driver, "format": r.format, "source": r.source, "created": r.created, "usedBy": r.usedBy, "inspectOnly": r.readOnlyStorage ? "true" : "false"] }
print(ContainerService.pretty(rows))
}
static func watch(_ arguments: [String], service: ContainerService) async throws {
var args = arguments, interval = 1.0, count: Int?, json = false, ids: [String] = []
while !args.isEmpty {
let option = args.removeFirst()
switch option {
case "--interval": guard !args.isEmpty, let value = Double(args.removeFirst()), value >= 0.25, value.isFinite else { throw ServiceError.message("--interval needs a finite number >= 0.25 seconds") }; interval = value
case "--count": guard !args.isEmpty, let value = Int(args.removeFirst()), value > 0 else { throw ServiceError.message("--count needs a positive number") }; count = value
case "--json": json = true
default: guard !option.hasPrefix("-") else { throw ServiceError.message("Unknown watch option: \(option)") }; ids.append(option)
}
}
var tracker = NetworkRateTracker(), history: [Double] = [], samples = 0
while !Task.isCancelled {
let start = Date()
var rows = try await service.list(.containers)
if !ids.isEmpty { rows = rows.filter { ids.contains($0.id) }; guard rows.count == Set(ids).count else { throw ServiceError.message("One or more requested containers were not found") } }
rows = try await service.withStats(rows)
let time = Date(), rates = tracker.sample(rows, at: time), cpu = rows.reduce(0) { $0 + ($1.cpuFraction ?? 0) * 100 }
history.append(cpu); history = Array(history.suffix(50)); samples += 1
if json {
let resourceRows: [[String: Any]] = rows.map { ["id": $0.id, "state": $0.state, "cpuPercent": $0.cpuFraction.map { $0 * 100 } as Any? ?? NSNull(), "memoryBytes": $0.memoryBytes as Any? ?? NSNull(), "networkRxBytes": $0.rxBytes as Any? ?? NSNull(), "networkTxBytes": $0.txBytes as Any? ?? NSNull()] }
let value: [String: Any] = ["timestamp": ISO8601DateFormatter().string(from: time), "containers": resourceRows, "networkRxBytesPerSecond": rates.rx, "networkTxBytesPerSecond": rates.tx, "demo": service.demo]
let data = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]); print(String(decoding: data, as: UTF8.self))
} else {
if isatty(STDOUT_FILENO) != 0 { print("\u{001B}[2J\u{001B}[H", terminator: "") }
print("container-compose monitor live monitor" + (service.demo ? " · DEMO READ-ONLY" : " · Apple Containers"))
print(time.formatted(date: .omitted, time: .standard) + " · " + String(rows.filter { $0.state == "running" }.count) + " running · CPU " + String(format: "%.2f%%",cpu))
print("Network ↓ " + ContainerService.bytes(rates.rx) + "/s ↑ " + ContainerService.bytes(rates.tx) + "/s")
let glyphs = Array("▁▂▃▄▅▆▇█"), peak = max(history.max() ?? 0, 1)
print("CPU " + history.map { String(glyphs[min(7, Int(max(0,$0) / peak * 7))]) }.joined())
print("NAME".padding(toLength: 32, withPad: " ", startingAt: 0) + "STATE CPU MEMORY NET RX / TX")
for r in rows { print(String(r.name.prefix(30)).padding(toLength: 32, withPad: " ", startingAt: 0) + r.state.padding(toLength: 12, withPad: " ", startingAt: 0) + r.cpu.padding(toLength: 13, withPad: " ", startingAt: 0) + r.memory.padding(toLength: 13, withPad: " ", startingAt: 0) + r.network) }
print("Ctrl+C to exit · native CPU samples take about 2s · no terminal input mode changes")
}
fflush(stdout)
if let count, samples >= count { return }
let remaining = interval - Date().timeIntervalSince(start)
if remaining > 0 { try await Task.sleep(for: .seconds(remaining)) }
}
}
static let help = """
container-compose monitor Swift 1.1.0 — Apple Container command-line companion
Usage: container-compose monitor [--container PATH] [--demo] [--project NAME] COMMAND [ARGS]
app Open the native macOS app
watch [--interval S] [--count N] [--json] [ID...]
Live terminal table + CPU sparkline; --json emits NDJSON
monitor Alias for watch
ps All containers as JSON
projects Recognized Compose members as JSON
volume list [--all-mounts] Named volumes; --all-mounts includes host mounts/references
volume inspect NAME... Volume details
volume create [-s SIZE] NAME
volume delete NAME... Delete named volumes
volume prune --yes Prune unreferenced named volumes
network|image SUBCOMMAND Native resource commands
images | networks | volumes Resource list as JSON
logs [-n N] [--follow] ID Logs; native streaming has no timeout
inspect ID... Container configuration as JSON
stats [ID...] JSON snapshot; --watch enables live monitoring
start ID... | stop ID... | delete ID... | kill ID...
restart ID Start if stopped, otherwise stop then start
prune [containers|images|networks|volumes] --yes
help | --help | --version
Global options precede COMMAND. Native options follow it.
Commands use Apple's container CLI; Docker is not required.
Non-streaming commands time out after 30s; captured output is capped at 4 MiB.
"""
}