Add network ping and speed test sections

This commit is contained in:
David Heinemeier Hansson
2026-06-29 09:44:29 -05:00
parent ca91dc3fec
commit 98c7f5b28c
4 changed files with 305 additions and 28 deletions
+35 -5
View File
@@ -112,12 +112,13 @@ function appendPingSample(samples, raw, limit) {
return values
}
function averagePingLatency(samples) {
function averagePingLatency(samples, limit) {
var values = Array.isArray(samples) ? samples : []
var sampleLimit = Math.max(1, parseInt(limit, 10) || values.length || 1)
var total = 0
var count = 0
for (var i = 0; i < values.length; i++) {
for (var i = Math.max(0, values.length - sampleLimit); i < values.length; i++) {
var value = values[i]
if (typeof value !== "number" || !isFinite(value) || value < 0) continue
total += value
@@ -127,11 +128,30 @@ function averagePingLatency(samples) {
return count > 0 ? total / count : -1
}
function pingLatencyState(previous, next, limit) {
function pingPacketLossPercent(samples) {
var values = Array.isArray(samples) ? samples : []
if (values.length === 0) return 0
var lost = 0
for (var i = 0; i < values.length; i++) {
if (values[i] === null) lost++
}
return Math.round((lost / values.length) * 100)
}
function formatPacketLoss(percent) {
var value = parseInt(percent, 10)
if (!value || value < 0) return "0%"
return value + "%"
}
function pingLatencyState(previous, next, limit, averageLimit) {
var prev = previous || {}
var sample = next || {}
var iface = sample.iface || ""
var window = Math.max(1, parseInt(limit, 10) || 5)
var averageWindow = Math.max(1, parseInt(averageLimit, 10) || window)
var reset = iface === "" || iface !== (prev.pingIface || "")
var routerSamples = reset ? [] : prev.routerPingSamples
var internetSamples = reset ? [] : prev.internetPingSamples
@@ -143,8 +163,9 @@ function pingLatencyState(previous, next, limit) {
pingIface: iface,
routerPingSamples: routerSamples,
internetPingSamples: internetSamples,
routerPingLatency: averagePingLatency(routerSamples),
internetPingLatency: averagePingLatency(internetSamples)
routerPingLatency: averagePingLatency(routerSamples, averageWindow),
internetPingLatency: averagePingLatency(internetSamples, averageWindow),
internetPingPacketLoss: pingPacketLossPercent(internetSamples)
}
}
@@ -161,6 +182,12 @@ function formatRate(bytesPerSec) {
return formatBytes(bytesPerSec) + "/s"
}
function formatSpeedMbps(mbps) {
var value = parseFloat(mbps)
if (!isFinite(value) || value <= 0) return "--"
return value.toFixed(value > 0 && value < 10 ? 1 : 0) + " Mbps"
}
function formatPingLatency(ms) {
var value = parseFloat(ms)
if (!isFinite(value) || value < 0) return "Timeout"
@@ -226,8 +253,11 @@ if (typeof module !== "undefined") {
parseKeyValue: parseKeyValue,
throughputState: throughputState,
pingLatencyState: pingLatencyState,
pingPacketLossPercent: pingPacketLossPercent,
formatPacketLoss: formatPacketLoss,
formatBytes: formatBytes,
formatRate: formatRate,
formatSpeedMbps: formatSpeedMbps,
formatPingLatency: formatPingLatency,
wifiRow: wifiRow,
sortWifiRows: sortWifiRows,
+173 -19
View File
@@ -37,13 +37,10 @@ Panel {
property var internetPingSamples: []
property real routerPingLatency: -1
property real internetPingLatency: -1
readonly property int pingWindow: 5
readonly property bool hasRouterPing: routerPingSamples.length > 0
property int internetPingPacketLoss: 0
readonly property int pingHistoryWindow: 24
readonly property int pingAverageWindow: 5
readonly property bool hasInternetPing: internetPingSamples.length > 0
readonly property bool hasPing: hasRouterPing || hasInternetPing
readonly property bool hasSecondPing: hasRouterPing && hasInternetPing
readonly property string primaryPingLabel: hasRouterPing ? "Router Ping" : (hasInternetPing ? "Internet Ping" : "")
readonly property real primaryPingLatency: hasRouterPing ? routerPingLatency : internetPingLatency
property int connectionPhraseIndex: 0
readonly property var connectionPhrases: [
"Wiring bits",
@@ -65,6 +62,14 @@ Panel {
property bool wifiStationAvailable: false
property string dnsProvider: ""
property string pendingDnsProvider: ""
property bool speedTestRunning: false
property bool speedTestHasRun: false
property bool speedTestExpectedStop: false
property string speedTestPhase: ""
property string speedTestStderr: ""
property string speedTestDownloadMbps: ""
property string speedTestUploadMbps: ""
property string speedTestError: ""
// Per-row in-flight state. `actionSsid` flips on for the row whose action
// is currently running so it can render "Connecting…" / "Disconnecting…" /
@@ -158,6 +163,7 @@ Panel {
internetPingSamples = []
routerPingLatency = -1
internetPingLatency = -1
internetPingPacketLoss = 0
if (wifiDevice) wifiDevice.scannerEnabled = false
}
}
@@ -322,13 +328,14 @@ Panel {
pingIface: pingIface,
routerPingSamples: routerPingSamples,
internetPingSamples: internetPingSamples
}, next, pingWindow)
}, next, pingHistoryWindow, pingAverageWindow)
pingIface = state.pingIface
routerPingSamples = state.routerPingSamples
internetPingSamples = state.internetPingSamples
routerPingLatency = state.routerPingLatency
internetPingLatency = state.internetPingLatency
internetPingPacketLoss = state.internetPingPacketLoss
}
function formatBytes(bytes) {
@@ -339,10 +346,18 @@ Panel {
return Model.formatRate(bytesPerSec)
}
function formatSpeedMbps(mbps) {
return Model.formatSpeedMbps(mbps)
}
function formatPingLatency(ms) {
return Model.formatPingLatency(ms)
}
function formatPacketLoss(percent) {
return Model.formatPacketLoss(percent)
}
function findDevice(type) {
var devices = networkDevices || []
for (var i = 0; i < devices.length; i++) {
@@ -388,6 +403,53 @@ Panel {
dnsProvider = value || "DHCP"
}
function updateSpeedTestLine(line) {
var value = parseFloat(line)
if (!isFinite(value) || value < 0) return
if (speedTestPhase === "down") speedTestDownloadMbps = String(value)
else if (speedTestPhase === "up") speedTestUploadMbps = String(value)
speedTestError = ""
}
function runSpeedTest() {
if (speedTestProc.running) return
speedTestError = ""
speedTestHasRun = true
speedTestRunning = true
startSpeedTestPhase("down")
}
function startSpeedTestPhase(phase) {
speedTestExpectedStop = false
speedTestPhase = phase
speedTestStderr = ""
speedTestProc.command = ["omarchy-network-speedtest", phase]
speedTestProc.running = true
speedTestPhaseTimer.restart()
}
function stopSpeedTestPhase() {
speedTestPhaseTimer.stop()
if (speedTestProc.running) {
speedTestExpectedStop = true
speedTestProc.running = false
return
}
finishSpeedTestPhase()
}
function finishSpeedTestPhase() {
if (speedTestPhase === "down") {
startSpeedTestPhase("up")
return
}
speedTestPhase = ""
speedTestRunning = false
speedTestExpectedStop = false
}
function dnsCommand(provider) {
var command = "omarchy-dns"
if (provider) command += " " + Util.shellQuote(provider)
@@ -541,6 +603,35 @@ Panel {
}
}
Process {
id: speedTestProc
stdout: SplitParser { onRead: function(line) { root.updateSpeedTestLine(line) } }
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: root.speedTestStderr = String(text || "").trim()
}
onExited: function(exitCode) {
speedTestPhaseTimer.stop()
if (!root.speedTestExpectedStop && exitCode !== 0) {
root.speedTestError = root.speedTestStderr || "Speed test failed"
root.speedTestPhase = ""
root.speedTestRunning = false
return
}
root.speedTestExpectedStop = false
root.finishSpeedTestPhase()
}
}
Timer {
id: speedTestPhaseTimer
interval: 5000
repeat: false
onTriggered: root.stopSpeedTestPhase()
}
// Action runner for DNS provider changes. Wi-Fi actions use the
// Quickshell.Networking NetworkManager backend directly.
Process {
@@ -838,21 +929,17 @@ Panel {
columnSpacing: Style.space(20)
rowSpacing: Style.spacing.labelGap
InfoLabel {
visible: root.hasPing
text: root.primaryPingLabel
}
InfoLabel { visible: root.hasInternetPing; text: "Ping" }
DetailValue {
visible: root.hasPing
text: root.formatPingLatency(root.primaryPingLatency)
}
InfoLabel {
visible: root.hasPing
text: root.hasSecondPing ? "Internet Ping" : ""
visible: root.hasInternetPing
text: root.formatPingLatency(root.internetPingLatency)
color: root.internetPingPacketLoss > 0 ? root.bar.urgent : root.bar.foreground
}
InfoLabel { visible: root.hasInternetPing; text: "Packet Loss" }
DetailValue {
visible: root.hasPing
text: root.hasSecondPing ? root.formatPingLatency(root.internetPingLatency) : ""
visible: root.hasInternetPing
text: root.formatPacketLoss(root.internetPingPacketLoss)
color: root.internetPingPacketLoss > 0 ? root.bar.urgent : root.bar.foreground
}
InfoLabel { visible: root.info.rx_bytes !== undefined; text: "Receiving" }
@@ -888,6 +975,73 @@ Panel {
}
}
PanelSeparator {
visible: !!root.info.iface
foreground: root.bar.foreground
}
Column {
visible: !!root.info.iface
width: parent.width
spacing: Style.space(12)
Column {
width: parent.width
spacing: Style.space(8)
Item {
width: parent.width
implicitHeight: Math.max(speedTestHeader.implicitHeight, speedRunButton.implicitHeight)
PanelSectionHeader {
id: speedTestHeader
text: "SPEED TEST"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
Button {
id: speedRunButton
text: root.speedTestRunning ? "Running..." : "Run"
enabled: !root.speedTestRunning
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.spacing.controlPaddingX
verticalPadding: Style.spacing.controlPaddingY
bordered: true
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
onClicked: root.runSpeedTest()
}
}
Row {
id: speedTestValues
visible: root.speedTestHasRun
width: parent.width
spacing: Style.space(20)
readonly property real cellWidth: Math.max(0, (width - spacing * 3) / 4)
InfoLabel { width: speedTestValues.cellWidth; text: "Download" }
DetailValue { width: speedTestValues.cellWidth; text: root.formatSpeedMbps(root.speedTestDownloadMbps) }
InfoLabel { width: speedTestValues.cellWidth; text: "Upload" }
DetailValue { width: speedTestValues.cellWidth; text: root.formatSpeedMbps(root.speedTestUploadMbps) }
}
InfoValue {
visible: root.speedTestError !== ""
text: root.speedTestError
color: root.bar.urgent
width: parent.width
elide: Text.ElideRight
}
}
}
// DNS provider selection.
PanelSeparator {
foreground: root.bar.foreground