diff --git a/bin/omarchy-network-speedtest b/bin/omarchy-network-speedtest new file mode 100755 index 00000000..cb4e475e --- /dev/null +++ b/bin/omarchy-network-speedtest @@ -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 diff --git a/shell/plugins/panels/network/Model.js b/shell/plugins/panels/network/Model.js index 5eebc6fd..a3bd0b41 100644 --- a/shell/plugins/panels/network/Model.js +++ b/shell/plugins/panels/network/Model.js @@ -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, diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index 08bf3bdc..e4b3ea9b 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -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 diff --git a/test/shell.d/network-test.sh b/test/shell.d/network-test.sh index 43350af1..89cd8c76 100644 --- a/test/shell.d/network-test.sh +++ b/test/shell.d/network-test.sh @@ -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 },