795 lines
35 KiB
Swift
795 lines
35 KiB
Swift
import Charts
|
|
import SwiftUI
|
|
|
|
struct PerformanceView: View {
|
|
@Environment(SystemMonitor.self) private var monitor
|
|
@AppStorage("performanceSelectedMetric") private var selectedMetric: PerformanceMetric = .cpu
|
|
@AppStorage("performanceCPUDisplayMode") private var cpuDisplayMode: CPUDisplayMode = .summary
|
|
@State private var selectedVolumeID: String?
|
|
|
|
private enum PerformanceMetric: String, CaseIterable, Identifiable {
|
|
case cpu = "CPU"
|
|
case memory = "Memory"
|
|
case disk = "Disk"
|
|
case gpu = "GPU"
|
|
case network = "Network"
|
|
var id: String { rawValue }
|
|
var icon: String {
|
|
switch self {
|
|
case .cpu: "cpu"
|
|
case .memory: "memorychip"
|
|
case .disk: "internaldrive"
|
|
case .gpu: "rectangle.3.group"
|
|
case .network: "network"
|
|
}
|
|
}
|
|
var color: Color {
|
|
switch self {
|
|
case .cpu: .blue
|
|
case .memory: .purple
|
|
case .disk: .green
|
|
case .gpu: .pink
|
|
case .network: .teal
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
PageHeader(title: "Performance") {
|
|
UpdateStatus()
|
|
}
|
|
HStack(spacing: 0) {
|
|
ScrollView {
|
|
VStack(spacing: 10) {
|
|
metricCard(.cpu, value: monitor.snapshot.cpuPercent, detail: "\(ProcessInfo.processInfo.activeProcessorCount) logical processors")
|
|
metricCard(.memory, value: monitor.snapshot.memoryPercent, detail: "\(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed))) of \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal)))")
|
|
metricCard(
|
|
.disk,
|
|
value: monitor.snapshot.diskActivePercent,
|
|
valueText: Formatters.rate(monitor.snapshot.diskThroughput),
|
|
detail: "\(Formatters.percent(monitor.snapshot.diskActivePercent)) active • System volume"
|
|
)
|
|
ForEach(monitor.snapshot.removableVolumes) { volume in
|
|
volumeCard(volume)
|
|
}
|
|
metricCard(
|
|
.gpu,
|
|
value: monitor.snapshot.gpuPercent,
|
|
detail: "\(monitor.snapshot.gpuCoreCount) cores • \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.gpuMemoryBytes))) in use"
|
|
)
|
|
metricCard(
|
|
.network,
|
|
value: 0,
|
|
valueText: Formatters.rate(monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate),
|
|
detail: "↓ \(Formatters.rate(monitor.snapshot.networkReceiveRate)) ↑ \(Formatters.rate(monitor.snapshot.networkSendRate))"
|
|
)
|
|
}
|
|
.padding(14)
|
|
}
|
|
.frame(width: 245)
|
|
.background(Color(nsColor: .controlBackgroundColor).opacity(0.45))
|
|
Divider()
|
|
detail
|
|
}
|
|
}
|
|
}
|
|
|
|
private func metricCard(_ metric: PerformanceMetric, value: Double, valueText: String? = nil, detail: String) -> some View {
|
|
Button {
|
|
selectedVolumeID = nil
|
|
selectedMetric = metric
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: metric.icon)
|
|
.font(.title3)
|
|
.foregroundStyle(metric.color)
|
|
.frame(width: 30)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(metric.rawValue).font(.headline)
|
|
Text(valueText ?? Formatters.percent(value)).font(.title3.monospacedDigit())
|
|
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(selectedVolumeID == nil && selectedMetric == metric ? metric.color.opacity(0.13) : Color(nsColor: .windowBackgroundColor))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 9)
|
|
.stroke(
|
|
selectedVolumeID == nil && selectedMetric == metric ? metric.color : Color.secondary.opacity(0.15),
|
|
lineWidth: selectedVolumeID == nil && selectedMetric == metric ? 1.5 : 1
|
|
)
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 9))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private func volumeCard(_ volume: VolumeSnapshot) -> some View {
|
|
Button { selectedVolumeID = volume.id } label: {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: volume.isEjectable ? "externaldrive.badge.checkmark" : "externaldrive")
|
|
.font(.title3)
|
|
.foregroundStyle(.orange)
|
|
.frame(width: 30)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(volume.name).font(.headline).lineLimit(1)
|
|
Text(Formatters.percent(volume.usedPercent)).font(.title3.monospacedDigit())
|
|
Text("\(Formatters.bytes.string(fromByteCount: Int64(volume.capacity))) • \(volume.fileSystem)")
|
|
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(selectedVolumeID == volume.id ? Color.orange.opacity(0.13) : Color(nsColor: .windowBackgroundColor))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 9)
|
|
.stroke(selectedVolumeID == volume.id ? Color.orange : Color.secondary.opacity(0.15), lineWidth: selectedVolumeID == volume.id ? 1.5 : 1)
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 9))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("\(volume.name), removable disk")
|
|
.accessibilityValue("\(Formatters.percent(volume.usedPercent)) used")
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var detail: some View {
|
|
if let selectedVolumeID,
|
|
let volume = monitor.snapshot.removableVolumes.first(where: { $0.id == selectedVolumeID }) {
|
|
RemovableDiskDetail(volume: volume)
|
|
} else {
|
|
switch selectedMetric {
|
|
case .cpu:
|
|
CPUPerformanceDetail(
|
|
subtitle: processorName,
|
|
value: monitor.snapshot.cpuPercent,
|
|
history: monitor.cpuHistory,
|
|
coreHistories: monitor.coreHistories,
|
|
coreValues: monitor.snapshot.corePercents,
|
|
displayMode: $cpuDisplayMode,
|
|
stats: [
|
|
("Processes", "\(monitor.snapshot.processCount)"),
|
|
("Threads", "\(monitor.snapshot.threadCount)"),
|
|
("Uptime", Formatters.uptime(monitor.snapshot.uptime)),
|
|
("Logical processors", "\(ProcessInfo.processInfo.activeProcessorCount)")
|
|
]
|
|
)
|
|
case .memory:
|
|
PerformanceDetail(
|
|
title: "Memory",
|
|
subtitle: Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal)),
|
|
color: .purple,
|
|
value: monitor.snapshot.memoryPercent,
|
|
history: monitor.memoryHistory,
|
|
stats: [
|
|
("In use", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed))),
|
|
("Available", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal - monitor.snapshot.memoryUsed))),
|
|
("Active", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryActive))),
|
|
("Cached", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryCached))),
|
|
("Wired", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryWired))),
|
|
("Compressed", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryCompressed))),
|
|
("Swap", "\(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.swapUsed))) / \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.swapTotal)))"),
|
|
("Type", monitor.snapshot.memoryType),
|
|
("Frequency / speed", monitor.snapshot.memorySpeed),
|
|
("Manufacturer", monitor.snapshot.memoryManufacturer)
|
|
]
|
|
)
|
|
case .disk:
|
|
DiskPerformanceDetail(
|
|
snapshot: monitor.snapshot,
|
|
readHistory: monitor.diskReadHistory,
|
|
writeHistory: monitor.diskWriteHistory,
|
|
latencyHistory: monitor.diskLatencyHistory,
|
|
activeHistory: monitor.diskActiveHistory
|
|
)
|
|
case .gpu:
|
|
GPUPerformanceDetail(
|
|
name: "\(processorName) GPU",
|
|
snapshot: monitor.snapshot,
|
|
history: monitor.gpuHistory,
|
|
rendererHistory: monitor.gpuRendererHistory,
|
|
tilerHistory: monitor.gpuTilerHistory
|
|
)
|
|
case .network:
|
|
NetworkPerformanceDetail(
|
|
interface: monitor.snapshot.networkInterface,
|
|
receiveRate: monitor.snapshot.networkReceiveRate,
|
|
sendRate: monitor.snapshot.networkSendRate,
|
|
receiveHistory: monitor.networkReceiveHistory,
|
|
sendHistory: monitor.networkSendHistory
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var processorName: String {
|
|
var size = 0
|
|
guard sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0) == 0, size > 1 else {
|
|
return "Apple Silicon"
|
|
}
|
|
var buffer = [CChar](repeating: 0, count: size)
|
|
guard sysctlbyname("machdep.cpu.brand_string", &buffer, &size, nil, 0) == 0 else {
|
|
return "Apple Silicon"
|
|
}
|
|
return String(decoding: buffer.prefix { $0 != 0 }.map(UInt8.init(bitPattern:)), as: UTF8.self)
|
|
}
|
|
}
|
|
|
|
private struct RemovableDiskDetail: View {
|
|
let volume: VolumeSnapshot
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(volume.name).font(.system(size: 30, weight: .semibold))
|
|
Text("\(volume.fileSystem) • \(volume.mountPath)")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
HStack {
|
|
Label(volume.isEjectable ? "Ejectable" : "Removable", systemImage: "externaldrive")
|
|
.foregroundStyle(.orange)
|
|
Spacer()
|
|
Text(Formatters.percent(volume.usedPercent))
|
|
.font(.system(size: 34, weight: .light).monospacedDigit())
|
|
}
|
|
|
|
ProgressView(value: volume.usedPercent, total: 100)
|
|
.tint(.orange)
|
|
|
|
StatsGrid(stats: [
|
|
("Used", Formatters.bytes.string(fromByteCount: Int64(volume.used))),
|
|
("Available", Formatters.bytes.string(fromByteCount: Int64(volume.available))),
|
|
("Capacity", Formatters.bytes.string(fromByteCount: Int64(volume.capacity))),
|
|
("Ejectable", volume.isEjectable ? "Yes" : "No")
|
|
])
|
|
}
|
|
.padding(22)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct GPUPerformanceDetail: View {
|
|
let name: String
|
|
let snapshot: SystemSnapshot
|
|
let history: [MetricSample]
|
|
let rendererHistory: [MetricSample]
|
|
let tilerHistory: [MetricSample]
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("GPU").font(.system(size: 30, weight: .semibold))
|
|
Text(name).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Text(Formatters.percent(snapshot.gpuPercent))
|
|
.font(.system(size: 34, weight: .light).monospacedDigit())
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Overall utilization").font(.caption).foregroundStyle(.secondary)
|
|
Chart(history) { sample in
|
|
AreaMark(x: .value("Time", sample.date), y: .value("Utilization", sample.value))
|
|
.foregroundStyle(Color.pink.opacity(0.12))
|
|
LineMark(x: .value("Time", sample.date), y: .value("Utilization", sample.value))
|
|
.foregroundStyle(.pink).lineStyle(.init(lineWidth: 2))
|
|
}
|
|
.chartYScale(domain: 0...100)
|
|
.chartXAxis(.hidden)
|
|
.chartYAxis {
|
|
AxisMarks(position: .trailing, values: [0, 25, 50, 75, 100]) { value in
|
|
AxisGridLine().foregroundStyle(Color.pink.opacity(0.16))
|
|
AxisValueLabel { if let number = value.as(Int.self) { Text("\(number)%") } }
|
|
}
|
|
}
|
|
.frame(height: 240)
|
|
Text("60 seconds").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
.padding(16)
|
|
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.overlay { RoundedRectangle(cornerRadius: 12).stroke(Color.pink.opacity(0.16)) }
|
|
|
|
HStack(spacing: 14) {
|
|
engineChart(title: "Renderer", value: snapshot.gpuRendererPercent, history: rendererHistory, color: .pink)
|
|
engineChart(title: "Tiler", value: snapshot.gpuTilerPercent, history: tilerHistory, color: .purple)
|
|
}
|
|
|
|
StatsGrid(stats: [
|
|
("Utilization", Formatters.percent(snapshot.gpuPercent)),
|
|
("Renderer", Formatters.percent(snapshot.gpuRendererPercent)),
|
|
("Tiler", Formatters.percent(snapshot.gpuTilerPercent)),
|
|
("GPU cores", "\(snapshot.gpuCoreCount)"),
|
|
("Memory in use", Formatters.bytes.string(fromByteCount: Int64(snapshot.gpuMemoryBytes))),
|
|
("Allocated", Formatters.bytes.string(fromByteCount: Int64(snapshot.gpuAllocatedBytes)))
|
|
])
|
|
}
|
|
.padding(22)
|
|
}
|
|
}
|
|
|
|
private func engineChart(title: String, value: Double, history: [MetricSample], color: Color) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Text(title).font(.caption).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(Formatters.percent(value)).font(.callout.monospacedDigit())
|
|
}
|
|
Chart(history) { sample in
|
|
AreaMark(x: .value("Time", sample.date), y: .value(title, sample.value))
|
|
.foregroundStyle(color.opacity(0.1))
|
|
LineMark(x: .value("Time", sample.date), y: .value(title, sample.value))
|
|
.foregroundStyle(color)
|
|
}
|
|
.chartYScale(domain: 0...100)
|
|
.chartXAxis(.hidden)
|
|
.chartYAxis(.hidden)
|
|
.frame(height: 92)
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity)
|
|
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
.overlay { RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.16)) }
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityLabel("\(title) GPU utilization")
|
|
.accessibilityValue(Formatters.percent(value))
|
|
}
|
|
}
|
|
|
|
private struct DiskPerformanceDetail: View {
|
|
let snapshot: SystemSnapshot
|
|
let readHistory: [MetricSample]
|
|
let writeHistory: [MetricSample]
|
|
let latencyHistory: [MetricSample]
|
|
let activeHistory: [MetricSample]
|
|
|
|
private var maximumRate: Double {
|
|
let maximum = (readHistory.map(\.value) + writeHistory.map(\.value) + [snapshot.diskReadRate, snapshot.diskWriteRate]).max() ?? 0
|
|
return max(1024, maximum * 1.15)
|
|
}
|
|
|
|
private var maximumLatency: Double {
|
|
max(0.1, ((latencyHistory.map(\.value) + [snapshot.diskLatencyMilliseconds]).max() ?? 0) * 1.15)
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Disk 0").font(.system(size: 30, weight: .semibold))
|
|
Text("System volume • APFS • Solid-state").foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text(Formatters.rate(snapshot.diskThroughput))
|
|
.font(.system(size: 30, weight: .light).monospacedDigit())
|
|
Text("\(Formatters.percent(snapshot.diskActivePercent)) active")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text("Disk transfer rate").font(.caption).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Label("Read", systemImage: "arrow.down").foregroundStyle(.green)
|
|
Label("Write", systemImage: "arrow.up").foregroundStyle(.orange)
|
|
}
|
|
.font(.caption)
|
|
Chart {
|
|
ForEach(readHistory) { sample in
|
|
AreaMark(x: .value("Time", sample.date), y: .value("Read", sample.value))
|
|
.foregroundStyle(Color.green.opacity(0.1))
|
|
LineMark(x: .value("Time", sample.date), y: .value("Read", sample.value))
|
|
.foregroundStyle(by: .value("Direction", "Read"))
|
|
}
|
|
ForEach(writeHistory) { sample in
|
|
LineMark(x: .value("Time", sample.date), y: .value("Write", sample.value))
|
|
.foregroundStyle(by: .value("Direction", "Write"))
|
|
}
|
|
}
|
|
.chartForegroundStyleScale(["Read": Color.green, "Write": Color.orange])
|
|
.chartYScale(domain: 0...maximumRate)
|
|
.chartXAxis(.hidden)
|
|
.chartYAxis {
|
|
AxisMarks(position: .trailing) { value in
|
|
AxisGridLine().foregroundStyle(Color.green.opacity(0.14))
|
|
AxisValueLabel {
|
|
if let rate = value.as(Double.self) { Text(Formatters.rate(rate)) }
|
|
}
|
|
}
|
|
}
|
|
.chartLegend(.hidden)
|
|
.frame(height: 220)
|
|
Text("60 seconds").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
.padding(16)
|
|
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.overlay { RoundedRectangle(cornerRadius: 12).stroke(Color.green.opacity(0.16)) }
|
|
|
|
HStack(spacing: 14) {
|
|
miniChart(
|
|
title: "Average response time",
|
|
value: String(format: "%.2f ms", snapshot.diskLatencyMilliseconds),
|
|
history: latencyHistory,
|
|
maximum: maximumLatency,
|
|
color: .orange
|
|
)
|
|
miniChart(
|
|
title: "Active time",
|
|
value: Formatters.percent(snapshot.diskActivePercent),
|
|
history: activeHistory,
|
|
maximum: 100,
|
|
color: .green
|
|
)
|
|
}
|
|
|
|
StatsGrid(stats: [
|
|
("Read speed", Formatters.rate(snapshot.diskReadRate)),
|
|
("Write speed", Formatters.rate(snapshot.diskWriteRate)),
|
|
("Response time", String(format: "%.2f ms", snapshot.diskLatencyMilliseconds)),
|
|
("IOPS", String(format: "%.0f", snapshot.diskOperationsPerSecond)),
|
|
("Used space", Formatters.bytes.string(fromByteCount: Int64(snapshot.diskTotal - snapshot.diskFree))),
|
|
("Capacity", Formatters.bytes.string(fromByteCount: Int64(snapshot.diskTotal)))
|
|
])
|
|
}
|
|
.padding(22)
|
|
}
|
|
}
|
|
|
|
private func miniChart(
|
|
title: String,
|
|
value: String,
|
|
history: [MetricSample],
|
|
maximum: Double,
|
|
color: Color
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Text(title).font(.caption).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(value).font(.callout.monospacedDigit())
|
|
}
|
|
Chart(history) { sample in
|
|
AreaMark(x: .value("Time", sample.date), y: .value(title, sample.value))
|
|
.foregroundStyle(color.opacity(0.1))
|
|
LineMark(x: .value("Time", sample.date), y: .value(title, sample.value))
|
|
.foregroundStyle(color)
|
|
}
|
|
.chartYScale(domain: 0...maximum)
|
|
.chartXAxis(.hidden)
|
|
.chartYAxis(.hidden)
|
|
.frame(height: 92)
|
|
.accessibilityLabel(title)
|
|
.accessibilityValue(value)
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity)
|
|
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
.overlay { RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.16)) }
|
|
}
|
|
}
|
|
|
|
private struct NetworkPerformanceDetail: View {
|
|
let interface: String
|
|
let receiveRate: Double
|
|
let sendRate: Double
|
|
let receiveHistory: [MetricSample]
|
|
let sendHistory: [MetricSample]
|
|
|
|
private var maximumRate: Double {
|
|
let maximum = (receiveHistory.map(\.value) + sendHistory.map(\.value) + [receiveRate, sendRate]).max() ?? 0
|
|
return max(1024, maximum * 1.15)
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Network").font(.system(size: 30, weight: .semibold))
|
|
Text(interface).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Text(Formatters.rate(receiveRate + sendRate))
|
|
.font(.system(size: 30, weight: .light).monospacedDigit())
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text("Throughput").font(.caption).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Label("Receive", systemImage: "arrow.down").foregroundStyle(.teal)
|
|
Label("Send", systemImage: "arrow.up").foregroundStyle(.purple)
|
|
}
|
|
.font(.caption)
|
|
|
|
Chart {
|
|
ForEach(receiveHistory) { sample in
|
|
AreaMark(x: .value("Time", sample.date), y: .value("Receive", sample.value))
|
|
.foregroundStyle(Color.teal.opacity(0.1))
|
|
LineMark(x: .value("Time", sample.date), y: .value("Receive", sample.value))
|
|
.foregroundStyle(by: .value("Direction", "Receive"))
|
|
}
|
|
ForEach(sendHistory) { sample in
|
|
LineMark(x: .value("Time", sample.date), y: .value("Send", sample.value))
|
|
.foregroundStyle(by: .value("Direction", "Send"))
|
|
}
|
|
}
|
|
.chartForegroundStyleScale(["Receive": Color.teal, "Send": Color.purple])
|
|
.chartYScale(domain: 0...maximumRate)
|
|
.chartXAxis(.hidden)
|
|
.chartYAxis {
|
|
AxisMarks(position: .trailing) { value in
|
|
AxisGridLine().foregroundStyle(Color.teal.opacity(0.14))
|
|
AxisValueLabel {
|
|
if let rate = value.as(Double.self) { Text(Formatters.rate(rate)) }
|
|
}
|
|
}
|
|
}
|
|
.chartLegend(.hidden)
|
|
.frame(height: 280)
|
|
Text("60 seconds").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
.padding(16)
|
|
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.overlay { RoundedRectangle(cornerRadius: 12).stroke(Color.teal.opacity(0.16)) }
|
|
|
|
StatsGrid(stats: [
|
|
("Receive", Formatters.rate(receiveRate)),
|
|
("Send", Formatters.rate(sendRate)),
|
|
("Combined", Formatters.rate(receiveRate + sendRate)),
|
|
("Interface", interface)
|
|
])
|
|
}
|
|
.padding(22)
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum CPUDisplayMode: String {
|
|
case summary = "Summary view"
|
|
case logicalProcessors = "Logical processors"
|
|
}
|
|
|
|
private struct CPUPerformanceDetail: View {
|
|
let subtitle: String
|
|
let value: Double
|
|
let history: [MetricSample]
|
|
let coreHistories: [[MetricSample]]
|
|
let coreValues: [Double]
|
|
@Binding var displayMode: CPUDisplayMode
|
|
let stats: [(String, String)]
|
|
|
|
private var chartData: [MetricSample] {
|
|
if !history.isEmpty { return history }
|
|
return [MetricSample(date: .now.addingTimeInterval(-60), value: value), MetricSample(date: .now, value: value)]
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("CPU").font(.system(size: 30, weight: .semibold))
|
|
Text(subtitle).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Text(Formatters.percent(value))
|
|
.font(.system(size: 34, weight: .light).monospacedDigit())
|
|
}
|
|
|
|
cpuGraph
|
|
.contextMenu {
|
|
Button {
|
|
displayMode = .summary
|
|
} label: {
|
|
Label("Summary view", systemImage: displayMode == .summary ? "checkmark" : "chart.xyaxis.line")
|
|
}
|
|
Button {
|
|
displayMode = .logicalProcessors
|
|
} label: {
|
|
Label("Logical processors", systemImage: displayMode == .logicalProcessors ? "checkmark" : "square.grid.3x3")
|
|
}
|
|
}
|
|
.help("Right-click to switch between summary and logical processor graphs")
|
|
|
|
StatsGrid(stats: stats)
|
|
}
|
|
.padding(22)
|
|
}
|
|
}
|
|
|
|
private var cpuGraph: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text(displayMode == .summary ? "% Utilization" : "Utilization by logical processor")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Label(displayMode.rawValue, systemImage: displayMode == .summary ? "chart.xyaxis.line" : "square.grid.3x3")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
if displayMode == .summary {
|
|
Chart(chartData) { sample in
|
|
AreaMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
|
|
.foregroundStyle(Color.blue.opacity(0.13))
|
|
LineMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
|
|
.foregroundStyle(.blue)
|
|
.lineStyle(.init(lineWidth: 2))
|
|
}
|
|
.chartYScale(domain: 0...100)
|
|
.chartXAxis(.hidden)
|
|
.chartYAxis {
|
|
AxisMarks(position: .trailing, values: [0, 25, 50, 75, 100]) { value in
|
|
AxisGridLine().foregroundStyle(Color.blue.opacity(0.18))
|
|
AxisValueLabel { if let number = value.as(Int.self) { Text("\(number)%") } }
|
|
}
|
|
}
|
|
.frame(height: 280)
|
|
} else {
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), spacing: 8) {
|
|
ForEach(coreValues.indices, id: \.self) { index in
|
|
CoreChart(
|
|
index: index,
|
|
value: coreValues[index],
|
|
history: index < coreHistories.count ? coreHistories[index] : []
|
|
)
|
|
}
|
|
}
|
|
.frame(minHeight: 280)
|
|
}
|
|
|
|
Text("60 seconds").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
.padding(16)
|
|
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(Color.blue.opacity(0.16), lineWidth: 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct CoreChart: View {
|
|
let index: Int
|
|
let value: Double
|
|
let history: [MetricSample]
|
|
|
|
private var data: [MetricSample] {
|
|
if !history.isEmpty { return history }
|
|
return [MetricSample(date: .now.addingTimeInterval(-60), value: value), MetricSample(date: .now, value: value)]
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topLeading) {
|
|
Chart(data) { sample in
|
|
AreaMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
|
|
.foregroundStyle(Color.blue.opacity(0.1))
|
|
LineMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
|
|
.foregroundStyle(.blue)
|
|
.lineStyle(.init(lineWidth: 1.25))
|
|
}
|
|
.chartYScale(domain: 0...100)
|
|
.chartXAxis(.hidden)
|
|
.chartYAxis(.hidden)
|
|
.background {
|
|
GridPattern().stroke(Color.blue.opacity(0.12), lineWidth: 0.5)
|
|
}
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text("CPU \(index)").font(.caption2.weight(.medium))
|
|
Text(Formatters.percent(value)).font(.caption2.monospacedDigit()).foregroundStyle(.secondary)
|
|
}
|
|
.padding(6)
|
|
}
|
|
.frame(height: 62)
|
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
|
.overlay { RoundedRectangle(cornerRadius: 5).stroke(Color.blue.opacity(0.28), lineWidth: 1) }
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel("CPU \(index), \(Formatters.percent(value)) utilization")
|
|
}
|
|
}
|
|
|
|
private struct GridPattern: Shape {
|
|
func path(in rect: CGRect) -> Path {
|
|
var path = Path()
|
|
for fraction in [0.25, 0.5, 0.75] {
|
|
let x = rect.width * fraction
|
|
let y = rect.height * fraction
|
|
path.move(to: CGPoint(x: x, y: 0))
|
|
path.addLine(to: CGPoint(x: x, y: rect.height))
|
|
path.move(to: CGPoint(x: 0, y: y))
|
|
path.addLine(to: CGPoint(x: rect.width, y: y))
|
|
}
|
|
return path
|
|
}
|
|
}
|
|
|
|
private struct StatsGrid: View {
|
|
let stats: [(String, String)]
|
|
|
|
var body: some View {
|
|
LazyVGrid(columns: [.init(.flexible()), .init(.flexible())], spacing: 18) {
|
|
ForEach(Array(stats.enumerated()), id: \.offset) { _, stat in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(stat.0).font(.caption).foregroundStyle(.secondary)
|
|
Text(stat.1).font(.title3.monospacedDigit()).lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PerformanceDetail: View {
|
|
let title: String
|
|
let subtitle: String
|
|
let color: Color
|
|
let value: Double
|
|
let history: [MetricSample]
|
|
let stats: [(String, String)]
|
|
|
|
var chartData: [MetricSample] {
|
|
if !history.isEmpty { return history }
|
|
return [MetricSample(date: .now.addingTimeInterval(-60), value: value), MetricSample(date: .now, value: value)]
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title).font(.system(size: 30, weight: .semibold))
|
|
Text(subtitle).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Text(Formatters.percent(value))
|
|
.font(.system(size: 34, weight: .light).monospacedDigit())
|
|
}
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("% Utilization").font(.caption).foregroundStyle(.secondary)
|
|
Chart(chartData) { sample in
|
|
AreaMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
|
|
.foregroundStyle(color.opacity(0.13))
|
|
LineMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
|
|
.foregroundStyle(color)
|
|
.lineStyle(.init(lineWidth: 2))
|
|
}
|
|
.chartYScale(domain: 0...100)
|
|
.chartXAxis(.hidden)
|
|
.chartYAxis {
|
|
AxisMarks(position: .trailing, values: [0, 25, 50, 75, 100]) { value in
|
|
AxisGridLine().foregroundStyle(color.opacity(0.18))
|
|
AxisValueLabel { if let number = value.as(Int.self) { Text("\(number)%") } }
|
|
}
|
|
}
|
|
.frame(minHeight: 280)
|
|
Text("60 seconds").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
.padding(16)
|
|
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
|
|
StatsGrid(stats: stats)
|
|
}
|
|
.padding(22)
|
|
}
|
|
}
|
|
}
|