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
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
# omarchy:summary=Measure live internet speed for one direction
# omarchy:group=network
# omarchy:args=[down|up]
set -e
direction="${1:-}"
probe=1.1.1.1
download_bytes=25000000
upload_blocks=64
case "$direction" in
down | up)
;;
*)
echo "Usage: omarchy-network-speedtest [down|up]" >&2
exit 2
;;
esac
format_mbps() {
awk -v value="$1" 'BEGIN {
if (value <= 0) print "0.0"
else if (value < 10) printf "%.1f\n", value
else printf "%.0f\n", value
}'
}
iface=$(ip route get "$probe" 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }')
if [[ -z $iface || ! -r /sys/class/net/$iface/statistics/rx_bytes || ! -r /sys/class/net/$iface/statistics/tx_bytes ]]; then
echo "No active network interface" >&2
exit 1
fi
if ! omarchy-cmd-present curl; then
echo "curl is required" >&2
exit 1
fi
traffic_pid=""
cleanup() {
if [[ -n $traffic_pid ]]; then
pkill -TERM -P "$traffic_pid" 2>/dev/null || true
kill "$traffic_pid" 2>/dev/null || true
wait "$traffic_pid" 2>/dev/null || true
fi
}
trap cleanup EXIT
if [[ $direction == "down" ]]; then
while true; do
curl -fsS -o /dev/null "https://speed.cloudflare.com/__down?bytes=$download_bytes" 2>/dev/null || exit
done &
else
while true; do
dd if=/dev/zero bs=1M count="$upload_blocks" 2>/dev/null | curl -fsS -o /dev/null -X POST --data-binary @- "https://speed.cloudflare.com/__up" 2>/dev/null || exit
done &
fi
traffic_pid=$!
rx_before=$(cat "/sys/class/net/$iface/statistics/rx_bytes")
tx_before=$(cat "/sys/class/net/$iface/statistics/tx_bytes")
while kill -0 "$traffic_pid" 2>/dev/null; do
sleep 1
rx_after=$(cat "/sys/class/net/$iface/statistics/rx_bytes")
tx_after=$(cat "/sys/class/net/$iface/statistics/tx_bytes")
if [[ $direction == "down" ]]; then
rate=$(awk -v before="$rx_before" -v after="$rx_after" 'BEGIN {
if (after < before) print 0
else print (after - before) * 8 / 1000000
}')
else
rate=$(awk -v before="$tx_before" -v after="$tx_after" 'BEGIN {
if (after < before) print 0
else print (after - before) * 8 / 1000000
}')
fi
format_mbps "$rate"
rx_before=$rx_after
tx_before=$tx_after
done
wait "$traffic_pid" 2>/dev/null || true
+35 -5
View File
@@ -112,12 +112,13 @@ function appendPingSample(samples, raw, limit) {
return values return values
} }
function averagePingLatency(samples) { function averagePingLatency(samples, limit) {
var values = Array.isArray(samples) ? samples : [] var values = Array.isArray(samples) ? samples : []
var sampleLimit = Math.max(1, parseInt(limit, 10) || values.length || 1)
var total = 0 var total = 0
var count = 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] var value = values[i]
if (typeof value !== "number" || !isFinite(value) || value < 0) continue if (typeof value !== "number" || !isFinite(value) || value < 0) continue
total += value total += value
@@ -127,11 +128,30 @@ function averagePingLatency(samples) {
return count > 0 ? total / count : -1 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 prev = previous || {}
var sample = next || {} var sample = next || {}
var iface = sample.iface || "" var iface = sample.iface || ""
var window = Math.max(1, parseInt(limit, 10) || 5) 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 reset = iface === "" || iface !== (prev.pingIface || "")
var routerSamples = reset ? [] : prev.routerPingSamples var routerSamples = reset ? [] : prev.routerPingSamples
var internetSamples = reset ? [] : prev.internetPingSamples var internetSamples = reset ? [] : prev.internetPingSamples
@@ -143,8 +163,9 @@ function pingLatencyState(previous, next, limit) {
pingIface: iface, pingIface: iface,
routerPingSamples: routerSamples, routerPingSamples: routerSamples,
internetPingSamples: internetSamples, internetPingSamples: internetSamples,
routerPingLatency: averagePingLatency(routerSamples), routerPingLatency: averagePingLatency(routerSamples, averageWindow),
internetPingLatency: averagePingLatency(internetSamples) internetPingLatency: averagePingLatency(internetSamples, averageWindow),
internetPingPacketLoss: pingPacketLossPercent(internetSamples)
} }
} }
@@ -161,6 +182,12 @@ function formatRate(bytesPerSec) {
return formatBytes(bytesPerSec) + "/s" 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) { function formatPingLatency(ms) {
var value = parseFloat(ms) var value = parseFloat(ms)
if (!isFinite(value) || value < 0) return "Timeout" if (!isFinite(value) || value < 0) return "Timeout"
@@ -226,8 +253,11 @@ if (typeof module !== "undefined") {
parseKeyValue: parseKeyValue, parseKeyValue: parseKeyValue,
throughputState: throughputState, throughputState: throughputState,
pingLatencyState: pingLatencyState, pingLatencyState: pingLatencyState,
pingPacketLossPercent: pingPacketLossPercent,
formatPacketLoss: formatPacketLoss,
formatBytes: formatBytes, formatBytes: formatBytes,
formatRate: formatRate, formatRate: formatRate,
formatSpeedMbps: formatSpeedMbps,
formatPingLatency: formatPingLatency, formatPingLatency: formatPingLatency,
wifiRow: wifiRow, wifiRow: wifiRow,
sortWifiRows: sortWifiRows, sortWifiRows: sortWifiRows,
+173 -19
View File
@@ -37,13 +37,10 @@ Panel {
property var internetPingSamples: [] property var internetPingSamples: []
property real routerPingLatency: -1 property real routerPingLatency: -1
property real internetPingLatency: -1 property real internetPingLatency: -1
readonly property int pingWindow: 5 property int internetPingPacketLoss: 0
readonly property bool hasRouterPing: routerPingSamples.length > 0 readonly property int pingHistoryWindow: 24
readonly property int pingAverageWindow: 5
readonly property bool hasInternetPing: internetPingSamples.length > 0 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 property int connectionPhraseIndex: 0
readonly property var connectionPhrases: [ readonly property var connectionPhrases: [
"Wiring bits", "Wiring bits",
@@ -65,6 +62,14 @@ Panel {
property bool wifiStationAvailable: false property bool wifiStationAvailable: false
property string dnsProvider: "" property string dnsProvider: ""
property string pendingDnsProvider: "" 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 // Per-row in-flight state. `actionSsid` flips on for the row whose action
// is currently running so it can render "Connecting…" / "Disconnecting…" / // is currently running so it can render "Connecting…" / "Disconnecting…" /
@@ -158,6 +163,7 @@ Panel {
internetPingSamples = [] internetPingSamples = []
routerPingLatency = -1 routerPingLatency = -1
internetPingLatency = -1 internetPingLatency = -1
internetPingPacketLoss = 0
if (wifiDevice) wifiDevice.scannerEnabled = false if (wifiDevice) wifiDevice.scannerEnabled = false
} }
} }
@@ -322,13 +328,14 @@ Panel {
pingIface: pingIface, pingIface: pingIface,
routerPingSamples: routerPingSamples, routerPingSamples: routerPingSamples,
internetPingSamples: internetPingSamples internetPingSamples: internetPingSamples
}, next, pingWindow) }, next, pingHistoryWindow, pingAverageWindow)
pingIface = state.pingIface pingIface = state.pingIface
routerPingSamples = state.routerPingSamples routerPingSamples = state.routerPingSamples
internetPingSamples = state.internetPingSamples internetPingSamples = state.internetPingSamples
routerPingLatency = state.routerPingLatency routerPingLatency = state.routerPingLatency
internetPingLatency = state.internetPingLatency internetPingLatency = state.internetPingLatency
internetPingPacketLoss = state.internetPingPacketLoss
} }
function formatBytes(bytes) { function formatBytes(bytes) {
@@ -339,10 +346,18 @@ Panel {
return Model.formatRate(bytesPerSec) return Model.formatRate(bytesPerSec)
} }
function formatSpeedMbps(mbps) {
return Model.formatSpeedMbps(mbps)
}
function formatPingLatency(ms) { function formatPingLatency(ms) {
return Model.formatPingLatency(ms) return Model.formatPingLatency(ms)
} }
function formatPacketLoss(percent) {
return Model.formatPacketLoss(percent)
}
function findDevice(type) { function findDevice(type) {
var devices = networkDevices || [] var devices = networkDevices || []
for (var i = 0; i < devices.length; i++) { for (var i = 0; i < devices.length; i++) {
@@ -388,6 +403,53 @@ Panel {
dnsProvider = value || "DHCP" 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) { function dnsCommand(provider) {
var command = "omarchy-dns" var command = "omarchy-dns"
if (provider) command += " " + Util.shellQuote(provider) 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 // Action runner for DNS provider changes. Wi-Fi actions use the
// Quickshell.Networking NetworkManager backend directly. // Quickshell.Networking NetworkManager backend directly.
Process { Process {
@@ -838,21 +929,17 @@ Panel {
columnSpacing: Style.space(20) columnSpacing: Style.space(20)
rowSpacing: Style.spacing.labelGap rowSpacing: Style.spacing.labelGap
InfoLabel { InfoLabel { visible: root.hasInternetPing; text: "Ping" }
visible: root.hasPing
text: root.primaryPingLabel
}
DetailValue { DetailValue {
visible: root.hasPing visible: root.hasInternetPing
text: root.formatPingLatency(root.primaryPingLatency) text: root.formatPingLatency(root.internetPingLatency)
} color: root.internetPingPacketLoss > 0 ? root.bar.urgent : root.bar.foreground
InfoLabel {
visible: root.hasPing
text: root.hasSecondPing ? "Internet Ping" : ""
} }
InfoLabel { visible: root.hasInternetPing; text: "Packet Loss" }
DetailValue { DetailValue {
visible: root.hasPing visible: root.hasInternetPing
text: root.hasSecondPing ? root.formatPingLatency(root.internetPingLatency) : "" text: root.formatPacketLoss(root.internetPingPacketLoss)
color: root.internetPingPacketLoss > 0 ? root.bar.urgent : root.bar.foreground
} }
InfoLabel { visible: root.info.rx_bytes !== undefined; text: "Receiving" } 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. // DNS provider selection.
PanelSeparator { PanelSeparator {
foreground: root.bar.foreground foreground: root.bar.foreground
+7 -4
View File
@@ -44,34 +44,37 @@ let ping = network.pingLatencyState(
) )
assertDeepEqual( assertDeepEqual(
ping, ping,
{ pingIface: 'wlan0', routerPingSamples: [2], internetPingSamples: [20], routerPingLatency: 2, internetPingLatency: 20 }, { pingIface: 'wlan0', routerPingSamples: [2], internetPingSamples: [20], routerPingLatency: 2, internetPingLatency: 20, internetPingPacketLoss: 0 },
'network seeds ping latency samples' 'network seeds ping latency samples'
) )
ping = network.pingLatencyState(ping, { iface: 'wlan0', router_ping_ms: '4.0', internet_ping_ms: '' }, 4) ping = network.pingLatencyState(ping, { iface: 'wlan0', router_ping_ms: '4.0', internet_ping_ms: '' }, 4)
assertDeepEqual( assertDeepEqual(
ping, ping,
{ pingIface: 'wlan0', routerPingSamples: [2, 4], internetPingSamples: [20, null], routerPingLatency: 3, internetPingLatency: 20 }, { pingIface: 'wlan0', routerPingSamples: [2, 4], internetPingSamples: [20, null], routerPingLatency: 3, internetPingLatency: 20, internetPingPacketLoss: 50 },
'network averages recent successful ping samples' 'network averages recent successful ping samples'
) )
assertDeepEqual( assertDeepEqual(
network.pingLatencyState(ping, { iface: 'eth0', router_ping_ms: '1.5', internet_ping_ms: '10.0' }, 4), network.pingLatencyState(ping, { iface: 'eth0', router_ping_ms: '1.5', internet_ping_ms: '10.0' }, 4),
{ pingIface: 'eth0', routerPingSamples: [1.5], internetPingSamples: [10], routerPingLatency: 1.5, internetPingLatency: 10 }, { pingIface: 'eth0', routerPingSamples: [1.5], internetPingSamples: [10], routerPingLatency: 1.5, internetPingLatency: 10, internetPingPacketLoss: 0 },
'network resets ping samples when interface changes' 'network resets ping samples when interface changes'
) )
assertDeepEqual( assertDeepEqual(
network.pingLatencyState(ping, { iface: 'wlan0', internet_ping_ms: '22.0' }, 4), network.pingLatencyState(ping, { iface: 'wlan0', internet_ping_ms: '22.0' }, 4),
{ pingIface: 'wlan0', routerPingSamples: [], internetPingSamples: [20, null, 22], routerPingLatency: -1, internetPingLatency: 21 }, { pingIface: 'wlan0', routerPingSamples: [], internetPingSamples: [20, null, 22], routerPingLatency: -1, internetPingLatency: 21, internetPingPacketLoss: 33 },
'network clears ping samples when a target is unavailable' 'network clears ping samples when a target is unavailable'
) )
assertEqual(network.formatBytes(1536), '1.5 KB', 'network formats bytes') assertEqual(network.formatBytes(1536), '1.5 KB', 'network formats bytes')
assertEqual(network.formatRate(1536), '1.5 KB/s', 'network formats rates') assertEqual(network.formatRate(1536), '1.5 KB/s', 'network formats rates')
assertEqual(network.formatSpeedMbps('250.4'), '250 Mbps', 'network formats speed test results')
assertEqual(network.formatPingLatency('2.54'), '2.5 ms', 'network formats low ping with precision') assertEqual(network.formatPingLatency('2.54'), '2.5 ms', 'network formats low ping with precision')
assertEqual(network.formatPingLatency('25.4'), '25 ms', 'network formats ping') assertEqual(network.formatPingLatency('25.4'), '25 ms', 'network formats ping')
assertEqual(network.formatPingLatency(''), 'Timeout', 'network formats missing ping as timeout') assertEqual(network.formatPingLatency(''), 'Timeout', 'network formats missing ping as timeout')
assertEqual(network.formatPacketLoss(2), '2%', 'network formats packet loss')
assertEqual(network.formatPacketLoss(0), '0%', 'network formats zero packet loss')
const rows = network.sortWifiRows([ const rows = network.sortWifiRows([
{ ssid: 'Open', connected: false, known: false, signal: 95 }, { ssid: 'Open', connected: false, known: false, signal: 95 },