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
}
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
+7 -4
View File
@@ -44,34 +44,37 @@ let ping = network.pingLatencyState(
)
assertDeepEqual(
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'
)
ping = network.pingLatencyState(ping, { iface: 'wlan0', router_ping_ms: '4.0', internet_ping_ms: '' }, 4)
assertDeepEqual(
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'
)
assertDeepEqual(
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'
)
assertDeepEqual(
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'
)
assertEqual(network.formatBytes(1536), '1.5 KB', 'network formats bytes')
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('25.4'), '25 ms', 'network formats ping')
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([
{ ssid: 'Open', connected: false, known: false, signal: 95 },