feat(metrics): add per-core CPU bars and adaptive layouts

This commit is contained in:
Kaede Akino committed 2026-10-01 22:56:20 +08:00
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"
}
+9 -4
View File
@@ -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
+2 -2
View File
@@ -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(