Add network ping and speed test sections
This commit is contained in:
Executable
+90
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user