diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 2fab5839..7ce26a33 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Frameworks/TermindDomain/Metrics/MetricsCPUPoint.swift b/Frameworks/TermindDomain/Metrics/MetricsCPUPoint.swift new file mode 100644 index 00000000..0fdf6dfc --- /dev/null +++ b/Frameworks/TermindDomain/Metrics/MetricsCPUPoint.swift @@ -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 + } +} diff --git a/Frameworks/TermindDomain/Metrics/ServerMetrics.swift b/Frameworks/TermindDomain/Metrics/ServerMetrics.swift index 594a9b8f..e4507f0f 100644 --- a/Frameworks/TermindDomain/Metrics/ServerMetrics.swift +++ b/Frameworks/TermindDomain/Metrics/ServerMetrics.swift @@ -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] { diff --git a/Frameworks/TermindInfrastructure/LinuxMetricsParser.swift b/Frameworks/TermindInfrastructure/LinuxMetricsParser.swift index e525fe32..f6413d3a 100644 --- a/Frameworks/TermindInfrastructure/LinuxMetricsParser.swift +++ b/Frameworks/TermindInfrastructure/LinuxMetricsParser.swift @@ -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) diff --git a/Frameworks/TermindPresentation/MetricsSystemPanels.swift b/Frameworks/TermindPresentation/MetricsSystemPanels.swift index a0fbe925..98b39358 100644 --- a/Frameworks/TermindPresentation/MetricsSystemPanels.swift +++ b/Frameworks/TermindPresentation/MetricsSystemPanels.swift @@ -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) } diff --git a/Frameworks/TermindPresentation/Strings/Metrics.swift b/Frameworks/TermindPresentation/Strings/Metrics.swift index 0833aa54..fb7e493e 100644 --- a/Frameworks/TermindPresentation/Strings/Metrics.swift +++ b/Frameworks/TermindPresentation/Strings/Metrics.swift @@ -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" } diff --git a/TFI/Features/Metrics/MobileMetricsView.swift b/TFI/Features/Metrics/MobileMetricsView.swift index c0d52aa3..cfe51494 100644 --- a/TFI/Features/Metrics/MobileMetricsView.swift +++ b/TFI/Features/Metrics/MobileMetricsView.swift @@ -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) } } } diff --git a/TFM/Features/Metrics/MetricsDetailLayout.swift b/TFM/Features/Metrics/MetricsDetailLayout.swift index 6328c9ef..7faf22bc 100644 --- a/TFM/Features/Metrics/MetricsDetailLayout.swift +++ b/TFM/Features/Metrics/MetricsDetailLayout.swift @@ -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 diff --git a/TFM/Features/Metrics/MetricsView.swift b/TFM/Features/Metrics/MetricsView.swift index b43d2dc1..878b8d29 100644 --- a/TFM/Features/Metrics/MetricsView.swift +++ b/TFM/Features/Metrics/MetricsView.swift @@ -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(