1097 lines
47 KiB
Swift
1097 lines
47 KiB
Swift
import SwiftUI
|
|
|
|
public struct ContentView: View {
|
|
@State private var store = SystemTelemetryStore.shared
|
|
@State private var selectedTab: String = "Dashboard"
|
|
@State private var processSearchText: String = ""
|
|
@State private var socketSearchText: String = ""
|
|
@State private var selectedPID: Int32? = nil
|
|
@State private var showingProcessDetail: Bool = false
|
|
|
|
public init() {}
|
|
|
|
public var body: some View {
|
|
NavigationSplitView {
|
|
sidebarContent
|
|
} detail: {
|
|
detailContent
|
|
}
|
|
.frame(minWidth: 1000, minHeight: 700)
|
|
.onAppear {
|
|
store.start()
|
|
}
|
|
.sheet(isPresented: $showingProcessDetail) {
|
|
if let pid = selectedPID {
|
|
ProcessDetailSheet(inspector: store.processInspector, pid: pid)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sidebar
|
|
private var sidebarContent: some View {
|
|
List(selection: $selectedTab) {
|
|
Section("Overview") {
|
|
Label("Dashboard", systemImage: "gauge.with.needle")
|
|
.tag("Dashboard")
|
|
Label("Hardware Architecture", systemImage: "cpu")
|
|
.tag("Architecture")
|
|
}
|
|
|
|
Section("Core Telemetry") {
|
|
Label("CPU Load & Cores", systemImage: "chart.bar.xaxis")
|
|
.tag("CPU")
|
|
Label("Kernel & System Load", systemImage: "waveform.path.ecg")
|
|
.tag("Kernel")
|
|
Label("Thermal Matrix", systemImage: "flame")
|
|
.tag("Thermals")
|
|
Label("Fans & Cooling", systemImage: "fanblades")
|
|
.tag("Fans")
|
|
Label("Memory & Swap", systemImage: "memorychip")
|
|
.tag("Memory")
|
|
}
|
|
|
|
Section("I/O & Storage") {
|
|
Label("Storage & Disk I/O", systemImage: "internaldrive")
|
|
.tag("Storage")
|
|
Label("Network Traffic", systemImage: "network")
|
|
.tag("Network")
|
|
Label("Active Sockets", systemImage: "point.3.filled.connected.trianglepath.dotted")
|
|
.tag("Sockets")
|
|
}
|
|
|
|
Section("Hardware & Devices") {
|
|
Label("Process Explorer", systemImage: "list.bullet.rectangle")
|
|
.tag("Processes")
|
|
Label("Graphics (GPU)", systemImage: "display")
|
|
.tag("GPU")
|
|
Label("Power & Battery", systemImage: "bolt.fill")
|
|
.tag("Power")
|
|
Label("Peripherals", systemImage: "cable.connector")
|
|
.tag("Peripherals")
|
|
Label("Audio Devices", systemImage: "speaker.wave.3.fill")
|
|
.tag("Audio")
|
|
}
|
|
}
|
|
.listStyle(.sidebar)
|
|
.navigationSplitViewColumnWidth(min: 210, ideal: 240, max: 300)
|
|
}
|
|
|
|
// MARK: - Detail Content Router
|
|
@ViewBuilder
|
|
private var detailContent: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
headerCard
|
|
engineControlCard
|
|
|
|
switch selectedTab {
|
|
case "Dashboard":
|
|
dashboardView
|
|
case "Architecture":
|
|
specsCard
|
|
providersCard
|
|
case "CPU":
|
|
cpuLoadCard
|
|
specsCard
|
|
case "Kernel":
|
|
kernelCountersCard
|
|
systemLoadCard
|
|
case "Thermals":
|
|
cpuThermalCard
|
|
componentThermalCard
|
|
case "Fans":
|
|
fansCard
|
|
case "Memory":
|
|
memoryCard
|
|
case "Storage":
|
|
diskIOCard
|
|
storageCard
|
|
case "Network":
|
|
networkBandwidthCard
|
|
case "Sockets":
|
|
networkSocketsCard
|
|
case "Processes":
|
|
processExplorerCard
|
|
case "GPU":
|
|
gpuTelemetryCard
|
|
case "Power":
|
|
batteryHealthCard
|
|
powerCard
|
|
case "Peripherals":
|
|
peripheralsCard
|
|
case "Audio":
|
|
audioDevicesCard
|
|
default:
|
|
dashboardView
|
|
}
|
|
}
|
|
.padding(24)
|
|
}
|
|
.navigationTitle("MacMonitor — \(selectedTab)")
|
|
}
|
|
|
|
// MARK: - Dashboard Composite View
|
|
private var dashboardView: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
HStack(alignment: .top, spacing: 16) {
|
|
cpuLoadCard
|
|
systemLoadCard
|
|
}
|
|
HStack(alignment: .top, spacing: 16) {
|
|
memoryCard
|
|
kernelCountersCard
|
|
}
|
|
HStack(alignment: .top, spacing: 16) {
|
|
cpuThermalCard
|
|
fansCard
|
|
}
|
|
HStack(alignment: .top, spacing: 16) {
|
|
diskIOCard
|
|
networkBandwidthCard
|
|
}
|
|
HStack(alignment: .top, spacing: 16) {
|
|
gpuTelemetryCard
|
|
batteryHealthCard
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Header Banner
|
|
private var headerCard: some View {
|
|
HStack(alignment: .center, spacing: 16) {
|
|
Image(systemName: "macpro.gen3.fill")
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: 44, height: 44)
|
|
.foregroundStyle(.blue)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(store.hostModel)
|
|
.font(.title2.bold())
|
|
Text("Intel x86_64 • \(store.osVersion)")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
HStack(spacing: 8) {
|
|
Circle()
|
|
.fill(store.isRunning ? Color.green : Color.red)
|
|
.frame(width: 10, height: 10)
|
|
Text(store.isRunning ? "ACTIVE" : "PAUSED")
|
|
.font(.caption.bold())
|
|
.foregroundStyle(store.isRunning ? .green : .secondary)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(.quaternary, in: Capsule())
|
|
}
|
|
.padding(16)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Telemetry Coordinator Controls
|
|
private var engineControlCard: some View {
|
|
HStack(spacing: 16) {
|
|
Button(action: {
|
|
if store.isRunning { store.stop() } else { store.start() }
|
|
}) {
|
|
Label(store.isRunning ? "Pause Engine" : "Start Engine",
|
|
systemImage: store.isRunning ? "pause.fill" : "play.fill")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(store.isRunning ? .orange : .green)
|
|
|
|
Button(action: { store.triggerManualSample() }) {
|
|
Label("Sample Now", systemImage: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Spacer()
|
|
|
|
Picker("Interval:", selection: Binding(
|
|
get: { store.sampleInterval },
|
|
set: { store.setSampleInterval($0) }
|
|
)) {
|
|
Text("0.5s").tag(0.5)
|
|
Text("1.0s").tag(1.0)
|
|
Text("2.0s").tag(2.0)
|
|
Text("5.0s").tag(5.0)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.frame(width: 220)
|
|
|
|
Text("Updated: \(store.lastUpdateTimestamp.formatted(date: .omitted, time: .standard))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(14)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - CPU Load Card
|
|
private var cpuLoadCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Label("CPU Activity", systemImage: "cpu")
|
|
.font(.headline)
|
|
Spacer()
|
|
if store.cpuLoad.isThrottled {
|
|
Text("THROTTLED")
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.red)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.red.opacity(0.15), in: Capsule())
|
|
}
|
|
Text(String(format: "%.1f%%", store.cpuLoad.totalLoad))
|
|
.font(.title3.bold().monospacedDigit())
|
|
}
|
|
|
|
ProgressView(value: min(store.cpuLoad.totalLoad / 100.0, 1.0))
|
|
.tint(store.cpuLoad.totalLoad > 85 ? .red : (store.cpuLoad.totalLoad > 60 ? .orange : .blue))
|
|
|
|
HStack {
|
|
Text("User: \(String(format: "%.1f%%", store.cpuLoad.userLoad))")
|
|
Spacer()
|
|
Text("System: \(String(format: "%.1f%%", store.cpuLoad.systemLoad))")
|
|
Spacer()
|
|
Text("Idle: \(String(format: "%.1f%%", store.cpuLoad.idleLoad))")
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
if !store.cpuLoad.perCoreLoad.isEmpty {
|
|
Divider()
|
|
Text("Per-Core Utilization (\(store.cpuLoad.perCoreLoad.count) Cores)")
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.secondary)
|
|
|
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 70))], spacing: 8) {
|
|
ForEach(Array(store.cpuLoad.perCoreLoad.enumerated()), id: \.offset) { idx, load in
|
|
VStack(spacing: 4) {
|
|
Text("Core \(idx)")
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
ProgressView(value: min(load / 100.0, 1.0))
|
|
.tint(load > 85 ? .red : .blue)
|
|
Text(String(format: "%.0f%%", load))
|
|
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Kernel Counters Card
|
|
private var kernelCountersCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Label("Kernel & VM Telemetry", systemImage: "waveform.path.ecg")
|
|
.font(.headline)
|
|
|
|
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 8) {
|
|
GridRow {
|
|
Text("Context Switches:").foregroundStyle(.secondary)
|
|
Text("\(Int(store.kernelCounters.contextSwitchesPerSec))/s").bold().monospacedDigit()
|
|
Text("Syscalls:").foregroundStyle(.secondary)
|
|
Text("\(Int(store.kernelCounters.syscallsPerSec))/s").bold().monospacedDigit()
|
|
}
|
|
GridRow {
|
|
Text("Page Faults:").foregroundStyle(.secondary)
|
|
Text("\(Int(store.kernelCounters.pageFaultsPerSec))/s").bold().monospacedDigit()
|
|
Text("COW Faults:").foregroundStyle(.secondary)
|
|
Text("\(Int(store.kernelCounters.cowFaultsPerSec))/s").bold().monospacedDigit()
|
|
}
|
|
GridRow {
|
|
Text("Pageins / Pageouts:").foregroundStyle(.secondary)
|
|
Text("\(Int(store.kernelCounters.pageinsPerSec)) / \(Int(store.kernelCounters.pageoutsPerSec)) /s").bold().monospacedDigit()
|
|
Text("Zero-Fill:").foregroundStyle(.secondary)
|
|
Text("\(Int(store.kernelCounters.zeroFillFaultsPerSec))/s").bold().monospacedDigit()
|
|
}
|
|
}
|
|
.font(.caption)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - System Load & Mach Factor Card
|
|
private var systemLoadCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Label("System Load & Mach Factor", systemImage: "gauge.high")
|
|
.font(.headline)
|
|
|
|
HStack(spacing: 24) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("1m Load").font(.caption).foregroundStyle(.secondary)
|
|
Text(String(format: "%.2f", store.systemLoad.load1Min)).font(.title3.bold().monospacedDigit())
|
|
}
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("5m Load").font(.caption).foregroundStyle(.secondary)
|
|
Text(String(format: "%.2f", store.systemLoad.load5Min)).font(.title3.bold().monospacedDigit())
|
|
}
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("15m Load").font(.caption).foregroundStyle(.secondary)
|
|
Text(String(format: "%.2f", store.systemLoad.load15Min)).font(.title3.bold().monospacedDigit())
|
|
}
|
|
Spacer()
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text("Mach Factor").font(.caption).foregroundStyle(.secondary)
|
|
Text(String(format: "%.2f", store.systemLoad.machFactor)).font(.title3.bold().monospacedDigit()).foregroundStyle(.blue)
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
HStack {
|
|
Text("Active Mach Tasks: \(store.systemLoad.taskCount)").font(.caption).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text("Active Mach Threads: \(store.systemLoad.threadCount)").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Memory & Swap Card
|
|
private var memoryCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Label("Memory & Swap", systemImage: "memorychip")
|
|
.font(.headline)
|
|
Spacer()
|
|
Text(store.memory.pressureLevel)
|
|
.font(.caption.bold())
|
|
.foregroundStyle(pressureColor(store.memory.pressureLevel))
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(pressureColor(store.memory.pressureLevel).opacity(0.15), in: Capsule())
|
|
Text(String(format: "%.1f%%", store.memory.utilizationPercentage))
|
|
.font(.title3.bold().monospacedDigit())
|
|
}
|
|
|
|
ProgressView(value: min(store.memory.utilizationPercentage / 100.0, 1.0))
|
|
.tint(store.memory.utilizationPercentage > 85 ? .red : .blue)
|
|
|
|
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) {
|
|
GridRow {
|
|
Text("Used:").foregroundStyle(.secondary)
|
|
Text(formatBytes(store.memory.usedBytes)).bold()
|
|
Text("Free:").foregroundStyle(.secondary)
|
|
Text(formatBytes(store.memory.freeBytes)).bold()
|
|
}
|
|
GridRow {
|
|
Text("Wired:").foregroundStyle(.secondary)
|
|
Text(formatBytes(store.memory.wiredBytes))
|
|
Text("Compressed:").foregroundStyle(.secondary)
|
|
Text(formatBytes(store.memory.compressedBytes))
|
|
}
|
|
GridRow {
|
|
Text("Swap Used:").foregroundStyle(.secondary)
|
|
Text(formatBytes(store.memory.swapUsedBytes))
|
|
Text("Swap Total:").foregroundStyle(.secondary)
|
|
Text(formatBytes(store.memory.swapTotalBytes))
|
|
}
|
|
}
|
|
.font(.caption)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Disk I/O Card
|
|
private var diskIOCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Label("Disk I/O & IOPS", systemImage: "externaldrive.badge.timemachine")
|
|
.font(.headline)
|
|
|
|
if store.diskIO.isEmpty {
|
|
Text("Sampling disk I/O metrics...").font(.caption).foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(store.diskIO) { disk in
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(disk.bsdName).bold()
|
|
Text("IOPS: \(Int(disk.readIOPS + disk.writeIOPS)) (R: \(Int(disk.readIOPS)), W: \(Int(disk.writeIOPS)))")
|
|
.font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text("R: \(formatBps(disk.readBps))").font(.caption.bold().monospacedDigit()).foregroundStyle(.green)
|
|
Text("W: \(formatBps(disk.writeBps))").font(.caption.bold().monospacedDigit()).foregroundStyle(.blue)
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Storage Volumes Card
|
|
private var storageCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Label("Mounted Storage Volumes", systemImage: "internaldrive")
|
|
.font(.headline)
|
|
|
|
ForEach(store.storageVolumes) { vol in
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Text(vol.volumeName).bold()
|
|
Text("(\(vol.mountPoint)) • \(vol.fileSystem)").font(.caption).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(String(format: "%.1f%%", vol.usedPercentage)).font(.caption.bold().monospacedDigit())
|
|
}
|
|
ProgressView(value: min(vol.usedPercentage / 100.0, 1.0))
|
|
.tint(vol.usedPercentage > 90 ? .red : .blue)
|
|
HStack {
|
|
Text("Used: \(formatBytes(vol.usedBytes))").font(.caption2).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text("Free: \(formatBytes(vol.freeBytes))").font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Network Bandwidth Card
|
|
private var networkBandwidthCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Label("Network Bandwidth", systemImage: "network")
|
|
.font(.headline)
|
|
|
|
if store.networkBandwidth.isEmpty {
|
|
Text("No network interfaces detected").font(.caption).foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(store.networkBandwidth) { iface in
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack {
|
|
Circle().fill(iface.isUp ? Color.green : Color.gray).frame(width: 8, height: 8)
|
|
Text(iface.interfaceName).bold()
|
|
}
|
|
if !iface.ipAddress.isEmpty {
|
|
Text(iface.ipAddress).font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text("↓ \(formatBps(iface.downloadBps)) (\(Int(iface.downloadPps)) pps)")
|
|
.font(.caption.bold().monospacedDigit()).foregroundStyle(.green)
|
|
Text("↑ \(formatBps(iface.uploadBps)) (\(Int(iface.uploadPps)) pps)")
|
|
.font(.caption.bold().monospacedDigit()).foregroundStyle(.blue)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Network Sockets Card
|
|
private var networkSocketsCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Label("Active Network Sockets (\(store.networkSockets.count))", systemImage: "point.3.filled.connected.trianglepath.dotted")
|
|
.font(.headline)
|
|
Spacer()
|
|
TextField("Filter socket/PID/process...", text: $socketSearchText)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(width: 200)
|
|
}
|
|
|
|
let filtered = store.networkSockets.filter {
|
|
socketSearchText.isEmpty ||
|
|
$0.processName.localizedCaseInsensitiveContains(socketSearchText) ||
|
|
$0.localAddress.localizedCaseInsensitiveContains(socketSearchText) ||
|
|
$0.remoteAddress.localizedCaseInsensitiveContains(socketSearchText) ||
|
|
"\($0.pid)".contains(socketSearchText)
|
|
}
|
|
|
|
Table(filtered.prefix(150)) {
|
|
TableColumn("Process") { s in
|
|
Text("\(s.processName) (\(s.pid))").bold()
|
|
}
|
|
TableColumn("Proto") { s in
|
|
Text(s.protocolName)
|
|
}
|
|
TableColumn("Local Endpoint") { s in
|
|
Text("\(s.localAddress):\(s.localPort)").monospaced()
|
|
}
|
|
TableColumn("Remote Endpoint") { s in
|
|
Text("\(s.remoteAddress):\(s.remotePort)").monospaced()
|
|
}
|
|
TableColumn("State") { s in
|
|
Text(s.tcpState).font(.caption).foregroundStyle(s.tcpState == "ESTABLISHED" ? .green : .secondary)
|
|
}
|
|
}
|
|
.frame(minHeight: 350)
|
|
}
|
|
.padding(16)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Process Explorer Card
|
|
private var processExplorerCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Label("Process Explorer (\(store.processes.count))", systemImage: "list.bullet.rectangle")
|
|
.font(.headline)
|
|
Spacer()
|
|
TextField("Search name or PID...", text: $processSearchText)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(width: 220)
|
|
}
|
|
|
|
let filtered = store.processes.filter {
|
|
processSearchText.isEmpty ||
|
|
$0.name.localizedCaseInsensitiveContains(processSearchText) ||
|
|
"\($0.pid)".contains(processSearchText) ||
|
|
$0.username.localizedCaseInsensitiveContains(processSearchText)
|
|
}
|
|
|
|
Table(filtered.prefix(200)) {
|
|
TableColumn("PID") { p in
|
|
Text("\(p.pid)").monospacedDigit()
|
|
}
|
|
TableColumn("Process Name") { p in
|
|
HStack {
|
|
Text(p.name).bold()
|
|
if p.isStopped {
|
|
Text("STOP").font(.caption2).foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
TableColumn("% CPU") { p in
|
|
Text(String(format: "%.1f%%", p.cpuPercent))
|
|
.monospacedDigit()
|
|
.foregroundStyle(p.cpuPercent > 50 ? .red : .primary)
|
|
}
|
|
TableColumn("RSS Memory") { p in
|
|
Text(formatBytes(p.residentSize)).monospacedDigit()
|
|
}
|
|
TableColumn("Threads") { p in
|
|
Text("\(p.threadCount)").monospacedDigit()
|
|
}
|
|
TableColumn("User") { p in
|
|
Text(p.username)
|
|
}
|
|
TableColumn("Actions") { p in
|
|
HStack(spacing: 8) {
|
|
Button("Inspect") {
|
|
selectedPID = p.pid
|
|
showingProcessDetail = true
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.foregroundStyle(.blue)
|
|
|
|
Button("Kill") {
|
|
_ = store.terminateProcess(pid: p.pid, force: false)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
.frame(minHeight: 400)
|
|
}
|
|
.padding(16)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - GPU Card
|
|
private var gpuTelemetryCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Label("Graphics Accelerators (GPU)", systemImage: "display")
|
|
.font(.headline)
|
|
|
|
if store.gpus.isEmpty {
|
|
Text("No GPU accelerators detected").font(.caption).foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(store.gpus) { gpu in
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Text(gpu.name).bold()
|
|
Spacer()
|
|
if gpu.temperature > 0 {
|
|
Text(String(format: "%.1f°C", gpu.temperature))
|
|
.font(.caption.bold())
|
|
.foregroundStyle(gpu.temperature > 85 ? .red : .primary)
|
|
}
|
|
Text(String(format: "%.1f%%", gpu.utilizationPercent))
|
|
.font(.title3.bold().monospacedDigit())
|
|
}
|
|
ProgressView(value: min(gpu.utilizationPercent / 100.0, 1.0))
|
|
.tint(gpu.utilizationPercent > 80 ? .red : .blue)
|
|
HStack {
|
|
Text(gpu.isDiscrete ? "Discrete GPU" : "Integrated GPU").font(.caption2).foregroundStyle(.secondary)
|
|
Spacer()
|
|
if gpu.vramTotalBytes > 0 {
|
|
Text("VRAM: \(formatBytes(gpu.vramUsedBytes)) / \(formatBytes(gpu.vramTotalBytes))").font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Battery Health Card
|
|
private var batteryHealthCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Label("Battery & Health", systemImage: "battery.100.bolt")
|
|
.font(.headline)
|
|
Spacer()
|
|
if store.batteryHealth.hasBattery {
|
|
Text(store.batteryHealth.healthCondition)
|
|
.font(.caption.bold())
|
|
.foregroundStyle(store.batteryHealth.healthCondition == "Normal" ? .green : .orange)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.green.opacity(0.15), in: Capsule())
|
|
Text(String(format: "%.1f%%", store.batteryHealth.healthPercent))
|
|
.font(.title3.bold().monospacedDigit())
|
|
}
|
|
}
|
|
|
|
if !store.batteryHealth.hasBattery {
|
|
Text("Desktop Mac / No battery installed").font(.caption).foregroundStyle(.secondary)
|
|
} else {
|
|
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) {
|
|
GridRow {
|
|
Text("Cycle Count:").foregroundStyle(.secondary)
|
|
Text("\(store.batteryHealth.cycleCount) / \(store.batteryHealth.designCycleCount)").bold()
|
|
Text("Temperature:").foregroundStyle(.secondary)
|
|
Text(String(format: "%.1f°C", store.batteryHealth.temperature)).bold()
|
|
}
|
|
GridRow {
|
|
Text("Voltage / Power:").foregroundStyle(.secondary)
|
|
Text("\(String(format: "%.2f V", store.batteryHealth.voltage)) (\(String(format: "%.1f W", store.batteryHealth.watts)))")
|
|
Text("Power Adapter:").foregroundStyle(.secondary)
|
|
Text(store.batteryHealth.externalConnected ? "\(store.batteryHealth.adapterWatts)W Connected" : "On Battery")
|
|
}
|
|
GridRow {
|
|
Text("Capacity:").foregroundStyle(.secondary)
|
|
Text("\(store.batteryHealth.currentCapacity) / \(store.batteryHealth.maxCapacity) mAh")
|
|
Text("Manufacturer:").foregroundStyle(.secondary)
|
|
Text(store.batteryHealth.manufacturer)
|
|
}
|
|
}
|
|
.font(.caption)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Peripherals Card
|
|
private var peripheralsCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Label("Connected Peripherals (\(store.peripherals.count))", systemImage: "cable.connector")
|
|
.font(.headline)
|
|
|
|
Table(store.peripherals) {
|
|
TableColumn("Device Name") { d in
|
|
Text(d.name).bold()
|
|
}
|
|
TableColumn("Bus") { d in
|
|
Text(d.busType)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.secondary.opacity(0.15), in: Capsule())
|
|
}
|
|
TableColumn("Vendor ID") { d in
|
|
Text(String(format: "0x%04x", d.vendorID)).monospaced()
|
|
}
|
|
TableColumn("Product ID") { d in
|
|
Text(String(format: "0x%04x", d.productID)).monospaced()
|
|
}
|
|
TableColumn("Built-In") { d in
|
|
Text(d.isBuiltIn ? "Yes" : "External").foregroundStyle(d.isBuiltIn ? Color.secondary : Color.blue)
|
|
}
|
|
}
|
|
.frame(minHeight: 350)
|
|
}
|
|
.padding(16)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Audio Devices Card
|
|
private var audioDevicesCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Label("Audio Devices (\(store.audioDevices.count))", systemImage: "speaker.wave.3.fill")
|
|
.font(.headline)
|
|
|
|
ForEach(store.audioDevices) { audio in
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack {
|
|
Text(audio.name).bold()
|
|
if audio.isDefaultOutput {
|
|
Text("DEFAULT OUT").font(.caption2.bold()).foregroundStyle(.green)
|
|
}
|
|
if audio.isDefaultInput {
|
|
Text("DEFAULT IN").font(.caption2.bold()).foregroundStyle(.blue)
|
|
}
|
|
}
|
|
Text("\(audio.manufacturer) • \(Int(audio.sampleRate)) Hz • \(audio.channelCount) Channels")
|
|
.font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
if audio.isOutput {
|
|
Text("Vol: \(Int(audio.volume * 100))%")
|
|
.font(.caption.bold().monospacedDigit())
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - CPU Thermal Card
|
|
private var cpuThermalCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Label("CPU Temperature", systemImage: "thermometer.medium")
|
|
.font(.headline)
|
|
Spacer()
|
|
Text(store.cpuThermal.thermalPressureState)
|
|
.font(.caption.bold())
|
|
.foregroundStyle(store.cpuThermal.thermalPressureState == "Nominal" ? .green : .red)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.green.opacity(0.15), in: Capsule())
|
|
Text(String(format: "%.1f°C", store.cpuThermal.packageTemperature))
|
|
.font(.title3.bold().monospacedDigit())
|
|
}
|
|
|
|
ProgressView(value: min(store.cpuThermal.packageTemperature / 105.0, 1.0))
|
|
.tint(store.cpuThermal.packageTemperature > 85 ? .red : (store.cpuThermal.packageTemperature > 70 ? .orange : .blue))
|
|
|
|
HStack {
|
|
Text("Avg Core: \(String(format: "%.1f°C", store.cpuThermal.averageCoreTemperature))")
|
|
Spacer()
|
|
Text("Peak Core: \(String(format: "%.1f°C", store.cpuThermal.peakCoreTemperature))")
|
|
Spacer()
|
|
Text("Throttling: \(store.cpuThermal.isThrottling ? "YES" : "No")")
|
|
.foregroundStyle(store.cpuThermal.isThrottling ? .red : .secondary)
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Fans Card
|
|
private var fansCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Label("Cooling Fans", systemImage: "fanblades")
|
|
.font(.headline)
|
|
|
|
if store.fans.isEmpty {
|
|
Text("No SMC fan sensors detected").font(.caption).foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(store.fans) { fan in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(fan.name).bold()
|
|
Spacer()
|
|
Text("\(Int(fan.currentRPM)) RPM").font(.title3.bold().monospacedDigit())
|
|
}
|
|
ProgressView(value: min(fan.utilization / 100.0, 1.0))
|
|
.tint(fan.utilization > 80 ? .orange : .blue)
|
|
HStack {
|
|
Text("Min: \(Int(fan.minRPM)) RPM").font(.caption2).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text("Max: \(Int(fan.maxRPM)) RPM").font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Component Thermal Matrix Card
|
|
private var componentThermalCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Label("Component Thermal Sensors (\(store.componentTemps.count) Probed)", systemImage: "flame")
|
|
.font(.headline)
|
|
|
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 160))], spacing: 10) {
|
|
ForEach(store.componentTemps) { item in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(item.name).font(.caption).bold().lineLimit(1)
|
|
Spacer()
|
|
Text(String(format: "%.1f°C", item.temperature))
|
|
.font(.caption.bold().monospacedDigit())
|
|
.foregroundStyle(item.temperature > 80 ? .red : (item.temperature > 65 ? .orange : .primary))
|
|
}
|
|
ProgressView(value: min(item.temperature / 100.0, 1.0))
|
|
.tint(item.temperature > 80 ? .red : .blue)
|
|
Text("\(item.category) • \(item.key)").font(.system(size: 9)).foregroundStyle(.secondary)
|
|
}
|
|
.padding(8)
|
|
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Power & Voltage Card
|
|
private var powerCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Label("Power Draw & Rails", systemImage: "bolt.fill")
|
|
.font(.headline)
|
|
Spacer()
|
|
Text(String(format: "%.1f W", store.power.systemTotalWatts))
|
|
.font(.title3.bold().monospacedDigit())
|
|
}
|
|
|
|
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) {
|
|
GridRow {
|
|
Text("CPU Power:").foregroundStyle(.secondary)
|
|
Text(String(format: "%.1f W", store.power.cpuWatts)).bold()
|
|
Text("GPU Power:").foregroundStyle(.secondary)
|
|
Text(String(format: "%.1f W", store.power.gpuWatts)).bold()
|
|
}
|
|
GridRow {
|
|
Text("CPU Voltage:").foregroundStyle(.secondary)
|
|
Text(String(format: "%.2f V", store.power.cpuVoltage))
|
|
Text("CPU Current:").foregroundStyle(.secondary)
|
|
Text(String(format: "%.2f A", store.power.cpuCurrent))
|
|
}
|
|
}
|
|
.font(.caption)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Hardware Specs Card
|
|
private var specsCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Host Architecture & Kernel")
|
|
.font(.headline)
|
|
|
|
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 10) {
|
|
GridRow {
|
|
Text("Hardware Model:").foregroundStyle(.secondary)
|
|
Text(store.hostModel).bold()
|
|
Text("Physical Cores:").foregroundStyle(.secondary)
|
|
Text("\(store.physicalCpuCount)").bold()
|
|
}
|
|
GridRow {
|
|
Text("Darwin Kernel:").foregroundStyle(.secondary)
|
|
Text(store.kernelVersion).bold()
|
|
Text("Logical Cores:").foregroundStyle(.secondary)
|
|
Text("\(store.logicalCpuCount)").bold()
|
|
}
|
|
GridRow {
|
|
Text("Target Architecture:").foregroundStyle(.secondary)
|
|
Text("Intel x86_64").bold()
|
|
Text("Deployment Target:").foregroundStyle(.secondary)
|
|
Text("macOS 14.0 (Sonoma)").bold()
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Providers Card
|
|
private var providersCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Text("Registered Telemetry Providers")
|
|
.font(.headline)
|
|
Spacer()
|
|
Text("\(store.registeredProviderCount) Active")
|
|
.font(.caption.bold())
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Color.blue.opacity(0.15))
|
|
.foregroundStyle(.blue)
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
ForEach(Array(store.latestSnapshot.keys.sorted()), id: \.self) { key in
|
|
HStack {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
Text(key)
|
|
.font(.system(.body, design: .monospaced))
|
|
Spacer()
|
|
Text("\(store.latestSnapshot[key]?.count ?? 0) metrics")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
|
}
|
|
|
|
// MARK: - Formatting Helpers
|
|
private func formatBytes(_ bytes: UInt64) -> String {
|
|
let formatter = ByteCountFormatter()
|
|
formatter.allowedUnits = [.useGB, .useMB, .useKB]
|
|
formatter.countStyle = .memory
|
|
return formatter.string(fromByteCount: Int64(bytes))
|
|
}
|
|
|
|
private func formatBps(_ bps: Double) -> String {
|
|
if bps >= 1_000_000_000 {
|
|
return String(format: "%.2f GB/s", bps / 1_000_000_000.0)
|
|
} else if bps >= 1_000_000 {
|
|
return String(format: "%.1f MB/s", bps / 1_000_000.0)
|
|
} else if bps >= 1_000 {
|
|
return String(format: "%.1f KB/s", bps / 1_000.0)
|
|
} else {
|
|
return String(format: "%.0f B/s", bps)
|
|
}
|
|
}
|
|
|
|
private func pressureColor(_ level: String) -> Color {
|
|
switch level {
|
|
case "Critical": return .red
|
|
case "Warning": return .orange
|
|
default: return .green
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Process Detail Inspector Sheet
|
|
struct ProcessDetailSheet: View {
|
|
let inspector: MMProcessDetailInspector
|
|
let pid: Int32
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var threads: [[String: Any]] = []
|
|
@State private var files: [String] = []
|
|
@State private var sockets: [[String: Any]] = []
|
|
@State private var selectedDetailTab: String = "Threads"
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
HStack {
|
|
Text("Process Inspector (PID \(pid))")
|
|
.font(.title2.bold())
|
|
Spacer()
|
|
Button("Done") { dismiss() }
|
|
}
|
|
|
|
Picker("", selection: $selectedDetailTab) {
|
|
Text("Threads (\(threads.count))").tag("Threads")
|
|
Text("Open Files (\(files.count))").tag("Files")
|
|
Text("Open Sockets (\(sockets.count))").tag("Sockets")
|
|
}
|
|
.pickerStyle(.segmented)
|
|
|
|
if selectedDetailTab == "Threads" {
|
|
List(threads.indices, id: \.self) { idx in
|
|
let t = threads[idx]
|
|
HStack {
|
|
Text("Thread \(t["threadID"] ?? idx)").bold()
|
|
Spacer()
|
|
Text("CPU: \(String(format: "%.1f%%", (t["cpuPercent"] as? Double) ?? 0.0))").monospacedDigit()
|
|
Text(t["state"] as? String ?? "").foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
} else if selectedDetailTab == "Files" {
|
|
List(files, id: \.self) { path in
|
|
Text(path).font(.system(.caption, design: .monospaced))
|
|
}
|
|
} else {
|
|
List(sockets.indices, id: \.self) { idx in
|
|
let s = sockets[idx]
|
|
HStack {
|
|
Text(s["protocol"] as? String ?? "TCP").bold()
|
|
Spacer()
|
|
Text("\(s["localAddress"] ?? ""):\(s["localPort"] ?? 0) → \(s["remoteAddress"] ?? ""):\(s["remotePort"] ?? 0)")
|
|
.monospaced()
|
|
Text(s["tcpState"] as? String ?? "").foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(24)
|
|
.frame(minWidth: 600, minHeight: 450)
|
|
.onAppear {
|
|
loadDetails()
|
|
}
|
|
}
|
|
|
|
private func loadDetails() {
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
let res = MMProcessDetailInspector.inspectProcess(withPID: pid) ?? [:]
|
|
let t = (res["threads"] as? [[String: Any]]) ?? []
|
|
let fds = (res["fileDescriptors"] as? [[String: Any]]) ?? []
|
|
let f = fds.compactMap { $0["path"] as? String }
|
|
let s = (res["sockets"] as? [[String: Any]]) ?? []
|
|
DispatchQueue.main.async {
|
|
self.threads = t
|
|
self.files = f
|
|
self.sockets = s
|
|
}
|
|
}
|
|
}
|
|
}
|