344 lines
30 KiB
Swift
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)))
|
|
}
|
|
}
|