import SwiftUI import MetricsProvider @MainActor struct NetworkPopoverContent: View { let model: NetworkModel var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top) { rate(model.snapshot.downloadRate, "network.download", .green) Spacer() rate(model.snapshot.uploadRate, "network.upload", .pink) } TrafficChart(history: model.history) .frame(height: 56) HStack { peak(model.history.map(\.downloadRate).max() ?? 0, "network.peak.download") Spacer() peak(model.history.map(\.uploadRate).max() ?? 0, "network.peak.upload") } Divider() Label(connectionName, systemImage: model.snapshot.interfaceKind.symbolName) .font(.callout) if model.snapshot.interfaceKind == .vpn { // Ein VPN verändert, was die Zahlen darüber bedeuten — der // Verkehr läuft dann durch den Tunnel, nicht direkt. Label("network.vpn.active", systemImage: "lock.shield") .font(.caption) .foregroundStyle(.secondary) } section("network.addresses") field("network.field.interface", model.snapshot.interfaceName) field("network.field.ipv4", model.snapshot.ipv4) field("network.field.ipv6", model.snapshot.ipv6) field("network.field.router", model.snapshot.router) section("network.transferred") field("network.total.received", bytes(model.snapshot.totalReceived)) field("network.total.sent", bytes(model.snapshot.totalSent)) // Kein Durchsatz je Programm: dafür gibt es ohne root keine // Schnittstelle, und `nettop` im Sekundentakt zu starten kostete // mehr als der gesamte übrige Monitor. Text("network.perProcess.unavailable", bundle: .module) .font(.caption2) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) } .padding(14) .frame(width: 280) } private var connectionName: String { if let ssid = model.snapshot.ssid { return ssid } if model.snapshot.isWiFiConnected { return String(localized: "network.wifi.connected", bundle: .module) } return model.snapshot.interfaceName ?? String(localized: "network.offline", bundle: .module) } private func rate(_ value: Double, _ key: LocalizedStringKey, _ tint: Color) -> some View { VStack(alignment: .leading, spacing: 1) { Text(Throughput.formatted(value)) .font(.system(size: 18, weight: .medium).monospacedDigit()) HStack(spacing: 4) { Circle().fill(tint).frame(width: 6, height: 6) Text(key, bundle: .module).font(.caption).foregroundStyle(.secondary) } } } private func peak(_ value: Double, _ key: LocalizedStringKey) -> some View { HStack(spacing: 4) { Text(key, bundle: .module).font(.caption2).foregroundStyle(.secondary) Text(Throughput.formatted(value)).font(.caption2.monospacedDigit()) } } private func section(_ key: LocalizedStringKey) -> some View { Text(key, bundle: .module) .font(.caption.weight(.semibold)) .foregroundStyle(.tint) .textCase(.uppercase) } @ViewBuilder private func field(_ key: LocalizedStringKey, _ value: String?) -> some View { if let value { HStack { Text(key, bundle: .module).font(.callout).foregroundStyle(.secondary) Spacer(minLength: 12) Text(value).font(.callout.monospacedDigit()).textSelection(.enabled) } } } private func bytes(_ value: UInt64) -> String { ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .binary) } } /// Verlauf in beide Richtungen: Download nach unten, Upload nach oben. /// /// Zwei getrennte Kurven übereinander wären platzsparender, aber schwerer zu /// lesen — hier sieht man sofort, ob gerade geladen oder gesendet wird, und in /// welchem Verhältnis. Beide Richtungen teilen sich denselben Maßstab, sonst /// sähe ein Upload von 20 KB/s genauso hoch aus wie ein Download von 20 MB/s. private struct TrafficChart: View { let history: [NetworkSnapshot] var body: some View { GeometryReader { geometry in let points = history.suffix(60) let peak = max(points.map { max($0.downloadRate, $0.uploadRate) }.max() ?? 1, 1) let width = geometry.size.width / CGFloat(max(points.count, 1)) let half = geometry.size.height / 2 HStack(alignment: .center, spacing: 1) { ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in VStack(spacing: 1) { Rectangle() .fill(.pink) .frame(height: max(half * CGFloat(snapshot.uploadRate / peak), 0.5)) .frame(maxHeight: .infinity, alignment: .bottom) Rectangle() .fill(.green) .frame(height: max(half * CGFloat(snapshot.downloadRate / peak), 0.5)) .frame(maxHeight: .infinity, alignment: .top) } .frame(width: max(width - 1, 1)) } } .frame(width: geometry.size.width, height: geometry.size.height) } } }