Files
2026-11-09 08:20:57 +00:00

344 lines
30 KiB
Swift

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