diff --git a/bin/omarchy-network-status b/bin/omarchy-network-status index abb3a8b5..b03df3bb 100755 --- a/bin/omarchy-network-status +++ b/bin/omarchy-network-status @@ -5,6 +5,7 @@ # omarchy:args=[--verbose] verbose=false +internet_probe=1.1.1.1 case "${1:-}" in "") @@ -21,7 +22,7 @@ esac print_status() { local device nm state ssid signal freq - device=$(ip route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }') + device=$(ip route get "$internet_probe" 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }') if [[ -z $device ]]; then printf 'disconnected\t\t\t\n' @@ -52,10 +53,44 @@ print_status() { printf 'wifi\t%s\t\t\n' "$device" } +ping_latency_ms() { + local host=$1 + + LC_ALL=C ping -n -c 1 -W 1 "$host" 2>/dev/null | awk -F'time[=<]' '/time[=<]/ { split($2, parts, " "); print parts[1]; exit }' +} + +print_ping_samples() { + local gateway=$1 + local tmpdir router_file internet_file + local router_pid="" internet_pid="" + + omarchy-cmd-present ping || return + tmpdir=$(mktemp -d) || return + router_file="$tmpdir/router" + internet_file="$tmpdir/internet" + + if [[ -n $gateway ]]; then + ping_latency_ms "$gateway" >"$router_file" & + router_pid=$! + fi + + ping_latency_ms "$internet_probe" >"$internet_file" & + internet_pid=$! + + if [[ -n $router_pid ]]; then + wait "$router_pid" + printf 'router_ping_ms\t%s\n' "$(cat "$router_file")" + fi + + wait "$internet_pid" + printf 'internet_ping_ms\t%s\n' "$(cat "$internet_file")" + rm -rf "$tmpdir" +} + print_verbose() { local route_json iface gw src prefix link - route_json=$(ip -j route get 1.1.1.1 2>/dev/null) + route_json=$(ip -j route get "$internet_probe" 2>/dev/null) [[ -z $route_json ]] && return iface=$(jq -r '.[0].dev // ""' <<<"$route_json" 2>/dev/null) @@ -96,6 +131,8 @@ print_verbose() { [[ -r /sys/class/net/$iface/speed ]] && printf 'speed\t%s\n' "$(cat /sys/class/net/$iface/speed)" [[ -r /sys/class/net/$iface/duplex ]] && printf 'duplex\t%s\n' "$(cat /sys/class/net/$iface/duplex)" fi + + print_ping_samples "$gw" } if [[ $verbose == "true" ]]; then diff --git a/shell/plugins/panels/network/Model.js b/shell/plugins/panels/network/Model.js index 65887d72..5eebc6fd 100644 --- a/shell/plugins/panels/network/Model.js +++ b/shell/plugins/panels/network/Model.js @@ -97,6 +97,57 @@ function throughputState(previous, next, now) { } } +function pingSampleValue(raw) { + var value = parseFloat(raw) + if (!isFinite(value) || value < 0) return null + return value +} + +function appendPingSample(samples, raw, limit) { + var values = Array.isArray(samples) ? samples.slice() : [] + + values.push(pingSampleValue(raw)) + while (values.length > limit) values.shift() + + return values +} + +function averagePingLatency(samples) { + var values = Array.isArray(samples) ? samples : [] + var total = 0 + var count = 0 + + for (var i = 0; i < values.length; i++) { + var value = values[i] + if (typeof value !== "number" || !isFinite(value) || value < 0) continue + total += value + count++ + } + + return count > 0 ? total / count : -1 +} + +function pingLatencyState(previous, next, limit) { + var prev = previous || {} + var sample = next || {} + var iface = sample.iface || "" + var window = Math.max(1, parseInt(limit, 10) || 5) + var reset = iface === "" || iface !== (prev.pingIface || "") + var routerSamples = reset ? [] : prev.routerPingSamples + var internetSamples = reset ? [] : prev.internetPingSamples + + routerSamples = sample.router_ping_ms === undefined ? [] : appendPingSample(routerSamples, sample.router_ping_ms, window) + internetSamples = sample.internet_ping_ms === undefined ? [] : appendPingSample(internetSamples, sample.internet_ping_ms, window) + + return { + pingIface: iface, + routerPingSamples: routerSamples, + internetPingSamples: internetSamples, + routerPingLatency: averagePingLatency(routerSamples), + internetPingLatency: averagePingLatency(internetSamples) + } +} + function formatBytes(bytes) { var n = Number(bytes) if (!isFinite(n) || n < 0) n = 0 @@ -110,6 +161,12 @@ function formatRate(bytesPerSec) { return formatBytes(bytesPerSec) + "/s" } +function formatPingLatency(ms) { + var value = parseFloat(ms) + if (!isFinite(value) || value < 0) return "Timeout" + return value.toFixed(value > 0 && value < 10 ? 1 : 0) + " ms" +} + function wifiRow(network) { if (!network) return null return { @@ -168,8 +225,10 @@ if (typeof module !== "undefined") { headerDetail: headerDetail, parseKeyValue: parseKeyValue, throughputState: throughputState, + pingLatencyState: pingLatencyState, formatBytes: formatBytes, formatRate: formatRate, + formatPingLatency: formatPingLatency, wifiRow: wifiRow, sortWifiRows: sortWifiRows, wifiSectionTitle: wifiSectionTitle, diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index 8c7a26b1..08bf3bdc 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -20,7 +20,7 @@ Panel { } // Live connection details from `ip` / /sys / iw. - property var info: ({}) // { iface, type, ip, prefix, gateway, speed, duplex, ssid, signal, freq, bitrate, rx_bytes, tx_bytes } + property var info: ({}) // { iface, type, ip, prefix, gateway, speed, duplex, ssid, signal, freq, bitrate, rx_bytes, tx_bytes, router_ping_ms, internet_ping_ms } // Throughput tracking. Rates are computed as deltas between successive // `omarchy-network-status --verbose` samples (~1.5s apart via detailsPoll). @@ -32,6 +32,18 @@ Panel { property string prevIface: "" property real downloadRate: 0 // bytes/sec property real uploadRate: 0 // bytes/sec + property string pingIface: "" + property var routerPingSamples: [] + property var internetPingSamples: [] + property real routerPingLatency: -1 + property real internetPingLatency: -1 + readonly property int pingWindow: 5 + readonly property bool hasRouterPing: routerPingSamples.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 readonly property var connectionPhrases: [ "Wiring bits", @@ -141,6 +153,11 @@ Panel { prevSampleTime = 0 downloadRate = 0 uploadRate = 0 + pingIface = "" + routerPingSamples = [] + internetPingSamples = [] + routerPingLatency = -1 + internetPingLatency = -1 if (wifiDevice) wifiDevice.scannerEnabled = false } } @@ -279,6 +296,7 @@ Panel { var next = Model.parseKeyValue(raw) info = next updateThroughput(next) + updatePingLatency(next) } function updateThroughput(next) { @@ -299,6 +317,20 @@ Panel { uploadRate = state.uploadRate } + function updatePingLatency(next) { + var state = Model.pingLatencyState({ + pingIface: pingIface, + routerPingSamples: routerPingSamples, + internetPingSamples: internetPingSamples + }, next, pingWindow) + + pingIface = state.pingIface + routerPingSamples = state.routerPingSamples + internetPingSamples = state.internetPingSamples + routerPingLatency = state.routerPingLatency + internetPingLatency = state.internetPingLatency + } + function formatBytes(bytes) { return Model.formatBytes(bytes) } @@ -307,6 +339,10 @@ Panel { return Model.formatRate(bytesPerSec) } + function formatPingLatency(ms) { + return Model.formatPingLatency(ms) + } + function findDevice(type) { var devices = networkDevices || [] for (var i = 0; i < devices.length; i++) { @@ -802,6 +838,23 @@ Panel { columnSpacing: Style.space(20) rowSpacing: Style.spacing.labelGap + InfoLabel { + visible: root.hasPing + text: root.primaryPingLabel + } + DetailValue { + visible: root.hasPing + text: root.formatPingLatency(root.primaryPingLatency) + } + InfoLabel { + visible: root.hasPing + text: root.hasSecondPing ? "Internet Ping" : "" + } + DetailValue { + visible: root.hasPing + text: root.hasSecondPing ? root.formatPingLatency(root.internetPingLatency) : "" + } + InfoLabel { visible: root.info.rx_bytes !== undefined; text: "Receiving" } DetailValue { visible: root.info.rx_bytes !== undefined; text: root.formatRate(root.downloadRate) } InfoLabel { visible: root.info.rx_bytes !== undefined; text: "Sending" } diff --git a/test/shell.d/network-test.sh b/test/shell.d/network-test.sh index 2824f02e..43350af1 100644 --- a/test/shell.d/network-test.sh +++ b/test/shell.d/network-test.sh @@ -37,8 +37,41 @@ assertDeepEqual( 'network computes throughput deltas' ) +let ping = network.pingLatencyState( + { pingIface: '', routerPingSamples: [], internetPingSamples: [] }, + { iface: 'wlan0', router_ping_ms: '2.0', internet_ping_ms: '20.0' }, + 4 +) +assertDeepEqual( + ping, + { pingIface: 'wlan0', routerPingSamples: [2], internetPingSamples: [20], routerPingLatency: 2, internetPingLatency: 20 }, + '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 }, + '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 }, + '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 }, + '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.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') const rows = network.sortWifiRows([ { ssid: 'Open', connected: false, known: false, signal: 95 },