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
+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) }
}
}