feat(metrics): add per-core CPU bars and adaptive layouts
This commit is contained in:
1 parent
a89c3875b4
commit
f5ed001938
9 files changed
+179
-53
No files matched your search
@@ -4725,6 +4725,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CPU %lld" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "CPU %lld"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "CPU %lld"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CPU time breakdown" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
public nonisolated struct MetricsCPUPoint: Identifiable, Equatable, Sendable {
|
||||
public var id: Int
|
||||
public var utilization: Double?
|
||||
public var user: Double?
|
||||
public var system: Double?
|
||||
public var wait: Double?
|
||||
public var steal: Double?
|
||||
public var idle: Double?
|
||||
|
||||
public init(
|
||||
id: Int, utilization: Double? = nil, user: Double? = nil, system: Double? = nil,
|
||||
wait: Double? = nil, steal: Double? = nil, idle: Double? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.utilization = utilization
|
||||
self.user = user
|
||||
self.system = system
|
||||
self.wait = wait
|
||||
self.steal = steal
|
||||
self.idle = idle
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ public nonisolated struct MetricsCounters: Equatable, Sendable {
|
||||
public nonisolated struct MetricsSample: Sendable {
|
||||
public var uptime: Double?
|
||||
public var cpu: [UInt64]?
|
||||
/// Linux logical CPU IDs and counters. Empty counters retain an unreadable CPU's identity.
|
||||
public var cpuCores: [Int: [UInt64]]
|
||||
public var coreCount: Int?
|
||||
public var load: [Double] = []
|
||||
public var memoryFree: Double?
|
||||
@@ -56,7 +58,8 @@ public nonisolated struct MetricsSample: Sendable {
|
||||
public var interfaceAddresses: [String: [String]] = [:]
|
||||
public var tcp: MetricsTCP?
|
||||
public init(
|
||||
uptime: Double? = nil, cpu: [UInt64]? = nil, coreCount: Int? = nil, load: [Double] = [],
|
||||
uptime: Double? = nil, cpu: [UInt64]? = nil, cpuCores: [Int: [UInt64]] = [:],
|
||||
coreCount: Int? = nil, load: [Double] = [],
|
||||
memoryFree: Double? = nil, memoryCached: Double? = nil, memoryUsed: Double? = nil, memoryTotal: Double? = nil,
|
||||
swapFree: Double? = nil, swapTotal: Double? = nil,
|
||||
disks: [String: MetricsCounters]? = nil, interfaces: [String: MetricsCounters]? = nil,
|
||||
@@ -65,6 +68,7 @@ public nonisolated struct MetricsSample: Sendable {
|
||||
) {
|
||||
self.uptime = uptime
|
||||
self.cpu = cpu
|
||||
self.cpuCores = cpuCores
|
||||
self.coreCount = coreCount
|
||||
self.load = load
|
||||
self.memoryFree = memoryFree
|
||||
@@ -191,6 +195,7 @@ public nonisolated struct MetricsPoint: Identifiable, Sendable {
|
||||
public var cpuWait: Double?
|
||||
public var cpuSteal: Double?
|
||||
public var cpuIdle: Double?
|
||||
public var cpuCores: [MetricsCPUPoint]
|
||||
public var memoryFree: Double?
|
||||
public var memoryCached: Double?
|
||||
public var diskDevices: [MetricsDevicePoint] = []
|
||||
@@ -206,7 +211,8 @@ public nonisolated struct MetricsPoint: Identifiable, Sendable {
|
||||
diskRead: Double? = nil, diskWrite: Double? = nil, networkReceive: Double? = nil, networkSend: Double? = nil,
|
||||
gpus: [NvidiaGPU] = [], uptime: Double? = nil, coreCount: Int? = nil, load: [Double] = [],
|
||||
cpuUser: Double? = nil, cpuSystem: Double? = nil, cpuWait: Double? = nil, cpuSteal: Double? = nil,
|
||||
cpuIdle: Double? = nil, memoryFree: Double? = nil, memoryCached: Double? = nil,
|
||||
cpuIdle: Double? = nil, cpuCores: [MetricsCPUPoint] = [], memoryFree: Double? = nil,
|
||||
memoryCached: Double? = nil,
|
||||
diskDevices: [MetricsDevicePoint] = [], networkDevices: [MetricsDevicePoint] = [],
|
||||
blockDevices: [String: MetricsDevicePoint] = [:], networkSentTotal: Double? = nil,
|
||||
networkReceivedTotal: Double? = nil, tcp: MetricsTCP? = nil, tcpRetransmissionPercent: Double? = nil
|
||||
@@ -233,6 +239,7 @@ public nonisolated struct MetricsPoint: Identifiable, Sendable {
|
||||
self.cpuWait = cpuWait
|
||||
self.cpuSteal = cpuSteal
|
||||
self.cpuIdle = cpuIdle
|
||||
self.cpuCores = cpuCores
|
||||
self.memoryFree = memoryFree
|
||||
self.memoryCached = memoryCached
|
||||
self.diskDevices = diskDevices
|
||||
@@ -253,6 +260,7 @@ public nonisolated enum MetricsCalculation {
|
||||
var point = MetricsPoint(date: date)
|
||||
point.uptime = current.uptime
|
||||
point.coreCount = current.coreCount
|
||||
point.cpuCores = current.cpuCores.keys.sorted().map { MetricsCPUPoint(id: $0) }
|
||||
point.load = current.load
|
||||
point.memoryFree = current.memoryFree
|
||||
point.memoryCached = current.memoryCached
|
||||
@@ -284,19 +292,15 @@ public nonisolated enum MetricsCalculation {
|
||||
after > before
|
||||
else { return point }
|
||||
let elapsed = after - before
|
||||
if let old = previous.cpu, let new = current.cpu, old.count >= 8, new.count >= 8,
|
||||
(0..<8).allSatisfy({ new[$0] >= old[$0] })
|
||||
{
|
||||
let delta = (0..<8).map { Double(new[$0] - old[$0]) }
|
||||
let total = delta.reduce(0, +)
|
||||
if total > 0 {
|
||||
point.cpu = max(0, min(100, (total - delta[3] - delta[4]) / total * 100))
|
||||
point.cpuUser = (delta[0] + delta[1]) / total * 100
|
||||
point.cpuSystem = (delta[2] + delta[5] + delta[6]) / total * 100
|
||||
point.cpuWait = delta[4] / total * 100
|
||||
point.cpuSteal = delta[7] / total * 100
|
||||
point.cpuIdle = delta[3] / total * 100
|
||||
}
|
||||
let cpu = cpuPoint(id: 0, previous: previous.cpu, current: current.cpu)
|
||||
point.cpu = cpu.utilization
|
||||
point.cpuUser = cpu.user
|
||||
point.cpuSystem = cpu.system
|
||||
point.cpuWait = cpu.wait
|
||||
point.cpuSteal = cpu.steal
|
||||
point.cpuIdle = cpu.idle
|
||||
point.cpuCores = current.cpuCores.keys.sorted().map {
|
||||
cpuPoint(id: $0, previous: previous.cpuCores[$0], current: current.cpuCores[$0])
|
||||
}
|
||||
point.diskDevices = devices(previous.disks, current.disks, selection.disks, elapsed: elapsed)
|
||||
point.networkDevices = networkDevices(previous, current, elapsed: elapsed)
|
||||
@@ -318,6 +322,20 @@ public nonisolated enum MetricsCalculation {
|
||||
return point
|
||||
}
|
||||
|
||||
private static func cpuPoint(id: Int, previous: [UInt64]?, current: [UInt64]?) -> MetricsCPUPoint {
|
||||
guard let old = previous, let new = current, old.count >= 8, new.count >= 8,
|
||||
(0..<8).allSatisfy({ new[$0] >= old[$0] })
|
||||
else { return MetricsCPUPoint(id: id) }
|
||||
// Guest counters are already included in user/nice time.
|
||||
let delta = (0..<8).map { Double(new[$0] - old[$0]) }
|
||||
let total = delta.reduce(0, +)
|
||||
guard total > 0 else { return MetricsCPUPoint(id: id) }
|
||||
return MetricsCPUPoint(
|
||||
id: id, utilization: max(0, min(100, (total - delta[3] - delta[4]) / total * 100)),
|
||||
user: (delta[0] + delta[1]) / total * 100, system: (delta[2] + delta[5] + delta[6]) / total * 100,
|
||||
wait: delta[4] / total * 100, steal: delta[7] / total * 100, idle: delta[3] / total * 100)
|
||||
}
|
||||
|
||||
private static func networkDevices(_ old: MetricsSample?, _ current: MetricsSample, elapsed: Double?)
|
||||
-> [MetricsDevicePoint]
|
||||
{
|
||||
|
||||
@@ -23,11 +23,16 @@ public nonisolated enum LinuxMetricsParser {
|
||||
if values.count == fields.count, values.count >= 8 { sample.cpu = values }
|
||||
}
|
||||
if let cpuSection = sections["__TERMIND_CPU__"] {
|
||||
let count = cpuSection.split(separator: "\n").filter {
|
||||
guard let name = $0.split(whereSeparator: \.isWhitespace).first else { return false }
|
||||
return name.hasPrefix("cpu") && Int(name.dropFirst(3)) != nil
|
||||
}.count
|
||||
sample.coreCount = count > 0 ? count : nil
|
||||
for line in cpuSection.split(separator: "\n") {
|
||||
let fields = line.split(whereSeparator: \.isWhitespace)
|
||||
guard let name = fields.first, name.hasPrefix("cpu"),
|
||||
!name.dropFirst(3).isEmpty, name.dropFirst(3).allSatisfy(\.isNumber),
|
||||
let id = Int(name.dropFirst(3))
|
||||
else { continue }
|
||||
let counters = fields.dropFirst().compactMap { UInt64($0) }
|
||||
sample.cpuCores[id] = counters.count == fields.count - 1 && counters.count >= 8 ? counters : []
|
||||
}
|
||||
sample.coreCount = sample.cpuCores.isEmpty ? nil : sample.cpuCores.count
|
||||
}
|
||||
sample.load =
|
||||
sections["__TERMIND_LOAD__"]?.split(whereSeparator: \.isWhitespace).prefix(3)
|
||||
|
||||
@@ -12,27 +12,15 @@ public struct MetricsSystemPanels: View {
|
||||
public var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
panel {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text(MetricsFormat.percent(point?.cpu))
|
||||
.font(.system(size: 30, weight: .semibold, design: .rounded)).minimumScaleFactor(0.7)
|
||||
Spacer(minLength: 0)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
legend("System", value: point?.cpuSystem, color: Color.red)
|
||||
legend("User", value: point?.cpuUser, color: Color.green)
|
||||
}
|
||||
HStack {
|
||||
legend("I/O Wait", value: point?.cpuWait, color: .purple)
|
||||
legend("Steal", value: point?.cpuSteal, color: Color.orange)
|
||||
.help("Steal: time a virtual CPU is ready to run but waiting for the hypervisor.")
|
||||
}
|
||||
}
|
||||
.font(.system(size: 30, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit().lineLimit(1).minimumScaleFactor(0.7)
|
||||
.frame(width: 104, height: 40, alignment: .leading)
|
||||
cpuLegend
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
HStack(spacing: 2) {
|
||||
ForEach(0..<48, id: \.self) { index in
|
||||
RoundedRectangle(cornerRadius: 1).fill(cpuColor(at: Double(index) / 48 * 100))
|
||||
}
|
||||
}.frame(height: 12).accessibilityLabel(Text("CPU time breakdown"))
|
||||
cpuBars
|
||||
HStack {
|
||||
figure("Cores", value: point?.coreCount.map(String.init) ?? "—")
|
||||
figure("Idle", value: MetricsFormat.percent(point?.cpuIdle))
|
||||
@@ -216,6 +204,33 @@ public struct MetricsSystemPanels: View {
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var cpuLegend: some View {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 12) {
|
||||
legend("System", value: point?.cpuSystem, color: Color.red)
|
||||
legend("User", value: point?.cpuUser, color: Color.green)
|
||||
legend("I/O Wait", value: point?.cpuWait, color: .purple)
|
||||
stealLegend
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
legend("System", value: point?.cpuSystem, color: Color.red)
|
||||
legend("User", value: point?.cpuUser, color: Color.green)
|
||||
}
|
||||
GridRow {
|
||||
legend("I/O Wait", value: point?.cpuWait, color: .purple)
|
||||
stealLegend
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var stealLegend: some View {
|
||||
legend("Steal", value: point?.cpuSteal, color: Color.orange)
|
||||
.help("Steal: time a virtual CPU is ready to run but waiting for the hypervisor.")
|
||||
}
|
||||
|
||||
private func legend(_ title: LocalizedStringResource, value: Double?, color: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 3) {
|
||||
@@ -224,18 +239,52 @@ public struct MetricsSystemPanels: View {
|
||||
}
|
||||
.font(.system(size: 8)).foregroundStyle(.secondary)
|
||||
Text(MetricsFormat.percent(value)).font(.system(size: 10, weight: .semibold, design: .monospaced))
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
// Reserve enough room for 100.0% so sampling never shifts the columns.
|
||||
.frame(minWidth: 40, maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func cpuColor(at position: Double) -> Color {
|
||||
guard let point, point.cpu != nil else { return Color.secondary.opacity(0.2) }
|
||||
var boundary = point.cpuSystem ?? 0
|
||||
private var cpuBars: some View {
|
||||
Group {
|
||||
if let cores = point?.cpuCores, !cores.isEmpty {
|
||||
VStack(spacing: 3) {
|
||||
ForEach(cores) { cpu in
|
||||
cpuBar(cpu)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Text(AppStrings.Metrics.cpuCore(cpu.id)))
|
||||
.accessibilityValue(MetricsFormat.percent(cpu.utilization))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cpuBar(
|
||||
MetricsCPUPoint(
|
||||
id: 0, utilization: point?.cpu, user: point?.cpuUser, system: point?.cpuSystem,
|
||||
wait: point?.cpuWait, steal: point?.cpuSteal, idle: point?.cpuIdle)
|
||||
)
|
||||
.accessibilityLabel(Text(AppStrings.Metrics.cpuTimeBreakdown))
|
||||
.accessibilityValue(MetricsFormat.percent(point?.cpu))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cpuBar(_ cpu: MetricsCPUPoint) -> some View {
|
||||
HStack(spacing: 2) {
|
||||
ForEach(0..<48, id: \.self) { index in
|
||||
RoundedRectangle(cornerRadius: 1)
|
||||
.fill(cpuColor(at: Double(index) / 48 * 100, cpu: cpu))
|
||||
}
|
||||
}.frame(height: 8)
|
||||
}
|
||||
|
||||
private func cpuColor(at position: Double, cpu: MetricsCPUPoint) -> Color {
|
||||
guard cpu.utilization != nil else { return Color.secondary.opacity(0.2) }
|
||||
var boundary = cpu.system ?? 0
|
||||
if position < boundary { return Color.red }
|
||||
boundary += point.cpuUser ?? 0
|
||||
boundary += cpu.user ?? 0
|
||||
if position < boundary { return Color.green }
|
||||
boundary += point.cpuWait ?? 0
|
||||
boundary += cpu.wait ?? 0
|
||||
if position < boundary { return .purple }
|
||||
boundary += point.cpuSteal ?? 0
|
||||
boundary += cpu.steal ?? 0
|
||||
if position < boundary { return Color.orange }
|
||||
return Color.secondary.opacity(0.2)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ extension AppStrings {
|
||||
"CPU"
|
||||
}
|
||||
|
||||
public static func cpuCore(_ id: Int) -> LocalizedStringResource {
|
||||
"CPU \(id)"
|
||||
}
|
||||
|
||||
public static var cpuTimeBreakdown: LocalizedStringResource {
|
||||
"CPU time breakdown"
|
||||
}
|
||||
|
||||
public static var defaultNetworkInterfaces: LocalizedStringResource {
|
||||
"Default Network Interfaces"
|
||||
}
|
||||
|
||||
@@ -231,7 +231,8 @@ nonisolated struct MobileMetricsPanels {
|
||||
&& MobileMetricsDetailPage.showsBothColumns(width: regions.continuous.width)
|
||||
{
|
||||
let bounds = regions.continuous
|
||||
let statusWidth = min(360, bounds.width * 0.4)
|
||||
// Leave about 400 points for the cards after the scroll content's horizontal padding.
|
||||
let statusWidth = min(432, bounds.width * 0.4)
|
||||
status = CGRect(x: bounds.minX, y: bounds.minY, width: statusWidth, height: bounds.height)
|
||||
processes = CGRect(
|
||||
x: bounds.minX + statusWidth + 16, y: bounds.minY,
|
||||
@@ -275,12 +276,16 @@ private struct MobileMetricsProcessesView: View {
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.frame(minWidth: 64, alignment: .leading)
|
||||
.layoutPriority(1)
|
||||
Text(process.name).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text(process.name)
|
||||
.lineLimit(1).truncationMode(.tail)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
VStack(alignment: .trailing) {
|
||||
Text(MetricsFormat.percent(process.cpu))
|
||||
Text(MetricsFormat.bytes(process.memory)).foregroundStyle(.secondary)
|
||||
}.font(.caption.monospaced())
|
||||
}
|
||||
.font(.caption.monospaced())
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ struct MetricsDetailLayout: Layout {
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
|
||||
guard subviews.count == 2 else { return }
|
||||
let statusWidth = split ? min(340, max(270, (bounds.width + 32) * 0.38)) : bounds.width
|
||||
let statusWidth = split ? min(400, max(270, (bounds.width + 32) * 0.38)) : bounds.width
|
||||
let processWidth = split ? max(0, bounds.width - statusWidth - 16) : bounds.width
|
||||
let showsStatus = split || page == .status
|
||||
let showsProcesses = split || page == .processes
|
||||
|
||||
@@ -257,8 +257,8 @@ struct MetricsProcessList: View {
|
||||
}
|
||||
.width(48)
|
||||
TableColumn(AppStrings.Metrics.process.swiftUIKey, value: \.name) { process in
|
||||
Text(process.name).lineLimit(1).help(process.name).frame(height: 28)
|
||||
}.width(min: 80, ideal: 180)
|
||||
Text(process.name).lineLimit(1).truncationMode(.tail).help(process.name).frame(height: 28)
|
||||
}.width(min: 0, ideal: 180)
|
||||
TableColumn(AppStrings.Metrics.cpu.swiftUIKey, value: \.cpuSortValue) { process in
|
||||
Text(MetricsFormat.percent(process.cpu)).frame(height: 28)
|
||||
}.width(
|
||||
|
||||
Reference in new issue
Block a user