Files
Termind/Frameworks/TermindPresentation/MetricsSystemPanels.swift
T
2026-09-14 09:05:43 +08:00

249 lines
11 KiB
Swift

import SwiftUI
import TermindApplication
import TermindDomain
/// Independently scrollable system panels; the process table owns its own viewport.
public struct MetricsSystemPanels: View {
public init(server: ServerMetric) { self.server = server }
let server: ServerMetric
private var point: MetricsPoint? { server.point }
@State private var virtualInterfacesExpanded = false
public var body: some View {
VStack(spacing: 12) {
panel {
HStack(alignment: .top, 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.")
}
}
}
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"))
HStack {
figure("Cores", value: point?.coreCount.map(String.init) ?? "—")
figure("Idle", value: MetricsFormat.percent(point?.cpuIdle))
figure("Uptime", value: uptime)
}
LabeledContent(
"Load Average",
value: point?.load.map { $0.formatted(.number.precision(.fractionLength(2))) }.joined(
separator: " / ") ?? "—"
)
.font(.caption2).foregroundStyle(.secondary)
}
panel {
HStack {
figure("Free Memory", value: MetricsFormat.bytes(point?.memoryFree))
figure("Used", value: MetricsFormat.bytes(point?.memoryUsed))
figure("Page Cache", value: MetricsFormat.bytes(point?.memoryCached))
MetricsRing(title: "Memory", value: point?.memory)
}
}
if let swapTotal = point?.swapTotal, swapTotal > 0 {
panel {
HStack {
figure("Free Memory", value: MetricsFormat.bytes(point?.swapFree))
figure("Used", value: MetricsFormat.bytes(point?.swapUsed))
figure("Total", value: MetricsFormat.bytes(point?.swapTotal))
MetricsRing(title: "Swap", value: point?.swapPercent)
}
}
}
if server.gpuSnapshot?.devices.isEmpty == false {
MetricsGPUSection(server: server)
}
networkPanel
if server.filesystemsFailed {
Text("Filesystem metrics unavailable").font(.caption).foregroundStyle(.secondary)
}
ForEach(server.filesystems) { filesystem in
filesystemPanel(filesystem)
}
}
}
private var hardwareInterfaces: [MetricsDevicePoint] {
(point?.networkDevices ?? []).filter { $0.kind != .virtual }
}
private var virtualInterfaces: [MetricsDevicePoint] { (point?.networkDevices ?? []).filter { $0.kind == .virtual } }
private var networkPanel: some View {
panel {
HStack(spacing: 12) {
VStack(spacing: 8) {
figure("Send Speed", value: MetricsFormat.rate(point?.networkSend))
figure("Receive Speed", value: MetricsFormat.rate(point?.networkReceive))
}
VStack(spacing: 8) {
figure(
"Total Sent", value: MetricsFormat.bytes(point?.networkSentTotal), color: Color.orange)
figure(
"Total Received", value: MetricsFormat.bytes(point?.networkReceivedTotal),
color: Color.green)
}
trafficRing
}
HStack(spacing: 6) {
figure("Retransmissions", value: MetricsFormat.percent(point?.tcpRetransmissionPercent))
figure("Active Opens", value: point?.tcp?.active?.formatted() ?? "—")
figure("Passive Opens", value: point?.tcp?.passive?.formatted() ?? "—")
figure("Failed Attempts", value: point?.tcp?.failures?.formatted() ?? "—")
}
ForEach(hardwareInterfaces) { interface in
Divider()
interfaceRow(interface)
}
if !virtualInterfaces.isEmpty {
DisclosureGroup(isExpanded: $virtualInterfacesExpanded) {
VStack(spacing: 12) {
ForEach(virtualInterfaces) { interface in
Divider()
interfaceRow(interface)
}
}
} label: {
HStack {
Text("Virtual Interfaces")
Spacer()
Text(virtualInterfaces.count.formatted()).monospacedDigit()
}.font(.caption).foregroundStyle(.secondary)
}
.tint(Color.secondary)
}
}
}
private func interfaceRow(_ device: MetricsDevicePoint) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Label(device.id, systemImage: "network").font(.caption.bold())
Spacer()
Text(device.addresses.joined(separator: ", ")).font(.system(size: 9)).foregroundStyle(.secondary)
.lineLimit(1).help(device.addresses.joined(separator: "\n"))
}
HStack(spacing: 6) {
figure("Send Speed", value: MetricsFormat.rate(device.write))
figure("Receive Speed", value: MetricsFormat.rate(device.read))
figure("Total Sent", value: MetricsFormat.bytes(device.writeTotal))
figure("Total Received", value: MetricsFormat.bytes(device.readTotal))
}
}
}
private var trafficRing: some View {
let sent = point?.networkSentTotal ?? 0
let received = point?.networkReceivedTotal ?? 0
let fraction = sent + received > 0 ? sent / (sent + received) : 0
return ZStack {
Circle().stroke(.quaternary, lineWidth: 4)
if sent + received > 0 {
Circle().trim(from: 0, to: fraction).stroke(Color.orange, lineWidth: 4)
Circle().trim(from: fraction, to: 1).stroke(Color.green, lineWidth: 4)
}
}
.rotationEffect(.degrees(-90)).frame(width: 34, height: 34)
.accessibilityLabel(Text("Sent / Received Traffic"))
.accessibilityValue(MetricsFormat.capacity(point?.networkSentTotal, point?.networkReceivedTotal))
}
private func filesystemPanel(_ filesystem: MetricsFilesystem) -> some View {
let io = point?.blockDevices[filesystem.blockID]
return panel {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text(filesystem.mountPoint).font(.headline).lineLimit(1).help(filesystem.mountPoint)
Text(filesystem.device).font(.caption).foregroundStyle(.secondary).lineLimit(1).help(
filesystem.device)
}
Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: 4) {
Text(filesystem.type.uppercased()).font(.system(size: 9)).foregroundStyle(.secondary)
Text(MetricsFormat.capacity(filesystem.used, filesystem.total))
.font(.system(size: 10, weight: .medium, design: .monospaced)).lineLimit(1).minimumScaleFactor(
0.7)
}
capacityBar(filesystem.usedPercent)
}
MetricsDiskTable(io: io)
}
}
private func capacityBar(_ percent: Double?) -> some View {
let color =
(percent ?? 0) >= 90
? Color.red : (percent ?? 0) >= 75 ? Color.orange : Color.green
return RoundedRectangle(cornerRadius: 3).fill(.quaternary)
.overlay(alignment: .bottom) {
if let percent {
RoundedRectangle(cornerRadius: 3).fill(color).frame(height: 36 * min(1, max(0, percent / 100)))
}
}
.frame(width: 8, height: 36)
.accessibilityLabel(Text("Disk Usage"))
.accessibilityValue(MetricsFormat.percent(percent))
}
private func panel<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 12, content: content)
.padding(12).frame(maxWidth: .infinity, alignment: .leading)
.background(.background, in: RoundedRectangle(cornerRadius: 12))
.overlay {
RoundedRectangle(cornerRadius: 12).strokeBorder(.quaternary, lineWidth: 1)
}
}
private func figure(_ title: LocalizedStringKey, value: String, color: Color = Color.primary)
-> some View
{
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.system(size: 9)).foregroundStyle(.secondary).lineLimit(1).minimumScaleFactor(0.75)
Text(value).font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundStyle(color).lineLimit(1).minimumScaleFactor(0.65)
}.frame(maxWidth: .infinity, alignment: .leading)
}
private func legend(_ title: LocalizedStringKey, value: Double?, color: Color) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 3) {
Rectangle().fill(color).frame(width: 3, height: 6)
Text(title).lineLimit(1)
}
.font(.system(size: 8)).foregroundStyle(.secondary)
Text(MetricsFormat.percent(value)).font(.system(size: 10, weight: .semibold, design: .monospaced))
}.frame(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
if position < boundary { return Color.red }
boundary += point.cpuUser ?? 0
if position < boundary { return Color.green }
boundary += point.cpuWait ?? 0
if position < boundary { return .purple }
boundary += point.cpuSteal ?? 0
if position < boundary { return Color.orange }
return Color.secondary.opacity(0.2)
}
private var uptime: String {
guard let seconds = point?.uptime, seconds >= 0, seconds < Double(Int64.max) else { return "—" }
return Duration.seconds(seconds).formatted(
.units(allowed: [.days, .hours, .minutes], width: .abbreviated, maximumUnitCount: 2))
}
}