Phase 5: Hardware- und Netzwerkmonitor, Panel und Menüleiste
Sechs neue Widgets (CPU, GPU, Speicher, Akku, Sensoren, Netzwerk) und dieselben sechs als einzeln aktivierbare Menüleisten-Module mit fünf Darstellungsarten. Panel-Widget und Menüleisten-Modul derselben Größe teilen sich die Messschleife über Referenzzählung. Ohne das liefe für jede Anzeige ein eigener Timer mit denselben IOKit- und SMC-Abfragen — bei sechs Modulen und ebenso vielen Widgets ein Vielfaches der nötigen Arbeit. Meldet sich der letzte Konsument ab, hört die Schleife auf; im Ruhezustand misst Onyx nichts. Die Differenzrechnung ist testgetrieben, und die Tests haben zwei echte Fehler gefunden. Erstens: mein Überlaufschutz beim Durchsatz hielt einen zurückgesetzten Zähler (Interface-Wechsel) für einen Überlauf und zeigte 4,3 GB/s. Da if_data64 64-Bit-Zähler liefert, die bei 10 Gbit/s erst nach 470 Jahren umlaufen, ist ein kleinerer Wert immer ein Zurücksetzen — die Unterscheidung produzierte nur den Ausreißer, den sie verhindern sollte. Zweitens: ByteCountFormatter schrieb bei null „Zero KB/s" statt „0 KB/s". Bei den CPU-Zählern bleibt der Überlaufschutz nötig: die sind 32 Bit breit und laufen nach gut 400 Tagen wirklich um, deshalb `&-` statt `-`. Sensoren kommen über den SMC-Leser aus Spike A statt über die private IOHID-Schnittstelle — auf dieser Maschine nachweislich verifiziert, und die dort gefundene Little-Endian-Eigenheit ist berücksichtigt. Nur eine kurze Liste benannter Fühler statt aller 3486 Keys: eine Wand aus Kürzeln ist keine Information. Gegengemessen statt vermutet: 15 Kerne, 39,8 W, GPU 50 %, 16,6/24 GB mit kritischem Druck, Akku 68 Zyklen bei 100 % Gesundheit, fünf Sensoren, zwei Lüfter, en0 mit 661 KB/s. Menüleisten-Module zeichnen in labelColor statt in den Onyx-Farben — nur die Systemfarbe passt sich heller wie dunkler Leiste an. Feste Breite je Darstellungsart, sonst schiebt jeder Messwert die halbe Leiste hin und her. Standardmäßig ist kein Modul aktiv: sechs neue Symbole beim ersten Start wären eine Zumutung. 145 Tests grün.
This commit is contained in:
115
Packages/OnyxKit/Sources/MetricsProvider/Calculations.swift
Normal file
115
Packages/OnyxKit/Sources/MetricsProvider/Calculations.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
import Foundation
|
||||
|
||||
/// Die CPU-Zeitzähler eines Kerns, wie `host_processor_info` sie liefert.
|
||||
///
|
||||
/// Es sind **Summen seit dem Start**, keine Momentanwerte. Die Auslastung
|
||||
/// ergibt sich erst aus der Differenz zweier Abtastungen.
|
||||
public struct CPUTicks: Equatable, Sendable {
|
||||
public let user: UInt32
|
||||
public let system: UInt32
|
||||
public let idle: UInt32
|
||||
public let nice: UInt32
|
||||
|
||||
public init(user: UInt32, system: UInt32, idle: UInt32, nice: UInt32) {
|
||||
self.user = user
|
||||
self.system = system
|
||||
self.idle = idle
|
||||
self.nice = nice
|
||||
}
|
||||
|
||||
/// Auslastung zwischen zwei Abtastungen, 0 bis 1.
|
||||
public static func usage(from previous: CPUTicks, to current: CPUTicks) -> Double {
|
||||
// `&-` statt `-`: die Zähler sind 32 Bit breit und laufen nach gut
|
||||
// 400 Tagen über. Ein gewöhnlicher Abzug stürzt dabei ab oder liefert
|
||||
// einen absurden Sprung; die überlaufende Subtraktion ergibt genau die
|
||||
// richtige Differenz.
|
||||
let user = UInt64(current.user &- previous.user)
|
||||
let system = UInt64(current.system &- previous.system)
|
||||
let nice = UInt64(current.nice &- previous.nice)
|
||||
let idle = UInt64(current.idle &- previous.idle)
|
||||
|
||||
let busy = user + system + nice
|
||||
let total = busy + idle
|
||||
guard total > 0 else { return 0 }
|
||||
|
||||
return min(max(Double(busy) / Double(total), 0), 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie angespannt die Speicherlage ist.
|
||||
public enum MemoryPressure: Equatable, Sendable {
|
||||
case normal
|
||||
case warning
|
||||
case critical
|
||||
|
||||
/// - Parameters:
|
||||
/// - free: Anteil wirklich freien Speichers, 0 bis 1.
|
||||
/// - compressed: Anteil komprimierten Speichers, 0 bis 1.
|
||||
/// - swapUsed: belegter Auslagerungsspeicher in Byte.
|
||||
public static func classify(free: Double, compressed: Double, swapUsed: UInt64) -> MemoryPressure {
|
||||
// Auslagerung schlägt alles andere: sobald sie läuft, bremst der
|
||||
// Rechner spürbar — auch wenn die Freispeicheranzeige harmlos aussieht.
|
||||
// Ein bisschen Swap hat macOS fast immer angelegt, deshalb erst ab
|
||||
// einem halben Gigabyte.
|
||||
if swapUsed > 512 * 1024 * 1024 { return .critical }
|
||||
if free < 0.10 || compressed > 0.20 { return .warning }
|
||||
return .normal
|
||||
}
|
||||
}
|
||||
|
||||
/// Netzwerkdurchsatz aus zwei Zählerständen.
|
||||
public enum Throughput {
|
||||
|
||||
/// Byte pro Sekunde zwischen zwei Abtastungen.
|
||||
///
|
||||
/// Die Normierung auf die tatsächlich verstrichene Zeit ist der Kern: die
|
||||
/// Menüleisten-Module tasten je nach Einstellung alle 1 bis 10 Sekunden ab.
|
||||
/// Ohne sie zeigte ein Modul mit 5 Sekunden Intervall das Fünffache an.
|
||||
public static func rate(previous: UInt64, current: UInt64, elapsed: TimeInterval) -> Double {
|
||||
guard elapsed > 0 else { return 0 }
|
||||
|
||||
// Zähler kleiner als vorher heißt: er wurde zurückgesetzt. Das passiert
|
||||
// bei jedem Interface-Wechsel — WLAN aus, VPN an — und beim Neustart
|
||||
// des Dienstes.
|
||||
//
|
||||
// Ein Überlauf wäre die andere denkbare Erklärung, kommt hier aber
|
||||
// nicht vor: `if_data64` liefert 64-Bit-Zähler, die bei 10 Gbit/s
|
||||
// erst nach rund 470 Jahren umlaufen. Der Versuch, beides zu
|
||||
// unterscheiden, produziert nur den Ausreißer, den er verhindern soll —
|
||||
// deshalb null.
|
||||
guard current >= previous else { return 0 }
|
||||
|
||||
return Double(current - previous) / elapsed
|
||||
}
|
||||
|
||||
/// „2,4 MB/s" statt „2400000".
|
||||
public static func formatted(_ bytesPerSecond: Double) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .binary
|
||||
formatter.allowedUnits = [.useKB, .useMB, .useGB]
|
||||
formatter.zeroPadsFractionDigits = false
|
||||
// Ohne das steht bei null „Zero KB/s" statt „0 KB/s" — in einer Spalte
|
||||
// aus Zahlen fällt ein Wort unangenehm auf und ändert die Breite.
|
||||
formatter.allowsNonnumericFormatting = false
|
||||
return formatter.string(fromByteCount: Int64(max(bytesPerSecond, 0))) + "/s"
|
||||
}
|
||||
}
|
||||
|
||||
/// Einheitliche Zahlendarstellung für alle Messwerte.
|
||||
public enum MetricFormat {
|
||||
|
||||
/// Ganzzahlig. Nachkommastellen bei der Auslastung täuschen eine
|
||||
/// Genauigkeit vor, die die Messung nicht hat — und sie zappeln im
|
||||
/// Sekundentakt, was die Anzeige unruhig macht.
|
||||
public static func percent(_ fraction: Double) -> String {
|
||||
"\(Int((min(max(fraction, 0), 1) * 100).rounded())) %"
|
||||
}
|
||||
|
||||
public static func temperature(_ celsius: Double) -> String {
|
||||
"\(Int(celsius.rounded()))°"
|
||||
}
|
||||
|
||||
public static func watts(_ value: Double) -> String {
|
||||
String(format: "%.1f W", max(value, 0))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user