new features

This commit is contained in:
Mark Gregory
2026-11-09 08:20:57 +00:00
parent ecfa74a35f
commit 965ae923c2
27 changed files with 1474 additions and 168 deletions
+24 -4
View File
@@ -1,10 +1,30 @@
import SwiftUI
@main
struct AppleContainerComposeApp: App {
@StateObject private var model = ComposeModel()
struct ContainerComposeApp: App {
@StateObject private var compose = ComposeModel()
@StateObject private var monitor = Monitor()
@State private var tab = "compose"
var body: some Scene {
WindowGroup("Apple Container Compose") { ContentView().environmentObject(model) }
.defaultSize(width: 1050, height: 720)
WindowGroup("Container Compose") {
TabView(selection: $tab) {
ContentView().environmentObject(compose).disabled(monitor.demo || monitor.acting)
.tabItem { Label("Compose", systemImage: "square.stack.3d.up") }.tag("compose")
Workspace(model: monitor).disabled(compose.busy)
.tabItem { Label("Monitor & Resources", systemImage: "waveform.path.ecg") }.tag("monitor")
}
.frame(minWidth: 1050, minHeight: 720)
.onChange(of: compose.projectName) { _, name in monitor.project = name }
}.defaultSize(width: 1250, height: 850)
.commands {
CommandGroup(after: .appInfo) { Button("Save Command-Line Tool…") { monitor.exportCLI() } }
CommandMenu("Containers") {
Button("Refresh") { Task { await monitor.refresh() } }.keyboardShortcut("r")
Button("Inspect") { monitor.mode = "Inspect" }.keyboardShortcut("i")
Button("Logs") { monitor.mode = "Logs" }.keyboardShortcut("l")
Button("Stats") { monitor.mode = "Stats" }.keyboardShortcut("s")
}
}
Settings { Preferences(model: monitor) }
}
}
+27 -7
View File
@@ -2,17 +2,23 @@ import Foundation
@main
struct AppleComposeCLI {
static func main() {
do { try execute() }
catch { fputs("accompose: \(error)\n", stderr); exit(1) }
static func main() async {
do {
let args = Array(CommandLine.arguments.dropFirst())
if args.first == "monitor" { try await MonitorCLI.run(Array(args.dropFirst())); return }
try execute()
}
catch { fputs("container-compose: \(error)\n", stderr); exit(1) }
}
static func execute() throws {
var arguments = Array(CommandLine.arguments.dropFirst())
let help = """
accompose — Apple Container Compose CLI
Usage: accompose [-f FILE] [-p PROJECT] COMMAND [OPTIONS]
container-compose — Container Compose CLI
Usage: container-compose [-f FILE] [-p PROJECT] COMMAND [OPTIONS]
Commands:
monitor [COMMAND] acdry resource manager; monitor --help for commands
monitor watch Live CPU, memory and network terminal dashboard
init Create a starter compose.yaml
doctor Check Apple Container runtime
config Validate Compose file and display configuration
@@ -36,6 +42,7 @@ struct AppleComposeCLI {
-f, --file FILE Compose file path
-p, --project-name N Override project name
-v, -vv, -vvv Increase diagnostics (independent of detach)
--version Display CLI version
-h, --help Display help
Supported: basic images, Dockerfile builds, ports, directory binds,
@@ -43,6 +50,7 @@ struct AppleComposeCLI {
Other service keys are rejected instead of silently ignored.
This is NOT a complete Docker Compose implementation.
"""
if arguments == ["--version"] { print("container-compose 0.2.0 (Compose + acdry native monitor)"); return }
if arguments.contains("--help") || arguments.contains("-h") || arguments.isEmpty {
print(help)
return
@@ -66,7 +74,7 @@ struct AppleComposeCLI {
}
guard let command = arguments.first else { throw ComposeError.message("Missing command") }
if command == "doctor" && file == nil && (try? ComposeProject.discover()) == nil {
print("Apple Container Compose diagnostics")
print("Container Compose diagnostics")
guard let binary = Shell.executable("container") else { throw ComposeError.message("Apple container CLI not found. Install from https://github.com/apple/container/releases") }
print("CLI: \(binary)")
print(try Shell.checked("container", ["--version"]))
@@ -76,7 +84,7 @@ struct AppleComposeCLI {
return
}
if command == "import" {
guard arguments.count >= 2 else { throw ComposeError.message("Usage: accompose import PATH") }
guard arguments.count >= 2 else { throw ComposeError.message("Usage: container-compose import PATH") }
let url = URL(fileURLWithPath: arguments[1]).standardizedFileURL
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) else { throw ComposeError.message("Path not found: \(url.path)") }
@@ -97,6 +105,18 @@ struct AppleComposeCLI {
print("Created \(target.path)"); return
}
let commandArgs = Array(arguments.dropFirst())
let allowed: [String: Set<String>] = [
"up": ["-d", "--detach", "--build"], "down": ["--volumes"],
"clean": ["--all", "--project", "--dry-run"], "prune": ["--images", "--dry-run"],
"config": [], "ps": [], "pull": [], "build": [], "logs": [],
"start": [], "stop": [], "restart": [], "doctor": []
]
if let options = allowed[command], let unknown = commandArgs.first(where: { $0.hasPrefix("-") && !options.contains($0) }) {
throw ComposeError.message("Unsupported \(command) option: \(unknown)")
}
if ["config", "ps", "pull", "build", "down", "doctor", "prune"].contains(command), commandArgs.contains(where: { !$0.hasPrefix("-") }) {
throw ComposeError.message("Unexpected argument for \(command)")
}
if command == "up" {
let supported: Set<String> = ["-d", "--detach", "--build"]
if let unknown = commandArgs.first(where: { !supported.contains($0) }) {
+97 -9
View File
@@ -33,6 +33,7 @@ public struct ComposeService: Identifiable {
public var image: String
public var buildContext: String?
public var dockerfile: String?
public var buildArguments: [String]
public var ports: [String]
public var volumes: [String]
public var environment: [String]
@@ -55,6 +56,10 @@ public struct CommandResult {
public enum Shell {
public static func executable(_ name: String) -> String? {
if name == "container", Bundle.main.bundleURL.pathExtension == "app",
let chosen = UserDefaults.standard.string(forKey: "containerBinary"), !chosen.isEmpty {
return FileManager.default.isExecutableFile(atPath: chosen) ? chosen : nil
}
let paths = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init) + ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
return paths.map { ($0 as NSString).appendingPathComponent(name) }.first { FileManager.default.isExecutableFile(atPath: $0) }
}
@@ -96,7 +101,7 @@ public struct ComposeProject {
let url = URL(fileURLWithPath: manager.currentDirectoryPath).appendingPathComponent(name)
if manager.fileExists(atPath: url.path) { return url }
}
throw ComposeError.message("No compose.yaml found. Use -f FILE or run accompose init.")
throw ComposeError.message("No compose.yaml found. Use -f FILE or run container-compose init.")
}
public init(file: URL, overrideName: String? = nil) throws {
self.file = file
@@ -111,9 +116,39 @@ public struct ComposeProject {
if parts.count == 2 && vars[parts[0]] == nil { vars[parts[0]] = parts[1].trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) }
}
let expanded = try Self.interpolate(source, vars: vars)
guard let root = try Yams.load(yaml: expanded) as? [String: Any], let rawServices = root["services"] as? [String: Any], !rawServices.isEmpty else { throw ComposeError.message("Compose file must contain a nonempty services mapping") }
guard !source.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ComposeError.message("Compose file is empty: \(file.path). Save your YAML to this file or select another file with -f.")
}
let loaded: Any?
do { loaded = try Yams.load(yaml: expanded) }
catch { throw ComposeError.message("Invalid YAML in \(file.path): \(error)") }
guard let root = loaded as? [String: Any] else {
throw ComposeError.message("Compose file must have a mapping at its root: \(file.path)")
}
guard let servicesValue = root["services"] else {
throw ComposeError.message("Missing top-level services mapping in \(file.path). Top-level keys: \(root.keys.sorted().joined(separator: ", ")). Check the filename and indentation.")
}
guard let rawServices = servicesValue as? [String: Any], !rawServices.isEmpty else {
throw ComposeError.message("The services value must be a nonempty mapping in \(file.path). Check YAML indentation.")
}
let unknownRoot = Set(root.keys).subtracting(["name", "version", "services", "networks", "volumes"]).filter { !$0.hasPrefix("x-") }
guard unknownRoot.isEmpty else { throw ComposeError.message("Unsupported Compose fields: \(unknownRoot.sorted().joined(separator: ", "))") }
for kind in ["networks", "volumes"] {
if let value = root[kind] {
guard let entries = value as? [String: Any] else { throw ComposeError.message("\(kind) must be a mapping") }
for (name, options) in entries {
let mapping = options as? [String: Any]
let defaultDriver = kind == "networks" ? "bridge" : "local"
let simpleDefault = mapping.map { Set($0.keys).isSubset(of: ["driver"]) && ($0["driver"] == nil || $0["driver"] as? String == defaultDriver) } ?? false
guard options is NSNull || simpleDefault else {
throw ComposeError.message("Unsupported \(kind).\(name) options (external, custom driver, name and IPAM semantics are not implemented)")
}
}
}
}
let requestedName = overrideName ?? (root["name"] as? String) ?? file.deletingLastPathComponent().lastPathComponent
self.name = Self.safeName(requestedName)
guard !self.name.isEmpty else { throw ComposeError.message("Project name must contain an ASCII letter or digit") }
self.networks = (root["networks"] as? [String: Any]).map { Array($0.keys).sorted() } ?? []
self.volumes = (root["volumes"] as? [String: Any]).map { Array($0.keys).sorted() } ?? []
let resolvedProjectName = self.name
@@ -124,9 +159,50 @@ public struct ComposeProject {
if !ignored.isEmpty {
throw ComposeError.message("Service \(key) contains options not yet supported by Apple Container: \(ignored.sorted().joined(separator: ", ")). Refusing to silently ignore them.")
}
for field in ["ports", "volumes"] {
if let value = raw[field] {
guard let list = value as? [Any], list.allSatisfy({ $0 is String || (field == "ports" && $0 is Int) }) else {
throw ComposeError.message("Service \(key) \(field) supports short scalar syntax only; long syntax is not implemented")
}
}
}
if let networks = raw["networks"] as? [String: Any] {
guard networks.values.allSatisfy({ $0 is NSNull || ($0 as? [String: Any])?.isEmpty == true }) else {
throw ComposeError.message("Service \(key) network aliases and attachment options are not implemented")
}
}
if let dependencies = raw["depends_on"] as? [String: Any] {
for (_, value) in dependencies {
guard let options = value as? [String: Any], Set(options.keys).isSubset(of: ["condition"]), options["condition"] as? String == "service_started" else {
throw ComposeError.message("Service \(key) supports depends_on service_started only; health/completion gating is not implemented")
}
}
}
let build = raw["build"]
let buildMap = build as? [String: Any]
if let buildMap {
let unknown = Set(buildMap.keys).subtracting(["context", "dockerfile", "args"])
guard unknown.isEmpty else { throw ComposeError.message("Unsupported build options for \(key): \(unknown.sorted().joined(separator: ", "))") }
}
let context = (build as? String) ?? (buildMap?["context"] as? String)
var buildArguments: [String] = []
if let value = buildMap?["args"] {
if let mapping = value as? [String: Any] {
for key in mapping.keys.sorted() {
let value = mapping[key]!
if value is NSNull {
if let inherited = vars[key] { buildArguments.append("\(key)=\(inherited)") }
} else if value is String || value is NSNumber {
buildArguments.append("\(key)=\(value)")
} else { throw ComposeError.message("Build argument \(key) for \(key) must be a scalar") }
}
} else if let list = value as? [String] {
for item in list {
if item.contains("=") { buildArguments.append(item) }
else if let inherited = vars[item] { buildArguments.append("\(item)=\(inherited)") }
}
} else { throw ComposeError.message("Service \(key) build.args must be a mapping or string list") }
}
let image = (raw["image"] as? String) ?? "\(resolvedProjectName)-\(key):latest"
guard context != nil || raw["image"] is String else { throw ComposeError.message("Service \(key) requires image or build") }
let deps: [String]
@@ -142,7 +218,7 @@ public struct ComposeProject {
if let arr = raw["command"] as? [String] { command = arr }
else if let str = raw["command"] as? String { command = ["sh", "-c", str] }
else { command = [] }
return ComposeService(id: key, image: image, buildContext: context, dockerfile: buildMap?["dockerfile"] as? String, ports: Self.strings(raw["ports"]), volumes: Self.strings(raw["volumes"]), environment: env, dependencies: deps, networks: networks, command: command, workingDirectory: raw["working_dir"] as? String, restart: raw["restart"] as? String)
return ComposeService(id: key, image: image, buildContext: context, dockerfile: buildMap?["dockerfile"] as? String, buildArguments: buildArguments, ports: Self.strings(raw["ports"]), volumes: Self.strings(raw["volumes"]), environment: env, dependencies: deps, networks: networks, command: command, workingDirectory: raw["working_dir"] as? String, restart: raw["restart"] as? String)
}
let ids = Set(services.map(\.id))
for service in services { for dep in service.dependencies where !ids.contains(dep) { throw ComposeError.message("Unknown dependency \(dep) for service \(service.id)") } }
@@ -205,6 +281,7 @@ public struct ComposeEngine {
private func build(_ service: ComposeService) throws {
guard let context = service.buildContext else { return }
var args = ["build", "-t", service.image]
for argument in service.buildArguments { args += ["--build-arg", argument] }
if let dockerfile = service.dockerfile { args += ["-f", path((context as NSString).appendingPathComponent(dockerfile))] }
args.append(path(context))
print(try run(args))
@@ -212,7 +289,7 @@ public struct ComposeEngine {
public func execute(_ action: String, args: [String] = [], attached: Bool = false, verbosity: Int = 0) throws -> String {
switch action {
case "doctor":
var lines = ["Apple Container Compose diagnostics", "Project: \(project.file.path)"]
var lines = ["Container Compose diagnostics", "Project: \(project.file.path)"]
guard let binary = Shell.executable("container") else { return (lines + ["FAIL: Apple container CLI not found. Install from https://github.com/apple/container/releases"]).joined(separator: "\n") }
lines.append("CLI: \(binary)")
let version = try Shell.run("container", ["--version"]); lines.append(version.output.trimmingCharacters(in: .whitespacesAndNewlines))
@@ -243,7 +320,7 @@ public struct ComposeEngine {
}
let rebuild = args.contains("--build")
if verbosity > 0 {
fputs("accompose: project=\(project.name) mode=\(attached ? "foreground" : "detached") verbosity=\(verbosity)\n", stderr)
fputs("container-compose: project=\(project.name) mode=\(attached ? "foreground" : "detached") verbosity=\(verbosity)\n", stderr)
}
if verbosity > 1 { fputs("Compose file: \(project.file.path)\n", stderr) }
if verbosity > 2 { fputs("Startup order: \(try project.orderedServices().map(\.id).joined(separator: ", "))\n", stderr) }
@@ -323,7 +400,7 @@ public struct ComposeEngine {
let all = args.contains("--all") || args.contains("--project")
let selected = args.first { !$0.hasPrefix("-") }
let targets = all ? project.services : project.services.filter { $0.id == selected || project.containerName($0) == selected }
guard !targets.isEmpty else { throw ComposeError.message("Select a service/container or use --all; see accompose clean --help") }
guard !targets.isEmpty else { throw ComposeError.message("Select a service/container or use --all; see container-compose clean --help") }
var output = ""
for service in targets {
let name = project.containerName(service)
@@ -336,7 +413,7 @@ public struct ComposeEngine {
return output
case "prune":
// Only safe, explicit image cleanup; no blind deletion of user data.
guard args.contains("--images") else { return "No resources deleted. Use accompose prune --images to request unused image pruning; named volumes are never pruned automatically.\n" }
guard args.contains("--images") else { return "No resources deleted. Use container-compose prune --images to request unused image pruning; named volumes are never pruned automatically.\n" }
if args.contains("--dry-run") { return "Dry run: inspect unused images with container image list; no changes made.\n" }
let result = try Shell.run("container", ["image", "prune"])
guard result.status == 0 else { throw ComposeError.message("Image prune unsupported or failed: \(result.output)") }
@@ -344,12 +421,16 @@ public struct ComposeEngine {
case "ps": return try run(["list", "--all"])
case "logs":
let selected = args.filter { !$0.hasPrefix("-") }
let unknown = Set(selected).subtracting(project.services.map(\.id))
guard unknown.isEmpty else { throw ComposeError.message("Unknown services: \(unknown.sorted().joined(separator: ", "))") }
let services = selected.isEmpty ? project.services : project.services.filter { selected.contains($0.id) }
var output = ""
for service in services { output += "=== \(service.id) ===\n" + (try run(["logs", project.containerName(service)])) }
return output
case "start", "stop", "restart":
let selected = args.filter { !$0.hasPrefix("-") }
let unknown = Set(selected).subtracting(project.services.map(\.id))
guard unknown.isEmpty else { throw ComposeError.message("Unknown services: \(unknown.sorted().joined(separator: ", "))") }
let services = selected.isEmpty ? project.services : project.services.filter { selected.contains($0.id) }
var output = ""
for service in services {
@@ -359,8 +440,15 @@ public struct ComposeEngine {
}
return output
case "exec":
guard let service = args.first, let match = project.services.first(where: { $0.id == service }), args.count > 1 else { throw ComposeError.message("Usage: accompose exec SERVICE COMMAND [ARGS]") }
return try run(["exec", project.containerName(match)] + Array(args.dropFirst()))
guard let service = args.first, let match = project.services.first(where: { $0.id == service }), args.count > 1 else { throw ComposeError.message("Usage: container-compose exec SERVICE COMMAND [ARGS]") }
guard let binary = Shell.executable("container") else { throw ComposeError.message("Missing container executable") }
let process = Process(); process.executableURL = URL(fileURLWithPath: binary)
process.currentDirectoryURL = project.directory
process.arguments = ["exec"] + (isatty(STDIN_FILENO) != 0 ? ["-i", "-t"] : ["-i"]) + [project.containerName(match)] + Array(args.dropFirst())
process.standardInput = FileHandle.standardInput; process.standardOutput = FileHandle.standardOutput; process.standardError = FileHandle.standardError
try process.run(); process.waitUntilExit()
guard process.terminationStatus == 0 else { throw ComposeError.message("exec failed (\(process.terminationStatus))") }
return ""
default: throw ComposeError.message("Unsupported command: \(action)")
}
}
+4 -1
View File
@@ -2,6 +2,7 @@ import SwiftUI
import AppKit
@MainActor final class ComposeModel: ObservableObject {
@Published var projectName = ""
@Published var projectURL: URL?
@Published var services: [ComposeService] = []
@Published var selectedService: String?
@@ -17,6 +18,7 @@ import AppKit
func load(_ url: URL) {
do {
let project = try ComposeProject(file: url)
projectName = project.name
projectURL = url; services = project.services
selectedService = services.first?.id
output += "Loaded \(url.path) (\(services.count) services)\n"
@@ -33,7 +35,8 @@ import AppKit
}
private func run(url: URL, action: String) {
guard !busy else { return }
busy = true; status = "Running \(action)…"; output += "\n$ acccompose \(action)\n"
guard !CommandLine.arguments.contains("--demo") else { output += "Demo mode is read-only. Relaunch normally to run Compose actions.\n"; return }
busy = true; status = "Running \(action)…"; output += "\n$ container-compose \(action)\n"
Task.detached {
let result: String
do { result = try ComposeEngine(ComposeProject(file: url)).execute(action) }
+1 -1
View File
@@ -27,7 +27,7 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 14) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Apple Container Compose").font(.title2.bold())
Text("Container Compose").font(.title2.bold())
Text(model.projectURL?.deletingLastPathComponent().path ?? "Open a Compose project to get started")
.font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
}
+131
View File
@@ -0,0 +1,131 @@
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.
"""
}
+154
View File
@@ -0,0 +1,154 @@
import Foundation
public enum ResourceKind: String, CaseIterable, Identifiable, Sendable {
case monitor, containers, images, networks, volumes, projects
public var id: String { rawValue }
public var title: String { rawValue.capitalized }
public var symbol: String { switch self { case .monitor: "waveform.path.ecg"; case .containers: "shippingbox"; case .images: "square.stack.3d.up"; case .networks: "network"; case .volumes: "externaldrive"; case .projects: "folder" } }
public var isContainer: Bool { self == .containers || self == .projects || self == .monitor }
public var prefix: [String] { isContainer ? [] : [String(rawValue.dropLast())] }
public var listArguments: [String] { isContainer ? ["list", "--all", "--format", "json"] : prefix + ["list", "--format", "json"] }
}
public struct Resource: Identifiable, Sendable, Hashable {
public var id: String
public var name: String
public var state: String
public var image: String
public var project: String
public var service: String
public var ports: String
public var raw: String
public var driver = "—"
public var format = "—"
public var source = "—"
public var created = "—"
public var capacity = "—"
public var storageKind = "Named volume"
public var usedBy = "—"
public var readOnlyStorage = false
public var cpuFraction: Double?
public var memoryBytes: Double?
public var rxBytes: Double?
public var txBytes: Double?
public var cpu = "—"
public var memory = "—"
public var network = "—"
public var disk = "—"
}
public enum ServiceError: LocalizedError {
case message(String)
public var errorDescription: String? { if case .message(let s) = self { return s }; return nil }
}
private final class Capture: @unchecked Sendable {
private let lock = NSLock()
private var data = Data()
func append(_ bytes: Data) { lock.lock(); defer { lock.unlock() }; let remaining = 4 * 1024 * 1024 - data.count; if remaining > 0 { data.append(bytes.prefix(remaining)) } }
var text: String { lock.lock(); defer { lock.unlock() }; return String(decoding: data, as: UTF8.self) }
}
public struct ContainerService: Sendable {
public var binary: String
public var demo: Bool
public init(binary: String = ContainerService.defaultBinary, demo: Bool = false) { self.binary = binary; self.demo = demo }
public static var defaultBinary: String {
let paths = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init) + ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]
return paths.map { $0 + "/container" }.first { FileManager.default.isExecutableFile(atPath: $0) } ?? "/opt/homebrew/bin/container"
}
public func run(_ args: [String], timeout: TimeInterval = 30) async throws -> String {
try await Task.detached { try execute(args, timeout: timeout) }.value
}
public func execute(_ args: [String], timeout: TimeInterval = 30) throws -> String {
if demo {
let verb = ["image", "network", "volume"].contains(args.first ?? "") ? args.dropFirst().first ?? "" : args.first ?? ""
guard ["list", "inspect", "logs", "stats", "exec"].contains(verb) else { throw ServiceError.message("Demo mode is read-only. Connect to the live runtime to control containers.") }
return try demoOutput(args)
}
guard FileManager.default.isExecutableFile(atPath: binary) else { throw ServiceError.message("Apple container CLI was not found at \(binary). Install it or choose its executable in Settings.") }
let process = Process(); process.executableURL = URL(fileURLWithPath: binary); process.arguments = args
let out = Pipe(), err = Pipe(); process.standardOutput = out; process.standardError = err
let stdout = Capture(), stderr = Capture(); let group = DispatchGroup()
let ended = DispatchSemaphore(value: 0); process.terminationHandler = { _ in ended.signal() }
try process.run()
for (handle, capture) in [(out.fileHandleForReading, stdout), (err.fileHandleForReading, stderr)] {
group.enter(); DispatchQueue.global().async { defer { group.leave() }; while true { let data = handle.availableData; if data.isEmpty { break }; capture.append(data) } }
}
let timedOut = ended.wait(timeout: .now() + timeout) == .timedOut
if timedOut { process.terminate(); if ended.wait(timeout: .now() + 2) == .timedOut { kill(process.processIdentifier, SIGKILL); process.waitUntilExit() } }
group.wait()
if timedOut { throw ServiceError.message("container \(args.joined(separator: " ")) timed out.") }
guard process.terminationStatus == 0 else { throw ServiceError.message("container \(args.joined(separator: " ")) failed:\n\(stderr.text)") }
return stdout.text
}
public static func parse(_ data: String, kind: ResourceKind, projectHint: String = "") throws -> [Resource] {
guard let bytes = data.data(using: .utf8), let rows = try JSONSerialization.jsonObject(with: bytes) as? [[String: Any]] else { throw ServiceError.message("Unexpected container JSON: expected a resource array.") }
return try rows.map { row in
let cfg = row["configuration"] as? [String: Any] ?? row
let status = row["status"] as? [String: Any] ?? [:]
let image = cfg["image"] as? [String: Any] ?? [:]
let id = kind.isContainer ? (cfg["id"] ?? row["id"]) : (cfg["name"] ?? row["reference"] ?? row["name"] ?? cfg["id"] ?? row["id"])
guard let id = id as? String, !id.isEmpty else { throw ServiceError.message("Unsupported \(kind.title) JSON: missing identifier.") }
let labels = cfg["labels"] as? [String: String] ?? [:]
var project = "", service = ""
for prefix in ["com.docker.compose.", "io.container-compose.", "com.container-compose.", "container-compose.", "io.accompose.", "com.accompose.", "accompose."] { if let p = labels[prefix + "project"] { project = p; service = labels[prefix + "service"] ?? ""; break } }
if project.isEmpty, !projectHint.isEmpty, id.hasPrefix(projectHint + "-"), id.hasSuffix("-1") { project = projectHint; service = String(id.dropFirst(projectHint.count + 1).dropLast(2)) }
var resource = Resource(id: id, name: id, state: status["state"] as? String ?? row["status"] as? String ?? "—", image: image["reference"] as? String ?? image["name"] as? String ?? (cfg["descriptor"] as? [String: Any])?["digest"] as? String ?? "—", project: project, service: service, ports: pretty(cfg["publishedPorts"] ?? []), raw: pretty(row))
if kind == .volumes {
resource.driver = cfg["driver"] as? String ?? "—"
resource.format = cfg["format"] as? String ?? "—"
resource.source = cfg["source"] as? String ?? "—"
resource.created = cfg["creationDate"] as? String ?? cfg["createdAt"] as? String ?? "—"
resource.capacity = Self.bytes(cfg["sizeInBytes"])
resource.storageKind = labels["com.apple.container.resource.anonymous"] == nil ? "Named volume" : "Anonymous volume"
}
return resource
}.sorted { $0.name < $1.name }
}
public func list(_ kind: ResourceKind, project: String = "") async throws -> [Resource] { try Self.parse(await run(kind.listArguments), kind: kind, projectHint: project) }
public func withStats(_ resources: [Resource]) async throws -> [Resource] {
guard resources.contains(where: { $0.state == "running" }) else { return resources }
let output = try await run(["stats", "--no-stream", "--format", "json"])
guard let rows = try JSONSerialization.jsonObject(with: Data(output.utf8)) as? [[String: Any]] else { throw ServiceError.message("Unsupported stats JSON") }
return resources.map { resource in var r = resource; if let s = rows.first(where: { $0["id"] as? String == r.id }) { if let cpu = s["cpuUtilization"] as? Double { r.cpuFraction = cpu; r.cpu = String(format: "%.2f%%", cpu * 100) }; r.memoryBytes = s["memoryUsageBytes"] as? Double; r.rxBytes = s["networkRxBytes"] as? Double; r.txBytes = s["networkTxBytes"] as? Double; r.disk = Self.bytes(s["blockReadBytes"]) + " / " + Self.bytes(s["blockWriteBytes"]); r.memory = Self.bytes(s["memoryUsageBytes"]); r.network = Self.bytes(s["networkRxBytes"]) + " / " + Self.bytes(s["networkTxBytes"]) }; return r }
}
public static func bytes(_ value: Any?) -> String { guard let n = value as? NSNumber else { return "—" }; return ByteCountFormatter.string(fromByteCount: n.int64Value, countStyle: .binary) }
public static func detailArguments(kind: ResourceKind, id: String, mode: String) -> [String] {
if kind.isContainer { switch mode { case "Logs": return ["logs", "-n", "200", id]; case "Stats": return ["stats", "--no-stream", "--format", "json", id]; case "Processes": return ["exec", id, "ps", "aux"]; default: break } }
return kind.prefix + ["inspect", id]
}
public static func actionArguments(kind: ResourceKind, action: String, id: String) -> [String] { kind.prefix + [action] + (action == "prune" ? [] : [id]) }
public func waitForState(id: String, expected: String, timeout: TimeInterval = 15) async throws -> Resource {
let deadline = Date().addingTimeInterval(timeout)
while true {
let output = try await run(["inspect", id], timeout: min(5, timeout))
if let resource = try Self.parse(output, kind: .containers).first(where: { $0.id == id }), resource.state == expected { return resource }
guard Date() < deadline else { throw ServiceError.message("The command returned, but \(id) did not reach \(expected) within \(Int(timeout)) seconds. Check Inspect for its current state.") }
try await Task.sleep(for: .milliseconds(250))
}
}
public func action(kind: ResourceKind, action: String, resource: Resource?) async throws -> String {
guard !demo else { throw ServiceError.message("Demo mode is read-only. Connect to the live runtime to control containers.") }
guard resource?.readOnlyStorage != true else { throw ServiceError.message("Host mounts are inspect-only and cannot be deleted as volumes.") }
if action == "restart" { guard let r = resource else { throw ServiceError.message("Select a container") }; if r.state == "running" { _ = try await run(["stop", r.id]) }; return try await run(["start", r.id]) }
guard action == "prune" || resource != nil else { throw ServiceError.message("Select a resource") }
return try await run(Self.actionArguments(kind: kind, action: action, id: resource?.id ?? ""))
}
public static func pretty(_ value: Any) -> String { guard JSONSerialization.isValidJSONObject(value), let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]), let s = String(data: data, encoding: .utf8) else { return String(describing: value) }; return s }
public static func formatted(_ string: String) -> String { guard let data = string.data(using: .utf8), let value = try? JSONSerialization.jsonObject(with: data) else { return string }; return pretty(value) }
private func demoOutput(_ args: [String]) throws -> String {
if args.first == "list" { return Self.demoContainers }
if args.prefix(2) == ["image", "list"] { return "[{\"configuration\":{\"name\":\"nginx:alpine\"}}]" }
if args.prefix(2) == ["network", "list"] { return "[{\"id\":\"default\",\"configuration\":{\"name\":\"default\"}}]" }
if args.prefix(2) == ["volume", "list"] { return "[{\"configuration\":{\"name\":\"demo-data\",\"driver\":\"local\",\"format\":\"ext4\",\"source\":\"/demo/demo-data.img\",\"creationDate\":\"2026-11-08T12:00:00Z\",\"sizeInBytes\":1073741824}}]" }
if args.first == "stats" { return Self.demoStats(at: Date()) }
if args.first == "logs" { return "12:00:01 Server ready on :80\n12:00:02 GET /health 200\nDemo mode · no runtime commands executed" }
if args.first == "inspect" { return Self.demoContainers }
return "Demo: \(args.joined(separator: " ")) (simulated; fixture state unchanged)"
}
public static func demoStats(at date: Date) -> String {
let t = date.timeIntervalSince1970, wave = sin(t / 13)
let row: [String: Any] = ["id": "demo-web-1", "cpuUtilization": 0.08 + 0.05 * wave, "memoryUsageBytes": (42 + 4 * wave) * 1_048_576, "networkRxBytes": t * 12 * 1024 - 8 * 1024 * 13 * cos(t / 13), "networkTxBytes": t * 7 * 1024 - 4 * 1024 * 13 * cos(t / 13)]
return pretty([row])
}
public static let demoContainers = """
[{"configuration":{"id":"demo-web-1","image":{"reference":"nginx:alpine"},"publishedPorts":[{"hostPort":8080,"containerPort":80}],"labels":{"io.container-compose.project":"demo","io.container-compose.service":"web"}},"status":{"state":"running"}},{"configuration":{"id":"demo-db-1","image":{"reference":"postgres:17"},"labels":{"io.container-compose.project":"demo","io.container-compose.service":"db"}},"status":{"state":"stopped"}}]
"""
}
@@ -0,0 +1,22 @@
import Foundation
/// Rates are computed per container so new or removed containers do not cause
/// spikes from their lifetime counters. Counter resets contribute zero.
public struct NetworkRateTracker: Sendable {
private struct Counter: Sendable { var date: Date; var rx: Double; var tx: Double }
private var counters: [String: Counter] = [:]
public init() {}
public mutating func sample(_ resources: [Resource], at date: Date) -> (rx: Double, tx: Double) {
var next: [String: Counter] = [:], rxRate = 0.0, txRate = 0.0
for resource in resources {
guard let rx = resource.rxBytes, let tx = resource.txBytes else { continue }
if let previous = counters[resource.id] {
let seconds = date.timeIntervalSince(previous.date)
if seconds > 0 { rxRate += max(0, rx - previous.rx) / seconds; txRate += max(0, tx - previous.tx) / seconds }
}
next[resource.id] = Counter(date: date, rx: rx, tx: tx)
}
counters = next
return (rxRate, txRate)
}
}
+35
View File
@@ -0,0 +1,35 @@
import Foundation
public enum VolumeInventory {
/// Managed volumes are enriched with configured references. Persistent host
/// mounts are displayed separately and can never be deleted as volumes.
public static func combine(volumes: [Resource], containers: [Resource], includeHostMounts: Bool = true) -> [Resource] {
var result = volumes
var uses: [String: Set<String>] = [:]
var host: [String: Resource] = [:]
for container in containers {
guard let data = container.raw.data(using: .utf8), let row = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let cfg = row["configuration"] as? [String: Any], let mounts = cfg["mounts"] as? [[String: Any]] else { continue }
for mount in mounts {
let type = mount["type"] as? [String: Any] ?? [:]
let source = mount["source"] as? String ?? ""
let destination = mount["destination"] as? String ?? ""
let reference = "\(container.name):\(destination)"
if let volume = type["volume"] as? [String: Any], let name = volume["name"] as? String { uses[name, default: []].insert(reference); continue }
if let volume = volumes.first(where: { $0.source == source && !source.isEmpty }) { uses[volume.id, default: []].insert(reference); continue }
guard includeHostMounts, !source.isEmpty, type["virtiofs"] != nil || type["block"] != nil else { continue }
let id = "host:" + source
var item = host[id] ?? Resource(id: id, name: URL(fileURLWithPath: source).lastPathComponent, state: "Referenced", image: "—", project: "", service: "", ports: "—", raw: ContainerService.pretty(mount))
item.source = source; item.storageKind = type["block"] == nil ? "Bind mount" : "Disk mount"; item.readOnlyStorage = true
item.driver = type["block"] == nil ? "virtiofs" : "block"; item.format = "host"; item.usedBy = ""
host[id] = item; uses[id, default: []].insert(reference)
}
}
result += host.values
for index in result.indices {
let references = uses[result[index].id] ?? []
result[index].usedBy = references.isEmpty ? "No container references" : references.sorted().joined(separator: ", ")
result[index].state = references.isEmpty ? "Unreferenced" : "Referenced"
}
return result.sorted { ($0.readOnlyStorage ? 1 : 0, $0.name) < ($1.readOnlyStorage ? 1 : 0, $1.name) }
}
}
+343
View File
@@ -0,0 +1,343 @@
import SwiftUI
import AppKit
import Charts
@MainActor final class Monitor: ObservableObject {
@Published var kind: ResourceKind = .monitor
@Published var resources: [Resource] = []
@Published var containerResources: [Resource] = []
@Published var lastSample: Date?
@Published var telemetryError = ""
@Published var sampling = false
@Published var history: [TelemetryPoint] = []
@Published var lastAction = ""
private var rateTracker = NetworkRateTracker()
@Published var telemetryAvailable = false
private var telemetrySource = ""
@Published var selection: String?
@Published var search = ""
@Published var mode = "Inspect"
@Published var detail = "Select a resource to inspect."
@Published var status = "Connecting…"
@Published var error: String?
@Published var busy = false
@Published var acting = false
@Published var showStopped = true
@Published var showHostMounts = true
@Published var showingConfirmation = false
@Published var pendingAction: String?
@Published var pendingResource: Resource?
@Published var pendingKind: ResourceKind = .containers
@Published var follow = true
@AppStorage("containerBinary") var binary = ContainerService.defaultBinary
@AppStorage("composeProject") var project = ""
@Published var demo = CommandLine.arguments.contains("--demo")
private var detailToken = UUID()
private var refreshToken = UUID()
private var mutationEpoch = 0
var service: ContainerService { ContainerService(binary: binary, demo: demo) }
var selected: Resource? { kind == .monitor ? nil : visible.first { $0.id == selection } }
var visible: [Resource] {
resources.filter { r in
(kind != .projects || (!r.project.isEmpty && (project.isEmpty || r.project == project))) &&
(showStopped || !kind.isContainer || r.state == "running") &&
(kind != .volumes || showHostMounts || !r.readOnlyStorage) &&
(search.isEmpty || (r.name + " " + r.image + " " + r.project + " " + r.source + " " + r.usedBy).localizedCaseInsensitiveContains(search))
}
}
func recordTelemetry(_ rows: [Resource], source: String) {
if source != telemetrySource { history = []; rateTracker = NetworkRateTracker(); telemetrySource = source }
let measured = rows.filter { $0.cpuFraction != nil || $0.memoryBytes != nil }
telemetryAvailable = !measured.isEmpty
guard !measured.isEmpty else { rateTracker = NetworkRateTracker(); return }
let now = Date(), cpu = measured.reduce(0) { $0 + ($1.cpuFraction ?? 0) * 100 }, memory = measured.reduce(0) { $0 + ($1.memoryBytes ?? 0) }
let rates = rateTracker.sample(measured, at: now)
if demo && history.isEmpty {
// Clearly labelled illustrative history makes the demo immediately explorable.
history = (0..<60).map { n in let date = now.addingTimeInterval(Double(n - 60) * 3); let wave = sin(date.timeIntervalSince1970 / 13); return TelemetryPoint(date: date, cpu: 8 + 5 * wave, memory: (42 + 4 * wave) * 1_048_576, rx: (12 + 8 * wave) * 1024, tx: (7 + 4 * wave) * 1024) }
}
history.append(TelemetryPoint(date: now, cpu: cpu, memory: memory, rx: rates.rx, tx: rates.tx))
history = history.filter { now.timeIntervalSince($0.date) <= 360 }.suffix(360).map { $0 }
}
func changeKind() { resources = []; selection = nil; detail = "Select a resource to inspect."; detailToken = UUID(); refreshToken = UUID(); busy = false }
func resetConnection() { changeKind(); containerResources = []; history = []; lastSample = nil; telemetryAvailable = false; telemetryError = ""; status = "Connecting…"; rateTracker = NetworkRateTracker() }
func connectLive() { demo = false; resetConnection() }
var connectionID: String { "\(binary)|\(demo)|\(project)" }
func sampleTelemetry() async {
let backend = service, connection = connectionID, epoch = mutationEpoch
sampling = true
defer { sampling = false }
do {
let listed = try await backend.list(.containers, project: project)
let measured = try await backend.withStats(listed)
guard !Task.isCancelled, connectionID == connection, mutationEpoch == epoch else { return }
containerResources = measured; lastSample = Date(); telemetryError = ""
recordTelemetry(measured, source: connection)
if kind.isContainer && !acting {
resources = measured
if !visible.contains(where: { $0.id == selection }) { selection = visible.first?.id }
}
if mode == "Stats", let selected { detail = "\(selected.name)\nCPU: \(selected.cpu)\nMemory: \(selected.memory)\nNetwork RX / TX (cumulative): \(selected.network)\nDisk read / write (cumulative): \(selected.disk)\nSample: \(Date.now.formatted(date: .omitted, time: .standard))" }
} catch {
guard !Task.isCancelled, connectionID == connection, mutationEpoch == epoch else { return }
telemetryError = error.localizedDescription; telemetryAvailable = false
}
}
func refresh() async {
guard !busy, !acting else { return }; busy = true
let token = UUID(); refreshToken = token
let requestedKind = kind, backend = service
defer { if refreshToken == token { busy = false } }
do {
var rows = try await backend.list(requestedKind, project: project)
guard !Task.isCancelled, refreshToken == token, kind == requestedKind else { return }
if requestedKind.isContainer {
rows = rows.map { row in
var result = row
if row.state == "running", let metrics = containerResources.first(where: { $0.id == row.id && $0.state == "running" }) { result.cpu = metrics.cpu; result.memory = metrics.memory; result.network = metrics.network; result.disk = metrics.disk }
return result
}
}
if requestedKind == .volumes {
let containers = try await backend.list(.containers, project: project)
guard !Task.isCancelled, refreshToken == token else { return }
rows = VolumeInventory.combine(volumes: rows, containers: containers)
}
resources = rows
if !visible.contains(where: { $0.id == selection }) { selection = visible.first?.id }
status = "Connected · \(rows.count) resources · \(rows.filter { $0.state == "running" }.count) running"
} catch { if refreshToken == token, !Task.isCancelled { status = "Unavailable: \(error.localizedDescription)"; resources = []; selection = nil; detail = "Check the Apple Container service and executable in Settings." } }
}
func loadDetail() async {
guard let resource = selected else { detail = "Select a resource to inspect."; return }
if kind == .volumes, resource.readOnlyStorage { detail = "\(resource.storageKind) · inspect only\nSource: \(resource.source)\nUsed by: \(resource.usedBy)\n\n\(resource.raw)"; return }
let token = UUID(); detailToken = token
let requestedMode = mode, requestedKind = kind, backend = service
do {
let result = try await backend.run(ContainerService.detailArguments(kind: requestedKind, id: resource.id, mode: requestedMode))
guard detailToken == token, !Task.isCancelled else { return }; detail = ContainerService.formatted(result)
} catch { if detailToken == token, !Task.isCancelled { detail = "Unavailable for this runtime or resource:\n\(error.localizedDescription)" } }
}
func request(_ action: String) { guard !acting else { return }; guard action == "prune" || selected?.readOnlyStorage != true else { error = "Host mounts are inspect-only. Manage their files on the host; they are not Apple named volumes."; return }; guard !demo else { error = "Demo mode is read-only. Use Connect to live runtime to control real containers."; return }; pendingResource = selected; pendingKind = kind; pendingAction = action; showingConfirmation = true }
func performPending() {
guard let action = pendingAction, !acting else { return }
let resource = pendingResource, requestedKind = pendingKind, backend = service
pendingAction = nil; acting = true; mutationEpoch += 1; refreshToken = UUID(); busy = false
lastAction = "\(action.capitalized) in progress · \(resource?.name ?? requestedKind.title)"
Task {
do {
let result = try await backend.action(kind: requestedKind, action: action, resource: resource)
if let resource, ["start", "stop", "restart"].contains(action) {
let expected = action == "stop" ? "stopped" : "running"
let verified = try await backend.waitForState(id: resource.id, expected: expected)
if let index = resources.firstIndex(where: { $0.id == verified.id }) { resources[index] = verified }
lastAction = "\(resource.name) is \(expected) · verified by runtime"
} else { lastAction = "Completed \(action) · \(resource?.name ?? requestedKind.title)" }
detail = result; status = lastAction
} catch { self.error = error.localizedDescription; lastAction = "\(action.capitalized) failed · \(error.localizedDescription)" }
acting = false; await refresh(); if mode != "Stats" { await loadDetail() }
}
}
func exportCLI() {
guard let source = Bundle.main.url(forAuxiliaryExecutable: "container-compose") else { error = "Build the release app bundle to include the command-line tool."; return }
let panel = NSSavePanel(); panel.nameFieldStringValue = "container-compose"; panel.title = "Save the container-compose command-line tool"
guard panel.runModal() == .OK, let url = panel.url else { return }
do { if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) }; try FileManager.default.copyItem(at: source, to: url); try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) } catch { self.error = error.localizedDescription }
}
}
struct Workspace: View {
@ObservedObject var model: Monitor
@State private var sidebarSelection: ResourceKind? = .monitor
var body: some View {
NavigationSplitView {
VStack(alignment: .leading, spacing: 18) {
HStack(spacing: 10) { Image(systemName: "shippingbox.fill").font(.largeTitle).foregroundStyle(.orange); VStack(alignment: .leading) { Text("Container Compose").font(.title.bold()); Text("Apple Containers").foregroundStyle(.secondary).font(.caption) } }.padding(.horizontal)
List(ResourceKind.allCases, selection: $sidebarSelection) { kind in Label(kind.title, systemImage: kind.symbol).tag(kind) }
VStack(alignment: .leading, spacing: 8) { Label(model.demo ? "Demo workspace" : "Local runtime", systemImage: model.demo ? "play.rectangle" : "desktopcomputer"); Text(model.demo ? "Fixture data · actions are simulated" : "Apple container CLI").font(.caption).foregroundStyle(.secondary) }.padding()
}.padding(.top, 12).navigationSplitViewColumnWidth(min: 190, ideal: 210)
} detail: {
VStack(spacing: 0) {
if model.demo {
HStack { Label("Demo preview · container controls are disabled", systemImage: "eye"); Spacer(); Button("Connect to live runtime") { model.connectLive() } }.font(.callout).padding(12).background(Color.orange.opacity(0.12))
}
if !model.lastAction.isEmpty { HStack { if model.acting { ProgressView().controlSize(.small) }; Text(model.lastAction).textSelection(.enabled); Spacer() }.font(.caption).padding(10).background(Color.accentColor.opacity(0.08)) }
if model.kind == .monitor {
MonitorDashboard(model: model)
} else {
HStack { VStack(alignment: .leading, spacing: 4) { Text(model.kind.title).font(.title2.bold()); Text("\(model.visible.count) visible resources").font(.caption).foregroundStyle(.secondary) }; Spacer(); if model.busy { ProgressView().controlSize(.small) }; if model.kind.isContainer { Toggle("Show stopped", isOn: $model.showStopped).toggleStyle(.checkbox) }; if model.kind == .volumes { Toggle("Include host mounts", isOn: $model.showHostMounts).toggleStyle(.checkbox) } }.padding()
VSplitView {
resourceTable.frame(minHeight: 140)
VStack(alignment: .leading, spacing: 12) {
if let r = model.selected {
HStack { Circle().fill(r.state == "running" ? Color.green : Color.secondary).frame(width: 7, height: 7); Text(r.name).font(.headline).textSelection(.enabled); Spacer(); Text(r.state).foregroundStyle(.secondary) }
Text(model.kind == .volumes ? "\(r.storageKind) · \(r.source) · \(r.usedBy)" : "\(r.image)\(r.project.isEmpty ? "" : " · " + r.project + " / " + r.service)").font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
}
HStack {
Picker("Details", selection: $model.mode) { Text("Inspect").tag("Inspect"); if model.kind.isContainer { Text("Logs").tag("Logs"); Text("Stats").tag("Stats"); Text("Processes").tag("Processes") } }.pickerStyle(.segmented).frame(maxWidth: 460)
Spacer(); if model.mode == "Logs" { Toggle("Follow", isOn: $model.follow).toggleStyle(.checkbox) }
Button { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(model.detail, forType: .string) } label: { Image(systemName: "doc.on.doc") }.help("Copy details")
}
ScrollViewReader { proxy in
ScrollView([.horizontal, .vertical]) { VStack(alignment: .leading) { Text(model.detail).font(.system(.caption, design: .monospaced)).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading); Color.clear.frame(height: 1).id("end") }.padding(12) }
.background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
.onChange(of: model.detail) { _, _ in if model.follow && model.mode == "Logs" { proxy.scrollTo("end", anchor: .bottom) } }
}
}.padding().frame(minHeight: 240)
}
}
Divider()
HStack { Circle().fill(model.status.hasPrefix("Connected") ? .green : .orange).frame(width: 6, height: 6); Text(model.status).font(.caption).lineLimit(2).textSelection(.enabled); Spacer(); TimelineView(.periodic(from: .now, by: 1)) { context in
if let sample = model.lastSample { let age = context.date.timeIntervalSince(sample); Text("Sample \(Int(max(0,age)))s ago").foregroundStyle(age > 8 ? Color.orange : Color.secondary).font(.caption).monospacedDigit() }
}; Text(model.sampling ? "Sampling…" : "Swift Edition").font(.caption).foregroundStyle(.secondary) }.padding(10)
}.background(Color(nsColor: .windowBackgroundColor))
.searchable(text: $model.search, prompt: "Filter names, images, projects")
.toolbar {
ToolbarItemGroup {
Button { Task { await model.refresh() } } label: { Label("Refresh", systemImage: "arrow.clockwise") }.disabled(model.busy || model.acting)
if model.kind.isContainer && model.kind != .monitor { Button { model.request("start") } label: { Label("Start", systemImage: "play.fill") }.disabled(model.selected == nil || model.acting || model.demo); Button { model.request("stop") } label: { Label("Stop", systemImage: "stop.fill") }.disabled(model.selected == nil || model.acting || model.demo) }
Menu { if model.kind.isContainer { Button("Restart") { model.request("restart") }; Button("Kill…", role: .destructive) { model.request("kill") } }; Button("Delete selected…", role: .destructive) { model.request("delete") } } label: { Label("Actions", systemImage: "ellipsis.circle") }.disabled(model.selected == nil || model.selected?.readOnlyStorage == true || model.acting || model.demo)
Button { model.request("prune") } label: { Label("Prune…", systemImage: "trash") }.disabled(model.acting || model.demo || model.kind == .monitor)
}
}
}
.onChange(of: model.kind) { _, kind in if sidebarSelection != kind { sidebarSelection = kind } }
.onChange(of: sidebarSelection) { _, kind in if let kind { model.kind = kind; model.mode = "Inspect"; model.changeKind() } }
.onChange(of: model.binary) { _, _ in model.resetConnection() }
.onChange(of: model.demo) { _, _ in model.resetConnection() }
.onChange(of: model.project) { _, _ in model.resetConnection() }
.task(id: "\(model.kind.rawValue)|\(model.connectionID)") { while !Task.isCancelled { await model.refresh(); do { try await Task.sleep(for: .seconds(2)) } catch { break } } }
.task(id: model.connectionID) { while !Task.isCancelled { await model.sampleTelemetry(); do { try await Task.sleep(for: .milliseconds(model.demo ? 1000 : 100)) } catch { break } } }
.task(id: "\(model.selection ?? "")|\(model.mode)|\(model.connectionID)") {
if model.mode == "Stats" { return }
repeat { await model.loadDetail(); if model.mode == "Inspect" { break }; do { try await Task.sleep(for: .seconds(1)) } catch { break } } while !Task.isCancelled
}
.alert("Confirm \(model.pendingAction ?? "action")", isPresented: $model.showingConfirmation) {
Button("Cancel", role: .cancel) { model.pendingAction = nil }
Button("Confirm", role: .destructive) { model.performPending() }
} message: { Text(model.pendingAction == "prune" ? "This affects ALL unused \(model.pendingKind == .projects ? "containers" : model.pendingKind.rawValue), regardless of project or search filters. Container prune removes stopped containers; image prune removes dangling images." : "\(model.pendingAction?.capitalized ?? "Change") \(model.pendingResource?.name ?? "selected resource")?") }
.alert("Command failed", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { Button("OK") { model.error = nil } } message: { Text(model.error ?? "") }
}
@ViewBuilder private var resourceTable: some View {
if model.kind == .volumes {
Table(model.visible, selection: $model.selection) {
TableColumn("Name", value: \.name).width(min: 140, ideal: 190)
TableColumn("Type", value: \.storageKind).width(110)
TableColumn("Capacity", value: \.capacity).width(90)
TableColumn("Driver", value: \.driver).width(70)
TableColumn("Format", value: \.format).width(65)
TableColumn("Used by", value: \.usedBy).width(min: 150, ideal: 220)
TableColumn("Source", value: \.source).width(min: 160, ideal: 250)
TableColumn("Created", value: \.created).width(min: 140, ideal: 180)
}.overlay { if model.visible.isEmpty { ContentUnavailableView("No named volumes or host mounts", systemImage: "externaldrive", description: Text("This runtime currently reports no volumes. Use container-compose monitor volume create NAME, or mount a host directory in a container. Bind mounts are shown separately and are inspect-only.")) } }
} else {
Table(model.visible, selection: $model.selection) {
TableColumn("Name", value: \.name).width(min: 150, ideal: 220)
TableColumn("State") { r in Text(r.state).foregroundStyle(r.state == "running" ? .green : .secondary) }.width(75)
TableColumn("Image / Digest", value: \.image).width(min: 130, ideal: 240)
TableColumn("CPU", value: \.cpu).width(65)
TableColumn("Memory", value: \.memory).width(85)
TableColumn("Network RX / TX", value: \.network).width(min: 110, ideal: 140)
TableColumn("Disk read / write", value: \.disk).width(min: 110, ideal: 140)
TableColumn("Project", value: \.project).width(min: 80, ideal: 120)
}.overlay { if model.visible.isEmpty { ContentUnavailableView(model.search.isEmpty ? "No \(model.kind.rawValue)" : "No matches", systemImage: model.kind.symbol, description: Text(model.kind == .projects ? "Set your container-compose project name in Settings, or use containers with Compose labels." : "Refresh the list or check the runtime status below.")) } }
}
}
}
struct Preferences: View {
@ObservedObject var model: Monitor
var body: some View {
Form {
TextField("Container executable", text: $model.binary)
TextField("container-compose project", text: $model.project)
Text("Recognizes <project>-<service>-1 names. Compose labels are also recognized. Actions operate on individual containers.").font(.caption).foregroundStyle(.secondary)
Toggle("Demo mode", isOn: $model.demo)
Text("Logs show the latest 200 lines and refresh every second. Telemetry runs continuously (about two seconds per native sample). Stats, volumes and processes depend on runtime support.").font(.caption).foregroundStyle(.secondary)
Button("Save Command-Line Tool…") { model.exportCLI() }
Link("Apple Container installation", destination: URL(string: "https://github.com/apple/container")!)
}.padding(24).frame(width: 560)
}
}
struct TelemetryPoint: Identifiable {
let id = UUID()
var date: Date
var cpu: Double
var memory: Double
var rx: Double
var tx: Double
}
struct MonitorDashboard: View {
@ObservedObject var model: Monitor
private let columns = [GridItem(.flexible()), GridItem(.flexible())]
private var current: TelemetryPoint? { model.telemetryAvailable ? model.history.last : nil }
private var running: Int { model.containerResources.filter { $0.state == "running" }.count }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 22) {
if !model.telemetryError.isEmpty { Label("Telemetry unavailable: " + model.telemetryError, systemImage: "exclamationmark.triangle").font(.callout).foregroundStyle(.orange).textSelection(.enabled) }
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 7) {
Text("Container Monitor").font(.system(size: 30, weight: .bold, design: .rounded))
Text(model.demo ? "DEMO · Illustrative history and fixture metrics" : "Live Apple Container telemetry · continuous two-second samples").font(.callout).foregroundStyle(.secondary)
}
Spacer()
Label(model.demo ? "Demo" : "Live", systemImage: "circle.fill").font(.caption.bold()).foregroundStyle(model.demo ? .orange : .mint).padding(.horizontal, 12).padding(.vertical, 7).background((model.demo ? Color.orange : Color.mint).opacity(0.12), in: Capsule())
}
HStack(spacing: 14) {
metricCard("Running", value: "\(running) / \(model.containerResources.count)", subtitle: "containers", symbol: "shippingbox.fill", color: .orange)
metricCard("CPU", value: current.map { String(format: "%.1f%%", $0.cpu) } ?? "—", subtitle: "of one core · all containers", symbol: "cpu", color: .mint)
metricCard("Memory", value: current.map { ContainerService.bytes($0.memory) } ?? "—", subtitle: "total working usage", symbol: "memorychip", color: .purple)
metricCard("Network", value: current.map { ContainerService.bytes($0.rx + $0.tx) + "/s" } ?? "—", subtitle: "combined receive + transmit", symbol: "arrow.up.arrow.down", color: .blue)
}
LazyVGrid(columns: columns, spacing: 16) {
graphCard("CPU usage", subtitle: "Percentage of one core", color: .mint, keyPath: \.cpu, divisor: 1, unit: "%")
graphCard("Memory", subtitle: "Aggregate container memory · MiB", color: .purple, keyPath: \.memory, divisor: 1_048_576, unit: "")
}
networkCard
HStack { Text("Container activity").font(.title3.bold()); Spacer(); Button("Manage containers") { model.kind = .containers; model.changeKind() } }
VStack(spacing: 0) {
ForEach(model.containerResources) { r in
HStack(spacing: 12) {
Image(systemName: "shippingbox").foregroundStyle(.orange).frame(width: 28, height: 28).background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 3) { Text(r.name).font(.system(.body, design: .monospaced).weight(.medium)); Text(r.image).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
Spacer(); Label(r.state, systemImage: "circle.fill").font(.caption).foregroundStyle(r.state == "running" ? .mint : .secondary).frame(width: 90, alignment: .leading)
Text(r.cpu).monospacedDigit().frame(width: 65, alignment: .trailing)
Text(r.memory).monospacedDigit().frame(width: 85, alignment: .trailing)
}.padding(12)
if r.id != model.containerResources.last?.id { Divider().padding(.leading, 52) }
}
if model.containerResources.isEmpty { Text("No application containers found. Start a container to see live metrics.").foregroundStyle(.secondary).padding(24) }
}.background(.background, in: RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(.primary.opacity(0.08)))
if !model.lastAction.isEmpty { Label(model.lastAction, systemImage: "checkmark.circle").foregroundStyle(.secondary).font(.caption) }
Text("Charts retain up to six minutes of in-app history. Network rates use differences between cumulative counters; unavailable metrics are shown as —. CPU can exceed 100% across multiple cores.").font(.caption).foregroundStyle(.secondary)
}.padding(24)
}.background(Color(nsColor: .underPageBackgroundColor))
}
private func metricCard(_ title: String, value: String, subtitle: String, symbol: String, color: Color) -> some View {
VStack(alignment: .leading, spacing: 12) { HStack { Label(title, systemImage: symbol).font(.subheadline.weight(.medium)).foregroundStyle(.secondary); Spacer() }; Text(value).font(.system(size: 25, weight: .semibold, design: .rounded)).monospacedDigit().lineLimit(1).minimumScaleFactor(0.6).foregroundStyle(color); Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1) }.padding(16).frame(maxWidth: .infinity, alignment: .leading).background(.background, in: RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(color.opacity(0.18)))
}
private func graphCard(_ title: String, subtitle: String, color: Color, keyPath: KeyPath<TelemetryPoint, Double>, divisor: Double, unit: String) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack { VStack(alignment: .leading, spacing: 4) { Text(title).font(.headline); Text(subtitle).font(.caption).foregroundStyle(.secondary) }; Spacer(); Text(current.map { String(format: "%.1f", $0[keyPath: keyPath] / divisor) + unit } ?? "—").font(.title3.bold()).monospacedDigit().foregroundStyle(color) }
Chart(model.history) { p in
AreaMark(x: .value("Time", p.date), y: .value(title, p[keyPath: keyPath] / divisor)).foregroundStyle(LinearGradient(colors: [color.opacity(0.26), color.opacity(0.01)], startPoint: .top, endPoint: .bottom)).interpolationMethod(.monotone)
LineMark(x: .value("Time", p.date), y: .value(title, p[keyPath: keyPath] / divisor)).foregroundStyle(color).lineStyle(StrokeStyle(lineWidth: 2)).interpolationMethod(.monotone)
}.chartYScale(domain: .automatic(includesZero: true)).chartXAxis { AxisMarks(values: .automatic(desiredCount: 4)) { _ in AxisGridLine(); AxisValueLabel(format: .dateTime.hour().minute().second()) } }.chartYAxis { AxisMarks(position: .leading) }.frame(height: 160).overlay { if model.history.isEmpty { Text("Waiting for supported container metrics…").font(.caption).foregroundStyle(.secondary) } }
}.padding(18).background(.background, in: RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(.primary.opacity(0.08)))
}
private var networkCard: some View {
VStack(alignment: .leading, spacing: 14) {
HStack { VStack(alignment: .leading, spacing: 4) { Text("Network throughput").font(.headline); Text("Receive and transmit · KiB/s").font(.caption).foregroundStyle(.secondary) }; Spacer(); Label("Receive", systemImage: "arrow.down").foregroundStyle(.blue); Label("Transmit", systemImage: "arrow.up").foregroundStyle(.orange) }.font(.caption)
Chart(model.history) { p in
LineMark(x: .value("Time", p.date), y: .value("KiB/s", p.rx / 1024), series: .value("Direction", "Receive")).foregroundStyle(.blue).interpolationMethod(.monotone)
LineMark(x: .value("Time", p.date), y: .value("KiB/s", p.tx / 1024), series: .value("Direction", "Transmit")).foregroundStyle(.orange).interpolationMethod(.monotone)
}.chartYScale(domain: .automatic(includesZero: true)).chartXAxis { AxisMarks(values: .automatic(desiredCount: 5)) { _ in AxisGridLine(); AxisValueLabel(format: .dateTime.hour().minute().second()) } }.chartYAxis { AxisMarks(position: .leading) }.frame(height: 145).overlay { if model.history.isEmpty { Text("Waiting for supported container metrics…").font(.caption).foregroundStyle(.secondary) } }
}.padding(18).background(.background, in: RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(.primary.opacity(0.08)))
}
}