Sync config from arch
- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "format.js" as Fmt
|
||||
|
||||
// Who is using the connection: top applications by TCP traffic, with an
|
||||
// honest bucket for what no unprivileged tool can attribute (QUIC/UDP,
|
||||
// protocol overhead). Rates are the last few seconds; totals are since the
|
||||
// daemon started.
|
||||
Column {
|
||||
id: tab
|
||||
|
||||
required property var panel
|
||||
|
||||
spacing: Style.space(12)
|
||||
|
||||
readonly property var apps: panel.appsData && panel.appsData.apps
|
||||
? panel.appsData.apps : []
|
||||
readonly property var other: panel.appsData ? panel.appsData.other : null
|
||||
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: appsLabel.implicitHeight
|
||||
|
||||
Text {
|
||||
id: appsLabel
|
||||
textFormat: Text.PlainText
|
||||
text: "TOP APPLICATIONS · TCP"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
text: "totals since the daemon started"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: tab.apps.length === 0
|
||||
text: "Collecting — the first sample lands within a few seconds."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Style.space(10)
|
||||
|
||||
Repeater {
|
||||
model: tab.apps
|
||||
|
||||
Column {
|
||||
id: appRow
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
spacing: Style.space(4)
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: nameText.implicitHeight
|
||||
|
||||
Text {
|
||||
id: nameText
|
||||
textFormat: Text.PlainText
|
||||
text: appRow.modelData.name
|
||||
+ (appRow.modelData.conns > 0
|
||||
? " · " + appRow.modelData.conns
|
||||
+ (appRow.modelData.conns === 1 ? " conn" : " conns")
|
||||
: "")
|
||||
// The kernel's round trip for this app's own sockets. Absent
|
||||
// for an app the kernel has not timed — QUIC-only traffic
|
||||
// shows no figure rather than a misleading zero.
|
||||
+ (appRow.modelData.rtt_ms
|
||||
? " · " + appRow.modelData.rtt_ms.toFixed(0) + " ms"
|
||||
: "")
|
||||
color: tab.panel.fg
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
text: " " + Fmt.rate(appRow.modelData.rx_bps)
|
||||
+ " " + Fmt.rate(appRow.modelData.tx_bps)
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
// Second row: the last minute as a half-width strip of stacked
|
||||
// mini-bars (download in accent, upload above in amber, newest on
|
||||
// the right, scaled to this app's own busiest moment) with the
|
||||
// session totals beside it.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: Math.max(strip.height, sessionText.implicitHeight)
|
||||
|
||||
Row {
|
||||
id: strip
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.round(parent.width * 0.5)
|
||||
height: Style.space(9)
|
||||
spacing: Math.max(1, Style.spaceReal(1.5))
|
||||
|
||||
readonly property var hist: appRow.modelData.hist || []
|
||||
readonly property int slots: 20
|
||||
readonly property real slotW:
|
||||
(width - spacing * (slots - 1)) / slots
|
||||
readonly property real peak: {
|
||||
var p = 1024
|
||||
for (var i = 0; i < hist.length; i++)
|
||||
p = Math.max(p, hist[i][0] + hist[i][1])
|
||||
return p
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: strip.slots
|
||||
|
||||
Item {
|
||||
id: histSlot
|
||||
required property int index
|
||||
readonly property var sample: {
|
||||
var h = strip.hist
|
||||
var i = h.length - strip.slots + index
|
||||
return i >= 0 && i < h.length ? h[i] : null
|
||||
}
|
||||
width: strip.slotW
|
||||
height: strip.height
|
||||
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Qt.rgba(tab.panel.fg.r, tab.panel.fg.g,
|
||||
tab.panel.fg.b, 0.12)
|
||||
}
|
||||
Rectangle {
|
||||
id: rxSeg
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: histSlot.sample
|
||||
? Math.min(parent.height,
|
||||
parent.height * histSlot.sample[0] / strip.peak)
|
||||
: 0
|
||||
color: Color.accent
|
||||
}
|
||||
Rectangle {
|
||||
anchors.bottom: rxSeg.top
|
||||
width: parent.width
|
||||
height: histSlot.sample
|
||||
? Math.min(parent.height - rxSeg.height,
|
||||
parent.height * histSlot.sample[1] / strip.peak)
|
||||
: 0
|
||||
color: tab.panel.warnTone
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: sessionText
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Fmt.bytes(appRow.modelData.rx_total)
|
||||
+ " down · " + Fmt.bytes(appRow.modelData.tx_total) + " up"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PanelSeparator {
|
||||
width: parent.width
|
||||
visible: tab.other !== null
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
visible: tab.other !== null
|
||||
height: otherText.implicitHeight
|
||||
|
||||
Text {
|
||||
id: otherText
|
||||
textFormat: Text.PlainText
|
||||
text: "Unattributed (QUIC, UDP, overhead)"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
text: tab.other
|
||||
? " " + Fmt.rate(tab.other.rx_bps) + " " + Fmt.rate(tab.other.tx_bps)
|
||||
: ""
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: parent.width
|
||||
text: "Per-app numbers come from each TCP connection's own counters — no "
|
||||
+ "packet capture, no root. QUIC (much of Chrome and YouTube) is UDP, "
|
||||
+ "which Linux only attributes to privileged tools; it shows above as "
|
||||
+ "unattributed instead of pretending the TCP list is everything."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// Nexthop's bar entry: the one always-visible surface. Colour carries the
|
||||
// state — the number is detail, the colour is the verdict. Clicking opens
|
||||
// the panel; middle-click asks the daemon for a peak speed test.
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "io.github.x3me.nexthop"
|
||||
|
||||
// ---- live state ----------------------------------------------------------
|
||||
//
|
||||
// The shell never opens a state file itself. `nexthop stream` performs a
|
||||
// bounded, non-blocking, no-follow, regular-file-only read and hands us
|
||||
// whole lines, so an oversized file, a FIFO or a symlink swapped in at
|
||||
// the predictable path is refused in a small short-lived process instead
|
||||
// of allocating or stalling inside the long-lived shell.
|
||||
// A URL, not a path: percent-encoded, so a space in the way becomes %20
|
||||
// and `cd` fails. Decode before it is used as a filesystem path.
|
||||
readonly property string pluginDir:
|
||||
decodeURIComponent(Qt.resolvedUrl(".").toString())
|
||||
.replace(/^file:\/\//, "").replace(/\/$/, "")
|
||||
|
||||
// One reader serves the whole widget: the panel below is created by this
|
||||
// component and binds to these properties rather than opening anything
|
||||
// itself, so the shell runs a single helper, not one per surface.
|
||||
property var live: null
|
||||
property var recent: null
|
||||
property var appsData: null
|
||||
|
||||
Process {
|
||||
id: stateStream
|
||||
running: true
|
||||
command: ["sh", "-c",
|
||||
'cd "$1" && exec python3 -m nexthopd.cli stream live apps recent',
|
||||
"sh", root.pluginDir]
|
||||
stdout: SplitParser {
|
||||
splitMarker: "\n"
|
||||
onRead: function (line) { root.applyStream(line) }
|
||||
}
|
||||
onExited: streamRestart.start()
|
||||
}
|
||||
|
||||
// The reader dies with the daemon's package on an update; bring it back.
|
||||
Timer {
|
||||
id: streamRestart
|
||||
interval: 2000
|
||||
onTriggered: stateStream.running = true
|
||||
}
|
||||
|
||||
// Each file has a known small size; a line past its bound is not ours.
|
||||
// live ~3 KB, apps ~8 KB, recent ~30 KB.
|
||||
function applyStream(line) {
|
||||
var sp = line ? line.indexOf(" ") : -1
|
||||
if (sp <= 0) return
|
||||
var key = line.slice(0, sp)
|
||||
if (line.length - sp - 1 > (key === "live" ? 262144 : 1048576)) return
|
||||
var v
|
||||
try { v = JSON.parse(line.slice(sp + 1)) } catch (e) { return }
|
||||
if (v === null || v === undefined) return
|
||||
if (key === "live") root.live = v
|
||||
else if (key === "apps") root.appsData = v
|
||||
else if (key === "recent") root.recent = v
|
||||
}
|
||||
|
||||
// ---- freshness -----------------------------------------------------------
|
||||
//
|
||||
// The stream emits only when live.json changes, so a daemon that has
|
||||
// stopped writing — hung, or its reader gone — leaves the last snapshot
|
||||
// on screen looking current, and the bar would hold a number from an
|
||||
// hour ago as if it were now. The snapshot carries its own timestamp; a
|
||||
// clock of our own tells "current" from "last seen". Five seconds is ten
|
||||
// missed writes at 2 Hz. After a suspend the first tick can read stale
|
||||
// until the next write lands, which is honest for as long as it lasts.
|
||||
property real nowS: Date.now() / 1000
|
||||
readonly property int staleAfterS: 5
|
||||
readonly property bool stale: live !== null && typeof live.t === "number"
|
||||
&& (nowS - live.t) > staleAfterS
|
||||
readonly property int staleForS: stale ? Math.round(nowS - live.t) : 0
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.nowS = Date.now() / 1000
|
||||
}
|
||||
|
||||
// ---- derived -------------------------------------------------------------
|
||||
readonly property string displayMode: setting("displayMode", "Index")
|
||||
readonly property string netState: !live || stale ? "no-daemon" : (live.state || "online")
|
||||
readonly property var index: !stale && live && live.index !== null && live.index !== undefined
|
||||
? live.index : null
|
||||
readonly property var lagNow: !stale && live && live.lag ? live.lag.now : null
|
||||
|
||||
readonly property color okColor: bar ? bar.foreground : Color.foreground
|
||||
// State colours resolve through the theme palette: green/yellow/red exist
|
||||
// in every Omarchy theme's colors.toml, surfaced via Color singleton.
|
||||
readonly property color stateColor: {
|
||||
// A sign-in page is a gate, not a fault: warn, not urgent.
|
||||
if (netState === "captive") return "#e0af68"
|
||||
if (netState === "local-down" || netState === "wan-down") return Color.urgent
|
||||
if (netState === "degraded") return "#e0af68"
|
||||
if (index === null) return okColor
|
||||
if (index >= 80) return okColor
|
||||
if (index >= 50) return "#e0af68"
|
||||
return Color.urgent
|
||||
}
|
||||
|
||||
readonly property string glyph: {
|
||||
if (netState === "captive") return "" // nf-md-shield_lock: a gate
|
||||
if (netState === "local-down") return "" // nf-md-wifi_strength_alert
|
||||
if (netState === "wan-down") return "" // nf-md-web_off / broken link
|
||||
return "" // nf-md-speedometer
|
||||
}
|
||||
|
||||
readonly property string barText: {
|
||||
if (netState === "no-daemon") return glyph
|
||||
if (netState === "captive") return glyph
|
||||
if (netState === "local-down" || netState === "wan-down") {
|
||||
var since = live && live.down_since ? live.down_since : 0
|
||||
if (!since) return glyph
|
||||
var s = Math.max(0, Math.round(Date.now() / 1000 - since))
|
||||
var m = Math.floor(s / 60)
|
||||
return glyph + " " + (m > 0 ? m + "m" + (s % 60) + "s" : s + "s")
|
||||
}
|
||||
if (displayMode === "Icon only") return glyph
|
||||
if (displayMode === "Lag")
|
||||
return glyph + " " + (lagNow !== null ? Math.round(lagNow) + "ms" : "--")
|
||||
return glyph + " " + (index !== null ? index : "--")
|
||||
}
|
||||
|
||||
// ---- panel wiring (same shape contract as weather / vitals) --------------
|
||||
function injectPanel() {
|
||||
var target = panelLoader.item
|
||||
if (!target) return
|
||||
if ("bar" in target) target.bar = root.bar
|
||||
if ("settings" in target) target.settings = root.settings
|
||||
if ("anchorItem" in target) target.anchorItem = button
|
||||
if ("hostWidget" in target) target.hostWidget = root
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
if (panelLoader.item && panelLoader.item.toggle) panelLoader.item.toggle()
|
||||
}
|
||||
|
||||
readonly property bool opened: panelLoader.item ? panelLoader.item.opened === true : false
|
||||
|
||||
function open() {
|
||||
if (panelLoader.item && panelLoader.item.openFromHotkey) panelLoader.item.openFromHotkey()
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (panelLoader.item && panelLoader.item.close) panelLoader.item.close()
|
||||
}
|
||||
|
||||
readonly property bool popoutSwitchClosing: panelLoader.item
|
||||
? panelLoader.item.popoutSwitchClosing === true : false
|
||||
|
||||
function closeForPopoutSwitch() {
|
||||
if (panelLoader.item) panelLoader.item.closeForPopoutSwitch()
|
||||
}
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
onBarChanged: injectPanel()
|
||||
onSettingsChanged: injectPanel()
|
||||
|
||||
Loader {
|
||||
id: panelLoader
|
||||
active: true
|
||||
source: Qt.resolvedUrl("Panel.qml")
|
||||
visible: false
|
||||
onLoaded: {
|
||||
root.injectPanel()
|
||||
Qt.callLater(root.injectPanel)
|
||||
}
|
||||
}
|
||||
|
||||
// The path travels as a positional argument, never spliced into the
|
||||
// script — the same form every other Process here uses.
|
||||
Process {
|
||||
id: peakRequest
|
||||
command: ["sh", "-c", 'cd "$1" && exec python3 -m nexthopd.cli peak',
|
||||
"sh", root.pluginDir]
|
||||
}
|
||||
|
||||
// Why the width is measured here rather than left to the control:
|
||||
// BarIconButton is an *icon* button — it pins `fixedWidth` to a
|
||||
// single-glyph slot (27 px by default), so its implicitWidth is that slot
|
||||
// no matter what text it holds. Our text is variable (" 92", " 1024ms",
|
||||
// " 1m3s"), so the bar reserved one icon's worth of space and the text
|
||||
// painted straight over the neighbouring widget. Longest during an
|
||||
// outage, which is when it was noticed.
|
||||
//
|
||||
// TextMetrics measures the string against the same font without
|
||||
// rendering it, so the width can drive the slot with no binding loop
|
||||
// back through the glyph that is being laid out.
|
||||
TextMetrics {
|
||||
id: textWidth
|
||||
font.family: button.fontFamily
|
||||
font.pixelSize: button.fontSize
|
||||
text: root.barText
|
||||
}
|
||||
|
||||
BarIconButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.barText
|
||||
// Never narrower than a normal icon slot, so an icon-only display mode
|
||||
// still lines up with its neighbours.
|
||||
slotSize: Math.max(Style.bar.iconSlot,
|
||||
Math.ceil(textWidth.advanceWidth) + Style.space(10))
|
||||
foreground: root.stateColor
|
||||
useActiveColor: false
|
||||
tooltipText: {
|
||||
if (!root.live) return "Nexthop: waiting for the daemon"
|
||||
if (root.stale) return "Nexthop: no data for " + root.staleForS + " s"
|
||||
var l = root.live
|
||||
var name = l.link && (l.link.ssid || l.link.name) || ""
|
||||
var parts = [name, (l.index !== null ? l.index + " " + l.band : "")]
|
||||
if (l.local && l.local.p50 !== null && l.wan && l.wan.p50 !== null)
|
||||
parts.push("local " + l.local.p50 + " ms · wan " + l.wan.p50 + " ms")
|
||||
return parts.filter(function(p) { return p && p.length }).join("\n")
|
||||
}
|
||||
|
||||
onPressed: function(b) {
|
||||
if (b === Qt.MiddleButton) {
|
||||
// The easiest way to spend a phone's data by accident: a stray
|
||||
// middle-click saturating the link. On a metered connection this
|
||||
// opens the panel instead, where the button asks twice.
|
||||
if (root.live && root.live.metered && root.live.metered.care)
|
||||
root.open()
|
||||
else
|
||||
peakRequest.running = true
|
||||
} else {
|
||||
root.togglePanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// What happened: outages and disruptions with durations and the leg named,
|
||||
// plus the copy-report affordance — the ISP-ticket artifact.
|
||||
Column {
|
||||
id: tab
|
||||
|
||||
required property var panel
|
||||
|
||||
spacing: Style.space(12)
|
||||
|
||||
Component.onCompleted: {
|
||||
panel.requestEvents("24h")
|
||||
ribbonProc.command = ["sh", "-c",
|
||||
"cd \"$1\" && exec python3 -m nexthopd.cli query --window 24h --resolution minute",
|
||||
"sh", panel.pluginDir]
|
||||
ribbonProc.running = true
|
||||
weekProc.command = ["sh", "-c",
|
||||
"cd \"$1\" && exec python3 -m nexthopd.cli query --window 7d",
|
||||
"sh", panel.pluginDir]
|
||||
weekProc.running = true
|
||||
}
|
||||
|
||||
property var ribbonRows: []
|
||||
property var weekDays: []
|
||||
|
||||
Process {
|
||||
id: ribbonProc
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
try {
|
||||
var d = JSON.parse(text)
|
||||
tab.ribbonRows = d.rows || []
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: weekProc
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
try {
|
||||
var d = JSON.parse(text)
|
||||
tab.weekDays = tab.foldDays(d.rows || [])
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hourly rows -> trailing seven local days, averaged experience each.
|
||||
function foldDays(rows) {
|
||||
var byDay = {}
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var r = rows[i]
|
||||
if (r.idx === null || r.idx === undefined) continue
|
||||
var d = new Date(r.ts * 1000)
|
||||
var key = d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate()
|
||||
var slot = byDay[key] || (byDay[key] = { sum: 0, n: 0, ts: r.ts })
|
||||
slot.sum += r.idx
|
||||
slot.n += 1
|
||||
}
|
||||
var out = []
|
||||
var names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||
for (var day = 6; day >= 0; day--) {
|
||||
var when = new Date(Date.now() - day * 86400 * 1000)
|
||||
var k = when.getFullYear() * 10000 + (when.getMonth() + 1) * 100 + when.getDate()
|
||||
var s = byDay[k]
|
||||
out.push({
|
||||
name: names[when.getDay()],
|
||||
idx: s ? Math.round(s.sum / s.n) : null,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function bandColor(idx) {
|
||||
if (idx === null || idx === undefined)
|
||||
return Qt.rgba(tab.panel.fg.r, tab.panel.fg.g, tab.panel.fg.b, 0.08)
|
||||
if (idx >= 90) return "#9ece6a"
|
||||
if (idx >= 80) return "#b9f27c"
|
||||
if (idx >= 70) return "#e0af68"
|
||||
if (idx >= 50) return "#eb927b"
|
||||
return "#f7768e"
|
||||
}
|
||||
|
||||
readonly property var events: panel.eventsData && panel.eventsData.events
|
||||
? panel.eventsData.events : []
|
||||
|
||||
// Consecutive events of the same kind, this close together, are one
|
||||
// episode rather than several: a laptop bouncing between two access
|
||||
// points is a single story, and listing each hop separately buries the
|
||||
// outages that actually matter under a wall of roams.
|
||||
readonly property int episodeGapS: 600
|
||||
// Beyond this the list stops being readable and the report is the right
|
||||
// tool. Nothing is discarded — the count of what is not shown is stated.
|
||||
readonly property int maxRows: 12
|
||||
|
||||
readonly property var episodes: foldEvents(events)
|
||||
readonly property var shownEpisodes: episodes.slice(0, maxRows)
|
||||
readonly property int hiddenEvents: {
|
||||
var n = 0
|
||||
for (var i = maxRows; i < episodes.length; i++) n += episodes[i].count
|
||||
return n
|
||||
}
|
||||
|
||||
// Outages and associations are never folded: each one is its own fact.
|
||||
function groupable(kind) {
|
||||
return kind === "roam" || kind === "kick" || kind === "drop"
|
||||
|| kind === "rate-drop" || kind === "disruption"
|
||||
|| kind === "icmp-quiet" || kind === "gateway-quiet"
|
||||
}
|
||||
|
||||
function foldEvents(list) {
|
||||
var out = []
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var e = list[i]
|
||||
var g = out.length ? out[out.length - 1] : null
|
||||
if (g && g.kind === e.kind && groupable(e.kind)
|
||||
&& (g.oldestTs - e.ts) <= episodeGapS) {
|
||||
g.count += 1
|
||||
g.oldestTs = e.ts
|
||||
g.members.push(e)
|
||||
} else {
|
||||
out.push({kind: e.kind, ts: e.ts, oldestTs: e.ts,
|
||||
count: 1, members: [e], first: e})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The first four octets are the same across every access point on one
|
||||
// site, so they carry no information — only the last two identify which
|
||||
// radio this was. The stored event keeps the full address for the report.
|
||||
function shortMac(text) {
|
||||
return String(text).replace(
|
||||
/\b(?:[0-9a-fA-F]{2}:){4}([0-9a-fA-F]{2}:[0-9a-fA-F]{2})\b/g, "…$1")
|
||||
}
|
||||
|
||||
function roamTargets(members) {
|
||||
var seen = []
|
||||
for (var i = 0; i < members.length; i++) {
|
||||
var m = /Roamed to ([0-9a-fA-F:]{17})/.exec(members[i].detail || "")
|
||||
if (!m) continue
|
||||
var short = shortMac(m[1])
|
||||
if (seen.indexOf(short) < 0) seen.push(short)
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
// Which access points did the kicking, and every distinct reason given.
|
||||
function kickSources(members) {
|
||||
var aps = [], whys = []
|
||||
for (var i = 0; i < members.length; i++) {
|
||||
var m = /Kicked by AP ([0-9a-fA-F:]{17}) \((reason [^)]*)\)/
|
||||
.exec(members[i].detail || "")
|
||||
if (!m) continue
|
||||
var short = shortMac(m[1])
|
||||
if (aps.indexOf(short) < 0) aps.push(short)
|
||||
if (whys.indexOf(m[2]) < 0) whys.push(m[2])
|
||||
}
|
||||
return {aps: aps, why: whys.length ? whys.join("; ") : null}
|
||||
}
|
||||
|
||||
function lowestRate(members) {
|
||||
var low = null
|
||||
for (var i = 0; i < members.length; i++) {
|
||||
var m = /dropped to (\d+)/.exec(members[i].detail || "")
|
||||
if (m && (low === null || Number(m[1]) < low)) low = Number(m[1])
|
||||
}
|
||||
return low
|
||||
}
|
||||
|
||||
function describeEpisode(g) {
|
||||
if (g.count === 1) return describe(g.first)
|
||||
if (g.kind === "roam") {
|
||||
var aps = roamTargets(g.members)
|
||||
return "Roamed " + g.count + "×"
|
||||
+ (aps.length > 1 ? " between " + aps.join(" ↔ ")
|
||||
: aps.length === 1 ? " to " + aps[0] : "")
|
||||
}
|
||||
if (g.kind === "kick") {
|
||||
var k = kickSources(g.members)
|
||||
return "Kicked by AP " + g.count + "\u00d7"
|
||||
+ (k.aps.length ? " \u2014 " + k.aps.join(", ") : "")
|
||||
+ (k.why ? " (" + k.why + ")" : "")
|
||||
}
|
||||
if (g.kind === "drop")
|
||||
return "Dropped by this machine " + g.count + "\u00d7"
|
||||
if (g.kind === "rate-drop") {
|
||||
var low = lowestRate(g.members)
|
||||
return "Tx rate dropped " + g.count + "×"
|
||||
+ (low !== null ? ", lowest " + low + " Mbps" : "")
|
||||
}
|
||||
return describe(g.first) + " · " + g.count + "×"
|
||||
}
|
||||
|
||||
// A folded episode reports how long it went on; a single event reports
|
||||
// its own duration, which for an instant event is nothing.
|
||||
function episodeDuration(g) {
|
||||
if (g.count === 1) return duration(g.first)
|
||||
var s = g.ts - g.oldestTs
|
||||
if (s < 60) return s + "s"
|
||||
if (s < 3600) return Math.floor(s / 60) + "m"
|
||||
return Math.floor(s / 3600) + "h " + Math.floor((s % 3600) / 60) + "m"
|
||||
}
|
||||
|
||||
property bool copied: false
|
||||
|
||||
function describe(e) {
|
||||
if (e.kind === "outage" && e.leg === "wan")
|
||||
return "No internet. The router still answered, so the fault was upstream."
|
||||
if (e.kind === "outage" && e.leg === "local")
|
||||
return "Router unreachable — nothing on the local network answered."
|
||||
return shortMac(e.detail || e.kind)
|
||||
}
|
||||
|
||||
function duration(e) {
|
||||
if (e.ended_ts === e.ts) return "\u2014"
|
||||
if (!e.ended_ts) return "ongoing"
|
||||
var s = e.ended_ts - e.ts
|
||||
if (s < 60) return s + "s"
|
||||
if (s < 3600) return Math.floor(s / 60) + "m " + (s % 60) + "s"
|
||||
return Math.floor(s / 3600) + "h " + Math.floor((s % 3600) / 60) + "m"
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: ribbonLabel.implicitHeight
|
||||
|
||||
Text {
|
||||
id: ribbonLabel
|
||||
textFormat: Text.PlainText
|
||||
text: "EXPERIENCE, LAST 24 HOURS"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
text: "1-minute buckets"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
Canvas {
|
||||
id: ribbon
|
||||
width: parent.width
|
||||
height: Style.space(22)
|
||||
|
||||
readonly property var rows: tab.ribbonRows
|
||||
onRowsChanged: requestPaint()
|
||||
onWidthChanged: requestPaint()
|
||||
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
// The unmonitored floor: minutes with no data stay this dark strip.
|
||||
ctx.fillStyle = Qt.rgba(tab.panel.fg.r, tab.panel.fg.g,
|
||||
tab.panel.fg.b, 0.06)
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
var start = Date.now() / 1000 - 86400
|
||||
var slice = width / 1440
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var r = rows[i]
|
||||
if (r.idx === null || r.idx === undefined) continue
|
||||
var x = (r.ts - start) / 86400 * width
|
||||
if (x < 0 || x > width) continue
|
||||
ctx.fillStyle = tab.bandColor(r.idx)
|
||||
ctx.fillRect(x, 0, Math.max(1, slice + 0.5), height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: axisLeft.implicitHeight
|
||||
|
||||
Text {
|
||||
id: axisLeft
|
||||
textFormat: Text.PlainText
|
||||
text: {
|
||||
var d = new Date(Date.now() - 86400 * 1000)
|
||||
return d.toLocaleString(Qt.locale(), "HH:mm") + " yest."
|
||||
}
|
||||
color: Qt.darker(tab.panel.dim, 1.2)
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.fontPx(0.75)
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
text: "now"
|
||||
color: Qt.darker(tab.panel.dim, 1.2)
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.fontPx(0.75)
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: Style.space(12)
|
||||
|
||||
component BandKey: Row {
|
||||
property color tint: "white"
|
||||
property string label: ""
|
||||
spacing: Style.space(5)
|
||||
Rectangle {
|
||||
width: Style.space(8); height: Style.space(8)
|
||||
color: parent.tint
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: parent.label
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
BandKey { tint: "#9ece6a"; label: "90+" }
|
||||
BandKey { tint: "#b9f27c"; label: "80" }
|
||||
BandKey { tint: "#e0af68"; label: "70" }
|
||||
BandKey { tint: "#eb927b"; label: "50" }
|
||||
BandKey { tint: "#f7768e"; label: "under 50" }
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: "LAST 7 DAYS"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Style.space(8)
|
||||
readonly property real cell: (width - Style.space(8) * 6) / 7
|
||||
|
||||
Repeater {
|
||||
model: tab.weekDays
|
||||
|
||||
Column {
|
||||
id: dayCol
|
||||
required property var modelData
|
||||
width: parent.cell
|
||||
spacing: Style.space(4)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: dayCol.modelData.idx === null ? "·" : String(dayCol.modelData.idx)
|
||||
color: dayCol.modelData.idx === null
|
||||
? tab.panel.dim : tab.panel.fg
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: Style.space(30)
|
||||
color: Qt.rgba(tab.panel.fg.r, tab.panel.fg.g, tab.panel.fg.b, 0.07)
|
||||
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: dayCol.modelData.idx === null
|
||||
? 0 : parent.height * Math.max(0.08, dayCol.modelData.idx / 100)
|
||||
color: tab.bandColor(dayCol.modelData.idx)
|
||||
}
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: dayCol.modelData.name
|
||||
color: Qt.darker(tab.panel.dim, 1.2)
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.fontPx(0.75)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: eventsLabel.implicitHeight
|
||||
|
||||
Text {
|
||||
id: eventsLabel
|
||||
textFormat: Text.PlainText
|
||||
text: "WHAT HAPPENED · LAST 24 H"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
text: tab.events.length === 0 ? "" :
|
||||
tab.events.length + (tab.events.length === 1 ? " event" : " events")
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: tab.events.length === 0
|
||||
text: "Nothing to report. A quiet log is the good outcome."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Style.space(9)
|
||||
|
||||
Repeater {
|
||||
model: tab.shownEpisodes
|
||||
|
||||
Row {
|
||||
id: eventRow
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
spacing: Style.space(10)
|
||||
|
||||
readonly property color tone: {
|
||||
var k = modelData.kind
|
||||
// Two link events keep colours of their own: a roam is purple
|
||||
// because it is neither good nor bad, an association is the link
|
||||
// coming up. Everything else takes the severity the daemon stored
|
||||
// WITH the event, so a kind added on the daemon side arrives
|
||||
// coloured. This used to be a second table keyed by kind, and
|
||||
// 0.2.4's gateway-quiet shipped warn-toned in the database and
|
||||
// accent-toned here because that table never learned of it.
|
||||
if (k === "roam") return "#bb9af7"
|
||||
if (k === "associate") return tab.panel.okTone
|
||||
var s = modelData.first ? modelData.first.severity : undefined
|
||||
if (s === "critical") return Color.urgent
|
||||
if (s === "warn") return tab.panel.warnTone
|
||||
// Rows written before 0.2.13 stored every link fault as "info".
|
||||
if (k === "kick" || k === "drop" || k === "rate-drop")
|
||||
return tab.panel.warnTone
|
||||
return Color.accent
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: Style.space(64)
|
||||
text: {
|
||||
var d = new Date(eventRow.modelData.ts * 1000)
|
||||
return d.toLocaleString(Qt.locale(), "ddd HH:mm")
|
||||
}
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: Style.space(5)
|
||||
height: Style.space(5)
|
||||
color: eventRow.tone
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: parent.width - Style.space(64) - Style.space(5)
|
||||
- Style.space(54) - Style.space(10) * 3
|
||||
text: tab.describeEpisode(eventRow.modelData)
|
||||
color: tab.panel.fg
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: Style.space(54)
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: tab.episodeDuration(eventRow.modelData)
|
||||
color: eventRow.modelData.count === 1 && !eventRow.modelData.first.ended_ts
|
||||
? Color.urgent : tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: tab.hiddenEvents > 0
|
||||
textFormat: Text.PlainText
|
||||
text: "+ " + tab.hiddenEvents + " earlier "
|
||||
+ (tab.hiddenEvents === 1 ? "event" : "events")
|
||||
+ " — the copied report has the full list."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: Math.max(copyHint.implicitHeight, copyButton.height)
|
||||
|
||||
Text {
|
||||
id: copyHint
|
||||
textFormat: Text.PlainText
|
||||
width: parent.width - copyButton.width - Style.space(12)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Copies a plain-text summary of the last 24 hours — timestamps, "
|
||||
+ "both legs, loss and events. The thing an ISP asks for."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: copyButton
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: copyText.implicitWidth + Style.space(24)
|
||||
height: Style.space(28)
|
||||
color: copyHover.hovered
|
||||
? Style.hoverFillFor(tab.panel.fg, Color.accent)
|
||||
: Style.normalFillFor(tab.panel.fg, Color.accent)
|
||||
border.width: Style.normalBorderWidth
|
||||
border.color: Style.normalBorderFor(tab.panel.fg, Color.accent)
|
||||
|
||||
Text {
|
||||
id: copyText
|
||||
textFormat: Text.PlainText
|
||||
anchors.centerIn: parent
|
||||
text: tab.copied ? " Copied" : " Copy report"
|
||||
color: tab.panel.fg
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
HoverHandler { id: copyHover }
|
||||
TapHandler {
|
||||
onTapped: {
|
||||
tab.panel.copyReport("24h")
|
||||
tab.copied = true
|
||||
copiedReset.restart()
|
||||
}
|
||||
}
|
||||
Timer {
|
||||
id: copiedReset
|
||||
interval: 2000
|
||||
onTriggered: tab.copied = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Extreme Labs
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,777 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// Latency in detail: window picker, the two-leg chart at full height, and
|
||||
// the per-leg statistics table.
|
||||
Column {
|
||||
id: tab
|
||||
|
||||
required property var panel
|
||||
|
||||
spacing: Style.space(12)
|
||||
|
||||
// The stats-table vocabulary, declared once for every table in this tab.
|
||||
// There were three identical copies of these three components, scoped
|
||||
// inside their own Grid, plus three formatters differing only in which
|
||||
// dash they printed for a missing value — the "fix it everywhere" trap
|
||||
// readout.js already sprang once. A missing figure is the em dash
|
||||
// throughout now, which is the panel's own grammar for "no value".
|
||||
component StatHead: Text {
|
||||
textFormat: Text.PlainText
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
component StatName: Text {
|
||||
textFormat: Text.PlainText
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
component StatVal: Text {
|
||||
textFormat: Text.PlainText
|
||||
color: tab.panel.fg
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
|
||||
function ms(v) {
|
||||
return v === null || v === undefined ? "\u2014" : v.toFixed(1) + " ms"
|
||||
}
|
||||
function pct(v) {
|
||||
return v === null || v === undefined ? "\u2014"
|
||||
: (v * 100).toFixed(2) + " %"
|
||||
}
|
||||
|
||||
Component.onCompleted: panel.requestTests()
|
||||
|
||||
// The last peak test that captured latency both ways.
|
||||
readonly property var loadTest: {
|
||||
var tests = panel.testsData && panel.testsData.tests ? panel.testsData.tests : []
|
||||
for (var i = 0; i < tests.length; i++) {
|
||||
var t = tests[i]
|
||||
if (t.kind === "peak" && t.ok && t.ping_idle !== null && t.ping_loaded !== null)
|
||||
return t
|
||||
}
|
||||
return null
|
||||
}
|
||||
// A peak test whose loaded latency came out BELOW its idle latency did
|
||||
// not measure the link under load: a queue cannot make packets arrive
|
||||
// sooner. The old code clamped the difference at zero, which graded that
|
||||
// A+ and handed back the best possible verdict on an unusable
|
||||
// measurement. Withhold it instead — the same wrong-direction rule the
|
||||
// daemon applies to the loaded/idle ratio.
|
||||
readonly property bool loadTestUsable: loadTest
|
||||
&& loadTest.ping_idle > 0
|
||||
&& loadTest.ping_loaded >= loadTest.ping_idle * 0.95
|
||||
|
||||
readonly property real addedMs: loadTestUsable
|
||||
? Math.max(0, loadTest.ping_loaded - loadTest.ping_idle) : 0
|
||||
// Waveform's grading of latency added under load.
|
||||
readonly property string bloatGrade: {
|
||||
if (!loadTestUsable) return ""
|
||||
if (addedMs < 5) return "A+"
|
||||
if (addedMs < 30) return "A"
|
||||
if (addedMs < 60) return "B"
|
||||
if (addedMs < 200) return "C"
|
||||
if (addedMs < 400) return "D"
|
||||
return "F"
|
||||
}
|
||||
readonly property color bloatTone: {
|
||||
if (bloatGrade === "A+" || bloatGrade === "A") return panel.okTone
|
||||
if (bloatGrade === "B" || bloatGrade === "C") return panel.warnTone
|
||||
return Color.urgent
|
||||
}
|
||||
|
||||
// The kernel's timing for the machine's own TCP connections, or null while
|
||||
// too few qualify — the daemon publishes nothing rather than a
|
||||
// distribution drawn from a handful of sockets.
|
||||
readonly property var sockets: panel.live ? panel.live.sockets : null
|
||||
|
||||
readonly property string socketsNote: {
|
||||
if (!sockets) return ""
|
||||
var q = sockets.queue_p50
|
||||
var head = "Measured from your own traffic, no probe. "
|
||||
if (q === null || q === undefined) return head
|
||||
// Deliberately a claim about the ABSOLUTE delay, not its share of the
|
||||
// round trip: a socket to another continent is mostly distance, and
|
||||
// saying "almost none of it is queueing" there would be false.
|
||||
if (q < 10) return head + "Queueing is a few milliseconds at most, so "
|
||||
+ "nothing on the path is holding your traffic up. The rest of each "
|
||||
+ "round trip is distance to the servers themselves."
|
||||
if (q < 30) return head + "There is real queueing on the path now \u2014 "
|
||||
+ "enough for a video call to start feeling it while something else "
|
||||
+ "is downloading."
|
||||
return head + "Queueing dominates what your apps feel. Something on the "
|
||||
+ "path is holding packets \u2014 the legs above say which side of the "
|
||||
+ "router it is on."
|
||||
}
|
||||
|
||||
// What the instruments line says when folded shut. A count is worth more
|
||||
// than the word "instruments" on its own, so the collapsed state still
|
||||
// carries the fact most worth knowing: how many are feeding the score.
|
||||
readonly property string instrumentSummary: {
|
||||
var ins = panel.live && panel.live.instruments ? panel.live.instruments : []
|
||||
if (!ins.length) return "NOT YET MEASURED"
|
||||
var scored = 0, quarantined = 0
|
||||
for (var i = 0; i < ins.length; i++) {
|
||||
if (ins[i].active) scored++
|
||||
else if (ins[i].quarantined) quarantined++
|
||||
}
|
||||
var parts = [scored + " SCORED"]
|
||||
var standby = ins.length - scored - quarantined
|
||||
if (standby > 0) parts.push(standby + " STANDBY")
|
||||
if (quarantined > 0) parts.push(quarantined + " QUARANTINED")
|
||||
return parts.join(" \u00b7 ")
|
||||
}
|
||||
|
||||
// The idle/loaded split, published on `lag`. Null until enough probes have
|
||||
// landed on each side of it to be worth comparing.
|
||||
readonly property var underLoad: {
|
||||
var l = panel.live && panel.live.lag ? panel.live.lag : null
|
||||
if (!l || l.loaded_p50 === null || l.loaded_p50 === undefined) return null
|
||||
return l
|
||||
}
|
||||
|
||||
// Collapsed, this line is the block: typical and worst while the link was
|
||||
// actually carrying traffic, which is the pair the headline 30 s window
|
||||
// averages away.
|
||||
readonly property string underLoadSummary: {
|
||||
var u = underLoad
|
||||
if (!u) return "NOT YET MEASURED"
|
||||
var p50 = u.loaded_p50 !== null && u.loaded_p50 !== undefined
|
||||
? Math.round(u.loaded_p50) : null
|
||||
var p95 = u.loaded_p95 !== null && u.loaded_p95 !== undefined
|
||||
? Math.round(u.loaded_p95) : null
|
||||
if (p50 === null) return "NOT YET MEASURED"
|
||||
var out = p50 + " MS TYPICAL"
|
||||
if (p95 !== null) out += " \u00b7 " + p95 + " WORST"
|
||||
return out
|
||||
}
|
||||
|
||||
readonly property string underLoadNote: {
|
||||
var u = underLoad
|
||||
if (!u) return ""
|
||||
var parts = []
|
||||
if (u.loaded_samples !== undefined)
|
||||
parts.push(u.loaded_samples + " of "
|
||||
+ (u.loaded_samples + (u.idle_samples || 0)) + " probes landed while "
|
||||
+ "the link was carrying traffic")
|
||||
// Depth is what everyone reports. Duration is what you feel after the
|
||||
// download has finished, and it is the half nobody shows.
|
||||
if (u.drain_ms !== null && u.drain_ms !== undefined) {
|
||||
var secs = (u.drain_ms / 1000).toFixed(1)
|
||||
parts.push(u.drain_settled
|
||||
? "the queue drained " + secs + " s after traffic stopped"
|
||||
: "still above its quiet level " + secs + " s after traffic stopped")
|
||||
}
|
||||
return parts.join(". ") + "."
|
||||
}
|
||||
|
||||
readonly property var windows: ["5m", "30m", "6h", "24h", "7d"]
|
||||
property string window: "30m"
|
||||
|
||||
// 5m/30m paint straight from recent.json; longer windows query history.
|
||||
readonly property bool fromRecent: window === "5m" || window === "30m"
|
||||
|
||||
onWindowChanged: if (!fromRecent) panel.requestHistory(window)
|
||||
|
||||
readonly property var chartPoints: {
|
||||
if (fromRecent) {
|
||||
var pts = panel.recentPoints
|
||||
if (window === "5m") {
|
||||
var cut = Date.now() / 1000 - 300
|
||||
pts = pts.filter(function(p) { return p.t >= cut })
|
||||
}
|
||||
return pts
|
||||
}
|
||||
var h = panel.history
|
||||
if (!h || !h.rows || panel.historyWindow !== window) return []
|
||||
return h.rows.map(function(r) {
|
||||
return {
|
||||
t: r.ts,
|
||||
local: r.local_p50,
|
||||
total: (r.local_p50 !== null && r.wan_p50 !== null)
|
||||
? r.local_p50 + r.wan_p50 : null,
|
||||
loss: ((r.local_loss || 0) + (r.wan_loss || 0)) > 0
|
||||
? (r.local_loss || 0) + (r.wan_loss || 0) : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: Style.space(5)
|
||||
|
||||
Repeater {
|
||||
model: tab.windows
|
||||
|
||||
Rectangle {
|
||||
id: pill
|
||||
required property string modelData
|
||||
readonly property bool selected: tab.window === modelData
|
||||
|
||||
width: pillLabel.implicitWidth + Style.space(22)
|
||||
height: Style.space(22)
|
||||
color: selected
|
||||
? Style.selectedFillFor(tab.panel.fg, Color.accent)
|
||||
: (pillHover.hovered
|
||||
? Style.hoverFillFor(tab.panel.fg, Color.accent)
|
||||
: Style.normalFillFor(tab.panel.fg, Color.accent))
|
||||
border.width: selected ? 0 : Style.normalBorderWidth
|
||||
border.color: Style.normalBorderFor(tab.panel.fg, Color.accent)
|
||||
|
||||
Text {
|
||||
id: pillLabel
|
||||
textFormat: Text.PlainText
|
||||
anchors.centerIn: parent
|
||||
text: pill.modelData.toUpperCase()
|
||||
color: pill.selected ? tab.panel.fg : tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
HoverHandler { id: pillHover }
|
||||
TapHandler { onTapped: tab.window = pill.modelData }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: !tab.fromRecent && tab.panel.historyLoading
|
||||
text: "loading…"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
LegChart {
|
||||
width: parent.width
|
||||
height: Style.space(140)
|
||||
points: tab.chartPoints
|
||||
wanColor: Color.accent
|
||||
localColor: tab.panel.dim
|
||||
minScaleMs: 10
|
||||
showScale: true
|
||||
fontFamily: tab.panel.fontFamily
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: Style.space(16)
|
||||
|
||||
component LegendEntry: Row {
|
||||
property color tint: "white"
|
||||
property string label: ""
|
||||
spacing: Style.space(6)
|
||||
Rectangle {
|
||||
width: Style.space(10); height: 2
|
||||
color: parent.tint
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: parent.label
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
LegendEntry { tint: Color.accent; label: "wan leg (router → internet)" }
|
||||
LegendEntry { tint: tab.panel.dim; label: "local leg (you → router)" }
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
// Stats table: rows of metric, local, wan — from live.json's 30 s window.
|
||||
Grid {
|
||||
width: parent.width
|
||||
columns: 3
|
||||
columnSpacing: Style.space(14)
|
||||
rowSpacing: Style.space(6)
|
||||
|
||||
readonly property real cell: (width - Style.space(14) * 2) / 3
|
||||
readonly property var local: tab.panel.live ? tab.panel.live.local : null
|
||||
readonly property var wan: tab.panel.live ? tab.panel.live.wan : null
|
||||
|
||||
|
||||
StatHead { width: parent.cell; text: "LAST 30 S" ; horizontalAlignment: Text.AlignLeft }
|
||||
StatHead { width: parent.cell; text: "LOCAL LEG" }
|
||||
StatHead { width: parent.cell; text: "WAN LEG" }
|
||||
|
||||
StatName { width: parent.cell; text: "median" }
|
||||
StatVal { width: parent.cell; text: tab.ms(parent.local ? parent.local.p50 : null) }
|
||||
StatVal { width: parent.cell; text: tab.ms(parent.wan ? parent.wan.p50 : null) }
|
||||
|
||||
StatName { width: parent.cell; text: "p95" }
|
||||
StatVal { width: parent.cell; text: tab.ms(parent.local ? parent.local.p95 : null) }
|
||||
StatVal { width: parent.cell; text: tab.ms(parent.wan ? parent.wan.p95 : null) }
|
||||
|
||||
StatName { width: parent.cell; text: "worst" }
|
||||
StatVal { width: parent.cell; text: tab.ms(parent.local ? parent.local.max : null) }
|
||||
StatVal { width: parent.cell; text: tab.ms(parent.wan ? parent.wan.max : null) }
|
||||
|
||||
StatName { width: parent.cell; text: "jitter" }
|
||||
StatVal { width: parent.cell; text: tab.ms(parent.local ? parent.local.jitter : null) }
|
||||
StatVal { width: parent.cell; text: tab.ms(parent.wan ? parent.wan.jitter : null) }
|
||||
|
||||
StatName { width: parent.cell; text: "loss" }
|
||||
StatVal {
|
||||
width: parent.cell
|
||||
text: tab.pct(parent.local ? parent.local.loss : null)
|
||||
color: parent.local && parent.local.loss > 0 ? tab.panel.warnTone : tab.panel.fg
|
||||
}
|
||||
StatVal {
|
||||
width: parent.cell
|
||||
text: tab.pct(parent.wan ? parent.wan.loss : null)
|
||||
color: parent.wan && parent.wan.loss > 0 ? tab.panel.warnTone : tab.panel.fg
|
||||
}
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
// ---- what the machine's own connections are experiencing ----------------
|
||||
// The table above is our probe. This is the kernel's own timing for the
|
||||
// user's real TCP traffic to their real destinations: no probe, no
|
||||
// privilege. `floor` is the lowest round trip each path has ever shown, so
|
||||
// "queueing" is the delay left once distance is divided out — which is the
|
||||
// only figure that compares a socket next door with one on another
|
||||
// continent.
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: "AS YOUR APPS SEE IT"
|
||||
+ (tab.sockets ? " \u00b7 " + tab.sockets.sockets + " CONNECTIONS" : "")
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: !tab.sockets
|
||||
width: parent.width
|
||||
wrapMode: Text.WordWrap
|
||||
text: "Not enough measured connections yet. This fills in once a few "
|
||||
+ "apps are talking over TCP."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
Grid {
|
||||
width: parent.width
|
||||
visible: !!tab.sockets
|
||||
columns: 3
|
||||
columnSpacing: Style.space(14)
|
||||
rowSpacing: Style.space(6)
|
||||
|
||||
readonly property real cell: (width - Style.space(14) * 2) / 3
|
||||
readonly property var s: tab.sockets
|
||||
|
||||
|
||||
StatHead { width: parent.cell; text: "TCP, LIVE"; horizontalAlignment: Text.AlignLeft }
|
||||
StatHead { width: parent.cell; text: "TYPICAL" }
|
||||
StatHead { width: parent.cell; text: "WORST" }
|
||||
|
||||
StatName { width: parent.cell; text: "round trip" }
|
||||
StatVal { width: parent.cell; text: tab.ms(parent.s ? parent.s.rtt_p50 : null) }
|
||||
StatVal { width: parent.cell; text: tab.ms(parent.s ? parent.s.rtt_p95 : null) }
|
||||
|
||||
StatName { width: parent.cell; text: "path floor" }
|
||||
StatVal { width: parent.cell; text: tab.ms(parent.s ? parent.s.floor_p50 : null) }
|
||||
StatVal { width: parent.cell; text: "\u2014" }
|
||||
|
||||
StatName { width: parent.cell; text: "queueing" }
|
||||
StatVal {
|
||||
width: parent.cell
|
||||
text: tab.ms(parent.s ? parent.s.queue_p50 : null)
|
||||
color: parent.s && parent.s.queue_p50 > 30 ? tab.panel.warnTone : tab.panel.fg
|
||||
}
|
||||
StatVal {
|
||||
width: parent.cell
|
||||
text: tab.ms(parent.s ? parent.s.queue_p95 : null)
|
||||
color: parent.s && parent.s.queue_p95 > 60 ? tab.panel.warnTone : tab.panel.fg
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: !!tab.sockets
|
||||
width: parent.width
|
||||
wrapMode: Text.WordWrap
|
||||
text: tab.socketsNote
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
// Folded shut by default: four rows and a paragraph is a lot of the tab's
|
||||
// height for something that only matters when you are asking which probe
|
||||
// produced the number. The header still reports the count, and the whole
|
||||
// row is the control — no separate button, no extra line.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: instHeader.implicitHeight
|
||||
|
||||
Text {
|
||||
id: instHeader
|
||||
textFormat: Text.PlainText
|
||||
text: "INSTRUMENTS \u00b7 " + (tab.panel.instrumentsExpanded
|
||||
? "WHAT MEASURES THE INTERNET LEG" : tab.instrumentSummary)
|
||||
color: instHover.hovered ? tab.panel.fg : tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: instHeader.verticalCenter
|
||||
// nf-md-chevron_down / nf-md-chevron_right
|
||||
text: tab.panel.instrumentsExpanded ? "\u{f0140}" : "\u{f0142}"
|
||||
color: instHover.hovered ? tab.panel.fg : tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
HoverHandler { id: instHover }
|
||||
TapHandler {
|
||||
onTapped: tab.panel.instrumentsExpanded = !tab.panel.instrumentsExpanded
|
||||
}
|
||||
}
|
||||
|
||||
// The bench: the two with the fewest losses and steadiest tails hold
|
||||
// the seats and feed the score; the rest idle at a tenth of the rate.
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: tab.panel.instrumentsExpanded
|
||||
height: visible ? implicitHeight : 0
|
||||
spacing: Style.space(6)
|
||||
|
||||
Repeater {
|
||||
model: tab.panel.live && tab.panel.live.instruments
|
||||
? tab.panel.live.instruments : []
|
||||
|
||||
Row {
|
||||
id: instRow
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
spacing: Style.space(8)
|
||||
|
||||
Rectangle {
|
||||
width: Style.space(5)
|
||||
height: Style.space(5)
|
||||
color: instRow.modelData.active ? tab.panel.okTone : tab.panel.dim
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: parent.width - Style.space(5) - Style.space(60)
|
||||
- Style.space(56) - Style.space(74) - Style.space(8) * 4
|
||||
text: instRow.modelData.kind + " \u00b7 " + instRow.modelData.target
|
||||
color: tab.panel.fg
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: Style.space(60)
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: instRow.modelData.p50 !== null && instRow.modelData.p50 !== undefined
|
||||
? instRow.modelData.p50.toFixed(1) + " ms" : "--"
|
||||
color: tab.panel.fg
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: Style.space(56)
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: {
|
||||
var l = instRow.modelData.loss
|
||||
return l !== null && l !== undefined ? (l * 100).toFixed(1) + "%" : "--"
|
||||
}
|
||||
color: instRow.modelData.loss ? tab.panel.warnTone : tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: Style.space(74)
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: instRow.modelData.active ? "scored"
|
||||
: instRow.modelData.quarantined ? "quarantined" : "standby"
|
||||
color: instRow.modelData.active ? tab.panel.okTone : tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: tab.panel.instrumentsExpanded
|
||||
height: visible ? implicitHeight : 0
|
||||
width: parent.width
|
||||
text: "Two instruments feed the score at a time, re-ranked every five "
|
||||
+ "minutes on loss and tail stability \u2014 one bad anchor cannot "
|
||||
+ "poison the number."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
// ---- latency under load (bufferbloat) -----------------------------------
|
||||
Item {
|
||||
width: parent.width
|
||||
height: loadLabel.implicitHeight
|
||||
|
||||
Text {
|
||||
id: loadLabel
|
||||
textFormat: Text.PlainText
|
||||
text: "LATENCY UNDER LOAD"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
text: tab.loadTestUsable ? "measured during the last peak test" : ""
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: !tab.loadTestUsable
|
||||
width: parent.width
|
||||
text: tab.loadTest && !tab.loadTestUsable
|
||||
? "The last peak test read a lower latency under load than at rest, "
|
||||
+ "which is not something a busy link can do — so it is not graded. "
|
||||
+ "Run another and it should settle."
|
||||
: "No measurement yet — run a peak test and the probes will time the "
|
||||
+ "connection while it is saturated."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
visible: tab.loadTestUsable
|
||||
spacing: Style.space(12)
|
||||
|
||||
Column {
|
||||
width: parent.width - gradeCol.width - Style.space(12)
|
||||
spacing: Style.space(6)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
readonly property real scaleMs: tab.loadTest
|
||||
? Math.max(1, Math.max(tab.loadTest.ping_idle, tab.loadTest.ping_loaded) * 2.2)
|
||||
: 1
|
||||
|
||||
component LoadBar: Row {
|
||||
property string label: ""
|
||||
property var ms: null
|
||||
property color tint: tab.panel.okTone
|
||||
width: parent.width
|
||||
spacing: Style.space(9)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: Style.space(44)
|
||||
text: parent.label
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width - Style.space(44) - Style.space(52) - Style.space(9) * 2
|
||||
height: Style.space(8)
|
||||
color: Qt.rgba(tab.panel.fg.r, tab.panel.fg.g, tab.panel.fg.b, 0.10)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Rectangle {
|
||||
height: parent.height
|
||||
width: parent.width * (parent.parent.ms !== null
|
||||
? Math.min(1, parent.parent.ms / parent.parent.parent.scaleMs) : 0)
|
||||
color: parent.parent.tint
|
||||
}
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: Style.space(52)
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: parent.ms !== null ? Math.round(parent.ms) + " ms" : "--"
|
||||
color: tab.panel.fg
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
LoadBar {
|
||||
label: "idle"
|
||||
ms: tab.loadTest ? tab.loadTest.ping_idle : null
|
||||
tint: tab.panel.okTone
|
||||
}
|
||||
LoadBar {
|
||||
label: "loaded"
|
||||
ms: tab.loadTest ? tab.loadTest.ping_loaded : null
|
||||
tint: tab.addedMs < 30 ? tab.panel.okTone : tab.panel.warnTone
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: gradeCol
|
||||
width: Style.space(76)
|
||||
spacing: Style.space(2)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: tab.bloatGrade
|
||||
color: tab.bloatTone
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.fontPx(1.7)
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: "BUFFERBLOAT"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: tab.loadTestUsable
|
||||
width: parent.width
|
||||
text: "+" + Math.round(tab.addedMs) + " ms added under full load. Below 30 ms "
|
||||
+ "a video call stays clean while someone else is downloading."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
// ---- under load, from the traffic you were already sending -------------
|
||||
// The block above needs a peak test. This one needs nothing: every probe
|
||||
// carries whether the link was busy when it landed, so the same samples
|
||||
// answer both "how bad does it get while in use" and "how fast does it
|
||||
// recover" without generating a byte.
|
||||
PanelSeparator { width: parent.width; visible: !!tab.underLoad }
|
||||
|
||||
// Folded shut like the bench above it: adding a table and a note pushed
|
||||
// the tab past the panel's height again, and the collapsed line already
|
||||
// carries the two numbers worth reading.
|
||||
Item {
|
||||
width: parent.width
|
||||
visible: !!tab.underLoad
|
||||
height: visible ? ulHeader.implicitHeight : 0
|
||||
|
||||
Text {
|
||||
id: ulHeader
|
||||
textFormat: Text.PlainText
|
||||
text: "WHILE YOU WERE USING IT \u00b7 " + (tab.panel.underLoadExpanded
|
||||
? "LAST 5 MIN" : tab.underLoadSummary)
|
||||
color: ulHover.hovered ? tab.panel.fg : tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: ulHeader.verticalCenter
|
||||
text: tab.panel.underLoadExpanded ? "\u{f0140}" : "\u{f0142}"
|
||||
color: ulHover.hovered ? tab.panel.fg : tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
HoverHandler { id: ulHover }
|
||||
TapHandler {
|
||||
onTapped: tab.panel.underLoadExpanded = !tab.panel.underLoadExpanded
|
||||
}
|
||||
}
|
||||
|
||||
Grid {
|
||||
width: parent.width
|
||||
visible: !!tab.underLoad && tab.panel.underLoadExpanded
|
||||
height: visible ? implicitHeight : 0
|
||||
columns: 3
|
||||
columnSpacing: Style.space(14)
|
||||
rowSpacing: Style.space(6)
|
||||
|
||||
readonly property real cell: (width - Style.space(14) * 2) / 3
|
||||
readonly property var u: tab.underLoad
|
||||
|
||||
|
||||
StatHead { width: parent.cell; text: "PROBES"; horizontalAlignment: Text.AlignLeft }
|
||||
StatHead { width: parent.cell; text: "TYPICAL" }
|
||||
StatHead { width: parent.cell; text: "WORST" }
|
||||
|
||||
StatName { width: parent.cell; text: "while busy" }
|
||||
StatVal {
|
||||
width: parent.cell
|
||||
text: tab.ms(parent.u ? parent.u.loaded_p50 : null)
|
||||
}
|
||||
// Scoped to the samples taken under load, so a short burst is not
|
||||
// averaged away by the quiet either side of it — which is what the
|
||||
// headline 30 s window does to it.
|
||||
StatVal {
|
||||
width: parent.cell
|
||||
text: tab.ms(parent.u ? parent.u.loaded_p95 : null)
|
||||
color: parent.u && parent.u.loaded_p95 > 100
|
||||
? tab.panel.warnTone : tab.panel.fg
|
||||
}
|
||||
|
||||
StatName { width: parent.cell; text: "while quiet" }
|
||||
StatVal {
|
||||
width: parent.cell
|
||||
text: tab.ms(parent.u ? parent.u.idle_p50 : null)
|
||||
}
|
||||
StatVal { width: parent.cell; text: "\u2014" }
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: !!tab.underLoad && tab.panel.underLoadExpanded
|
||||
height: visible ? implicitHeight : 0
|
||||
width: parent.width
|
||||
wrapMode: Text.WordWrap
|
||||
text: tab.underLoadNote
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import "readout.js" as Readout
|
||||
|
||||
// The two-leg latency chart: local leg as a dim band from the baseline, wan
|
||||
// leg stacked on top in the accent colour, loss as ticks along the floor.
|
||||
// Stacking is the point — the top line is the latency you feel, and the
|
||||
// split says which side of the router owns it.
|
||||
//
|
||||
// `points` is an array of {t, local, total, loss} — local/total in ms or
|
||||
// null, loss 0..1 or null. X is time, so a suspend shows as a gap.
|
||||
Canvas {
|
||||
id: chart
|
||||
|
||||
property var points: []
|
||||
property color wanColor: Color.accent
|
||||
property color localColor: Color.muted
|
||||
property color lossColor: Color.urgent
|
||||
property color axisColor: Qt.rgba(Color.popups.text.r, Color.popups.text.g,
|
||||
Color.popups.text.b, 0.15)
|
||||
property real minScaleMs: 20
|
||||
// Larger charts label their scale; the compact overview chart stays clean.
|
||||
property bool showScale: false
|
||||
property string fontFamily: Style.font.family
|
||||
// Hover crosshair with the values under the cursor. -1 = no hover.
|
||||
property real hoverX: -1
|
||||
property color readoutBg: Color.popups.background
|
||||
property color readoutFg: Color.popups.text
|
||||
|
||||
readonly property real peakMs: {
|
||||
var peak = 0
|
||||
for (var i = 0; i < points.length; i++) {
|
||||
var p = points[i]
|
||||
if (p.total !== null && p.total !== undefined) peak = Math.max(peak, p.total)
|
||||
}
|
||||
return Math.max(minScaleMs, peak) * 1.08
|
||||
}
|
||||
|
||||
onPointsChanged: requestPaint()
|
||||
onWidthChanged: requestPaint()
|
||||
onHeightChanged: requestPaint()
|
||||
onWanColorChanged: requestPaint()
|
||||
onHoverXChanged: requestPaint()
|
||||
|
||||
HoverHandler {
|
||||
onPointChanged: chart.hoverX = hovered ? point.position.x : -1
|
||||
onHoveredChanged: if (!hovered) chart.hoverX = -1
|
||||
}
|
||||
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
var top = 2, bottom = height - 1
|
||||
|
||||
ctx.strokeStyle = axisColor
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, bottom + 0.5)
|
||||
ctx.lineTo(width, bottom + 0.5)
|
||||
ctx.stroke()
|
||||
|
||||
if (showScale) {
|
||||
// Quarter gridlines plus the top-of-scale label, so a quiet line
|
||||
// reads as "3 ms on a 10 ms scale" rather than as an empty box.
|
||||
// Barely-there on purpose: they are reference, not content — and
|
||||
// Qt.rgba here sets an absolute alpha, so this must be far below
|
||||
// the baseline's 0.15, not a multiplier of it.
|
||||
ctx.strokeStyle = Qt.rgba(axisColor.r, axisColor.g, axisColor.b, 0.05)
|
||||
for (var g = 1; g <= 3; g++) {
|
||||
var gy = Math.round(bottom - (bottom - top) * g / 4) + 0.5
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, gy)
|
||||
ctx.lineTo(width, gy)
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.fillStyle = Qt.rgba(localColor.r, localColor.g, localColor.b, 0.9)
|
||||
ctx.font = "10px " + fontFamily
|
||||
ctx.textBaseline = "top"
|
||||
ctx.fillText(Math.round(peakMs) + " ms", 4, 3)
|
||||
}
|
||||
|
||||
var pts = points
|
||||
if (!pts || pts.length < 2) return
|
||||
|
||||
var t0 = pts[0].t, t1 = pts[pts.length - 1].t
|
||||
var span = Math.max(1, t1 - t0)
|
||||
var peak = peakMs
|
||||
|
||||
function xAt(t) { return (t - t0) * (width - 1) / span }
|
||||
function yAt(ms) { return bottom - (bottom - top) * Math.max(0, ms) / peak }
|
||||
|
||||
// Runs of consecutive non-null samples paint as separate segments so a
|
||||
// gap in the data is a gap on screen, not a line drawn through it.
|
||||
function runs(key) {
|
||||
var out = [], current = []
|
||||
for (var i = 0; i < pts.length; i++) {
|
||||
var v = pts[i][key]
|
||||
if (v === null || v === undefined) {
|
||||
if (current.length > 1) out.push(current)
|
||||
current = []
|
||||
} else {
|
||||
current.push([xAt(pts[i].t), yAt(v)])
|
||||
}
|
||||
}
|
||||
if (current.length > 1) out.push(current)
|
||||
return out
|
||||
}
|
||||
|
||||
function area(run, tint, alpha) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(run[0][0], bottom)
|
||||
for (var i = 0; i < run.length; i++) ctx.lineTo(run[i][0], run[i][1])
|
||||
ctx.lineTo(run[run.length - 1][0], bottom)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = Qt.rgba(tint.r, tint.g, tint.b, alpha)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
function line(run, tint, w) {
|
||||
ctx.beginPath()
|
||||
for (var i = 0; i < run.length; i++) {
|
||||
if (i === 0) ctx.moveTo(run[i][0], run[i][1])
|
||||
else ctx.lineTo(run[i][0], run[i][1])
|
||||
}
|
||||
ctx.strokeStyle = tint
|
||||
ctx.lineWidth = w
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
var i, r
|
||||
var totalRuns = runs("total")
|
||||
for (i = 0; i < totalRuns.length; i++) {
|
||||
r = totalRuns[i]
|
||||
area(r, wanColor, 0.20)
|
||||
line(r, wanColor, 1.5)
|
||||
}
|
||||
var localRuns = runs("local")
|
||||
for (i = 0; i < localRuns.length; i++) {
|
||||
r = localRuns[i]
|
||||
area(r, localColor, 0.5)
|
||||
line(r, localColor, 1)
|
||||
}
|
||||
|
||||
ctx.fillStyle = lossColor
|
||||
for (i = 0; i < pts.length; i++) {
|
||||
if (pts[i].loss !== null && pts[i].loss !== undefined && pts[i].loss > 0)
|
||||
ctx.fillRect(xAt(pts[i].t) - 1, bottom - 8, 2, 8)
|
||||
}
|
||||
|
||||
// Hover crosshair: nearest sample by time, values in a readout box.
|
||||
if (hoverX >= 0) {
|
||||
var tAt = t0 + hoverX * span / (width - 1)
|
||||
var best = null, bestD = Infinity
|
||||
for (i = 0; i < pts.length; i++) {
|
||||
var d = Math.abs(pts[i].t - tAt)
|
||||
if (d < bestD) { bestD = d; best = pts[i] }
|
||||
}
|
||||
if (best) {
|
||||
var cx = xAt(best.t)
|
||||
ctx.strokeStyle = Qt.rgba(readoutFg.r, readoutFg.g, readoutFg.b, 0.4)
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx + 0.5, 0)
|
||||
ctx.lineTo(cx + 0.5, bottom)
|
||||
ctx.stroke()
|
||||
|
||||
var parts = [Qt.formatTime(new Date(best.t * 1000), "HH:mm:ss")]
|
||||
if (best.total !== null && best.total !== undefined) {
|
||||
var local = best.local !== null && best.local !== undefined ? best.local : 0
|
||||
// Same rule the daemon applies to the leg figures: if the router
|
||||
// answered slower than the internet behind it, total = local + wan
|
||||
// does not hold and the subtraction says nothing. Clamping the
|
||||
// negative to zero printed "wan 0.0 ms" — a perfect ISP leg from
|
||||
// an invalid measurement.
|
||||
parts.push(local > best.total + 1.0
|
||||
? "wan \u2014"
|
||||
: "wan " + Math.max(0, best.total - local).toFixed(1) + " ms")
|
||||
parts.push("local " + local.toFixed(1) + " ms")
|
||||
} else {
|
||||
parts.push("no data")
|
||||
}
|
||||
if (best.loss !== null && best.loss !== undefined && best.loss > 0)
|
||||
parts.push("loss " + Math.round(best.loss * 100) + "%")
|
||||
var label = parts.join(" · ")
|
||||
|
||||
Readout.draw(ctx, fontFamily, label, cx, width,
|
||||
readoutFg, readoutBg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "format.js" as Fmt
|
||||
|
||||
// The default tab: is it me or is it them, in one glance.
|
||||
Column {
|
||||
id: tab
|
||||
|
||||
required property var panel
|
||||
readonly property var live: panel.live
|
||||
|
||||
spacing: Style.space(12)
|
||||
|
||||
readonly property bool outage: live
|
||||
&& (live.state === "wan-down" || live.state === "local-down")
|
||||
|
||||
// Same shape the bar shows, so the two agree at a glance.
|
||||
function elapsed(since) {
|
||||
if (!since) return ""
|
||||
var s = Math.max(0, Math.round(Date.now() / 1000 - since))
|
||||
var m = Math.floor(s / 60)
|
||||
return m > 0 ? m + "m" + (s % 60) + "s" : s + "s"
|
||||
}
|
||||
|
||||
// Only on a captive network, so it costs no height the rest of the time.
|
||||
// It goes first because it is the one thing worth reading here: without
|
||||
// it, every number below is the portal answering rather than the
|
||||
// connection, and the panel would be blaming the router for a sign-in
|
||||
// page.
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: tab.live && tab.live.state === "captive"
|
||||
height: visible ? implicitHeight : 0
|
||||
width: parent.width
|
||||
wrapMode: Text.WordWrap
|
||||
text: "This network wants you to sign in. Something here is answering "
|
||||
+ "for the internet, so treat the numbers below as the sign-in page, "
|
||||
+ "not your connection."
|
||||
color: tab.panel.warnTone
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: "PATH"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
PathChain {
|
||||
width: parent.width
|
||||
live: tab.live
|
||||
panel: tab.panel
|
||||
anchor: tab.panel.setting("internetAnchor", "1.1.1.1")
|
||||
textColor: tab.panel.fg
|
||||
dimColor: tab.panel.dim
|
||||
}
|
||||
|
||||
// Lag summary line, Orb vocabulary: best / typical / worst.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: lagLabel.implicitHeight
|
||||
|
||||
Text {
|
||||
id: lagLabel
|
||||
textFormat: Text.PlainText
|
||||
text: "LAG"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
text: {
|
||||
var l = tab.live
|
||||
if (!l || !l.lag) return "--"
|
||||
// The band is null when the whole window was lost. `lag.now` is
|
||||
// still 1500 there because Responsiveness needs an anchor to land
|
||||
// on, but 1500 is not a round trip and printing it three times
|
||||
// said the link was replying slowly when it was not replying.
|
||||
if (l.lag.typical === null || l.lag.typical === undefined)
|
||||
return tab.outage ? "no reply" : "--"
|
||||
var best = l.lag.best !== null ? Math.round(l.lag.best) : "--"
|
||||
var worst = l.lag.worst !== null ? Math.round(l.lag.worst) : "--"
|
||||
return "best " + best + " · typical " + Math.round(l.lag.typical)
|
||||
+ " ms · worst " + worst
|
||||
}
|
||||
color: tab.panel.fg
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
// The three pillars.
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Style.space(14)
|
||||
|
||||
readonly property real cell: (width - Style.space(14) * 2) / 3
|
||||
readonly property var scores: tab.live && tab.live.scores ? tab.live.scores : {}
|
||||
|
||||
ScorePillar {
|
||||
width: parent.cell
|
||||
label: "RESPONSIVENESS"
|
||||
value: parent.scores.responsiveness !== undefined ? parent.scores.responsiveness : null
|
||||
note: {
|
||||
var l = tab.live
|
||||
if (!l || !l.lag) return "no data yet"
|
||||
if (l.lag.typical === null || l.lag.typical === undefined)
|
||||
return tab.outage ? "nothing is answering" : "no data yet"
|
||||
return "lag " + Math.round(l.lag.typical) + " ms typical"
|
||||
}
|
||||
textColor: tab.panel.fg
|
||||
dimColor: tab.panel.dim
|
||||
}
|
||||
ScorePillar {
|
||||
width: parent.cell
|
||||
label: "RELIABILITY"
|
||||
value: parent.scores.reliability !== undefined ? parent.scores.reliability : null
|
||||
// A 24-hour score barely moves in the first minute of an outage, so
|
||||
// the number stays 100 and is honest. Green is not: it reads as
|
||||
// reassurance next to a dead link. The caption carries the live fact
|
||||
// instead of restating the window, and it is short enough to fit —
|
||||
// the previous wording truncated mid-word in this column.
|
||||
toneOverride: tab.outage ? tab.panel.warnTone : null
|
||||
note: {
|
||||
var l = tab.live
|
||||
if (!l) return ""
|
||||
if (l.state === "captive") return "not signed in yet"
|
||||
if (tab.outage) {
|
||||
var d = tab.elapsed(l.down_since)
|
||||
return d ? "down " + d : "outage now"
|
||||
}
|
||||
return "last 24 h"
|
||||
}
|
||||
textColor: tab.panel.fg
|
||||
dimColor: tab.panel.dim
|
||||
}
|
||||
ScorePillar {
|
||||
width: parent.cell
|
||||
label: "SPEED"
|
||||
value: parent.scores.speed !== undefined ? parent.scores.speed : null
|
||||
// A figure the index is ignoring must not shout in red as though it
|
||||
// were the verdict — it is being reported, not counted.
|
||||
toneOverride: {
|
||||
var c = tab.live ? tab.live.speed_ctx : null
|
||||
return c && c.scored === false ? tab.panel.dim : null
|
||||
}
|
||||
note: {
|
||||
// Say why it is blank, or the pause reads as a fault. This is the
|
||||
// whole point of detecting the hotspot: the checks are ~14 MB each
|
||||
// and hourly, which is data the user did not offer.
|
||||
var m = tab.live ? tab.live.metered : null
|
||||
var ctx = tab.live ? tab.live.speed_ctx : null
|
||||
var held = m && m.care
|
||||
if (!ctx || ctx.last_down === null || ctx.last_down === undefined)
|
||||
return held ? "checks paused on " + m.label : "no content check yet"
|
||||
var mbps = Math.round(ctx.last_down) + " Mbps"
|
||||
// No content check runs while the line is down, so this figure is
|
||||
// from before it. Saying "measured" would imply it is current.
|
||||
if (tab.outage) return mbps + " before the drop"
|
||||
if (held) return mbps + " · checks paused"
|
||||
if (ctx.basis === "plan")
|
||||
return mbps + " vs " + Math.round(ctx.plan_down) + " plan"
|
||||
// Not counted, and why. One check is the arrival check on a link
|
||||
// that may still have been settling; a peak test that read far
|
||||
// higher has already disproved this figure.
|
||||
// Kept inside the column: "63 Mbps · usually 384" is the widest
|
||||
// caption that fits here, so anything longer elides mid-word. The
|
||||
// dim number already says it is not counted; this says why.
|
||||
if (ctx.scored === false) {
|
||||
if (ctx.peak_down)
|
||||
return mbps + " · test: " + Math.round(ctx.peak_down)
|
||||
return mbps + " · unconfirmed"
|
||||
}
|
||||
if (ctx.baseline_down && ctx.last_down < ctx.baseline_down * 0.6)
|
||||
return mbps + " · usually " + Math.round(ctx.baseline_down)
|
||||
return mbps + " measured"
|
||||
}
|
||||
textColor: tab.panel.fg
|
||||
dimColor: tab.panel.dim
|
||||
}
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
// 30-minute latency chart from recent.json.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: chartLabel.implicitHeight
|
||||
|
||||
Text {
|
||||
id: chartLabel
|
||||
textFormat: Text.PlainText
|
||||
text: "LATENCY · LAST 30 MIN"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
text: {
|
||||
var l = tab.live
|
||||
if (!l || !l.total || l.total.p50 === null) return ""
|
||||
return "p50 " + Math.round(l.total.p50) + " ms · p95 "
|
||||
+ Math.round(l.total.p95) + " ms · jitter "
|
||||
+ (l.total.jitter !== null ? l.total.jitter.toFixed(1) : "--") + " ms"
|
||||
}
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
LegChart {
|
||||
width: parent.width
|
||||
height: Style.space(96)
|
||||
points: tab.panel.recentPoints
|
||||
wanColor: Color.accent
|
||||
localColor: tab.panel.dim
|
||||
showScale: true
|
||||
fontFamily: tab.panel.fontFamily
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: Style.space(16)
|
||||
|
||||
component LegendEntry: Row {
|
||||
property color tint: "white"
|
||||
property string label: ""
|
||||
property bool tick: false
|
||||
spacing: Style.space(6)
|
||||
Rectangle {
|
||||
width: parent.tick ? 2 : Style.space(10)
|
||||
height: parent.tick ? Style.space(8) : 2
|
||||
color: parent.tint
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: parent.label
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
LegendEntry { tint: Color.accent; label: "wan leg" }
|
||||
LegendEntry { tint: tab.panel.dim; label: "local leg" }
|
||||
LegendEntry { tint: Color.urgent; label: "packet loss"; tick: true }
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
// Speed strip + the run button.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: speedCol.implicitHeight
|
||||
|
||||
Column {
|
||||
id: speedCol
|
||||
spacing: Style.space(4)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: "THROUGHPUT NOW"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Row {
|
||||
spacing: Style.space(14)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: {
|
||||
var r = tab.live && tab.live.rates ? tab.live.rates.rx_bps : null
|
||||
return " " + Fmt.rate(r)
|
||||
}
|
||||
color: Color.accent
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.subtitle
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: {
|
||||
var r = tab.live && tab.live.rates ? tab.live.rates.tx_bps : null
|
||||
return " " + Fmt.rate(r)
|
||||
}
|
||||
color: tab.panel.warnTone
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.subtitle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: runLabel.implicitWidth + Style.space(24)
|
||||
height: Style.space(28)
|
||||
color: runHover.hovered
|
||||
? Style.hoverFillFor(tab.panel.fg, Color.accent)
|
||||
: Style.normalFillFor(tab.panel.fg, Color.accent)
|
||||
border.width: Style.normalBorderWidth
|
||||
border.color: Style.normalBorderFor(tab.panel.fg, Color.accent)
|
||||
|
||||
Text {
|
||||
id: runLabel
|
||||
textFormat: Text.PlainText
|
||||
anchors.centerIn: parent
|
||||
text: tab.live && tab.live.peak_running ? " Testing…"
|
||||
: tab.panel.peakArmed ? " Uses data · press again"
|
||||
: " Run test"
|
||||
color: tab.panel.fg
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
HoverHandler { id: runHover }
|
||||
TapHandler {
|
||||
enabled: !(tab.live && tab.live.peak_running)
|
||||
onTapped: tab.panel.runPeakTest()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// The Nexthop panel: header verdict, seven tabs, one alive at a time.
|
||||
//
|
||||
// All data arrives through files the daemon writes, but never through a
|
||||
// file handle the shell holds: `nexthop stream` reads live.json (the
|
||||
// always-current numbers), apps.json and recent.json (the default graphs)
|
||||
// and feeds them here as lines, and `nexthop query` runs on demand when a
|
||||
// tab asks for a longer window. The panel never probes anything itself.
|
||||
Panel {
|
||||
id: root
|
||||
moduleName: "io.github.x3me.nexthop"
|
||||
ipcTarget: "io.github.x3me.nexthop"
|
||||
manageIpc: false
|
||||
|
||||
property var anchorItem: null
|
||||
property var hostWidget: null
|
||||
readonly property var barIdentity: hostWidget || root
|
||||
|
||||
// ---- palette -------------------------------------------------------------
|
||||
readonly property color fg: bar ? bar.foreground : Color.popups.text
|
||||
readonly property color dim: Color.muted
|
||||
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
||||
readonly property color okTone: "#9ece6a"
|
||||
readonly property color warnTone: "#e0af68"
|
||||
|
||||
// Right-now congestion, from score.pressure: the fast channel the index
|
||||
// cannot be. Empty unless it is worth saying.
|
||||
readonly property string pressureSuffix: {
|
||||
var p = live && live.pressure ? live.pressure : null
|
||||
if (!p || !p.state || p.state === "clear") return ""
|
||||
return " \u00b7 " + p.state.toUpperCase()
|
||||
}
|
||||
|
||||
// Disclosure state for the panel's optional detail blocks. Lives here, not
|
||||
// on the tab, so moving between tabs does not fold them shut again. Resets
|
||||
// with the shell, which is the right lifetime for a view preference.
|
||||
property bool instrumentsExpanded: false
|
||||
property bool wanDetailOpen: false
|
||||
property bool underLoadExpanded: false
|
||||
|
||||
// A newer version is published. The daemon only ever reports this; the
|
||||
// panel only ever mentions it. Updating stays with `omarchy plugin update`,
|
||||
// which shows the diff and asks.
|
||||
readonly property bool updateAvailable:
|
||||
!!(live && live.update && live.update.available)
|
||||
|
||||
function updateTip() {
|
||||
return "A newer version of Nexthop is published.\n\n"
|
||||
+ "omarchy plugin update io.github.x3me.nexthop\n\n"
|
||||
+ "That shows you what changed before applying it. "
|
||||
+ "These checks can be turned off in the panel's Setup tab (the cog)."
|
||||
}
|
||||
|
||||
function bandColor(idx) {
|
||||
if (idx === null || idx === undefined) return dim
|
||||
if (idx >= 80) return okTone
|
||||
if (idx >= 50) return warnTone
|
||||
return Color.urgent
|
||||
}
|
||||
|
||||
// ---- state files ---------------------------------------------------------
|
||||
readonly property string stateDir: {
|
||||
var base = Quickshell.env("XDG_STATE_HOME")
|
||||
if (!base || base.length === 0) base = Quickshell.env("HOME") + "/.local/state"
|
||||
return base + "/nexthop"
|
||||
}
|
||||
// A URL, not a path: percent-encoded, so decode before it is used as a
|
||||
// filesystem path or a space in the way becomes %20 and cd fails — the
|
||||
// same form BarWidget and Service already use.
|
||||
readonly property string pluginDir:
|
||||
decodeURIComponent(Qt.resolvedUrl(".").toString())
|
||||
.replace(/^file:\/\//, "").replace(/\/$/, "")
|
||||
|
||||
// The panel opens no file of its own. The bar widget that creates it
|
||||
// owns the single `nexthop stream` reader — a bounded, non-blocking,
|
||||
// no-follow, regular-file-only read out in a small helper — and the
|
||||
// panel binds to what it already parsed.
|
||||
readonly property var live: hostWidget ? hostWidget.live : null
|
||||
readonly property var recent: hostWidget ? hostWidget.recent : null
|
||||
readonly property var appsData: hostWidget ? hostWidget.appsData : null
|
||||
// The bar widget keeps the clock; a snapshot it calls stale is history,
|
||||
// not a verdict, and the header must not present it as one. The tabs
|
||||
// keep drawing it — history is what they show anyway.
|
||||
readonly property bool stale: hostWidget ? hostWidget.stale === true : false
|
||||
readonly property int staleForS: hostWidget ? hostWidget.staleForS : 0
|
||||
|
||||
// recent.json points, trimmed to the slots that actually have data.
|
||||
readonly property var recentPoints: {
|
||||
if (!recent || !recent.points) return []
|
||||
var pts = recent.points
|
||||
var first = -1
|
||||
for (var i = 0; i < pts.length; i++) {
|
||||
if (pts[i].total !== null) { first = i; break }
|
||||
}
|
||||
return first < 0 ? [] : pts.slice(first)
|
||||
}
|
||||
|
||||
// ---- daemon config -------------------------------------------------------
|
||||
// Settings live in shell.json; the daemon can't read that, so mirror the
|
||||
// keys it cares about into its config file whenever they change.
|
||||
onSettingsChanged: writeDaemonConfig()
|
||||
Component.onCompleted: writeDaemonConfig()
|
||||
|
||||
function writeDaemonConfig() {
|
||||
var cfg = {
|
||||
internetAnchor: setting("internetAnchor", "1.1.1.1"),
|
||||
probeIntervalMs: setting("probeIntervalMs", 500),
|
||||
contentSpeed: setting("contentSpeed", true),
|
||||
contentSpeedIntervalMin: setting("contentSpeedIntervalMin", 60),
|
||||
peakEngine: setting("peakEngine", "Auto"),
|
||||
planDownMbps: setting("planDownMbps", 0),
|
||||
planUpMbps: setting("planUpMbps", 0),
|
||||
notifyOutage: setting("notifyOutage", true),
|
||||
updateCheck: setting("updateCheck", true),
|
||||
meteredCare: setting("meteredCare", true),
|
||||
historyDays: setting("historyDays", 7),
|
||||
throughputWindowS: setting("throughputWindowS", 3),
|
||||
}
|
||||
// Written atomically via mkstemp + rename — the same discipline the
|
||||
// daemon's own writers use. A shell redirection here would be a
|
||||
// truncating, symlink-following write at a predictable path: exactly
|
||||
// the class the security review flagged on the lock file.
|
||||
configWriter.command = ["python3", "-c",
|
||||
"import os, sys, tempfile\n" +
|
||||
"d = sys.argv[2]\n" +
|
||||
"os.makedirs(d, mode=0o700, exist_ok=True)\n" +
|
||||
"fd, tmp = tempfile.mkstemp(dir=d)\n" +
|
||||
"try:\n" +
|
||||
" os.write(fd, sys.argv[1].encode())\n" +
|
||||
" os.close(fd)\n" +
|
||||
" os.replace(tmp, os.path.join(d, 'config.json'))\n" +
|
||||
"except BaseException:\n" +
|
||||
" os.unlink(tmp)\n" +
|
||||
" raise\n",
|
||||
JSON.stringify(cfg), stateDir]
|
||||
configWriter.running = true
|
||||
}
|
||||
|
||||
Process { id: configWriter }
|
||||
|
||||
// ---- history queries -----------------------------------------------------
|
||||
// Tabs ask for a window; results land in `history` tagged by the request.
|
||||
property var history: null
|
||||
property string historyWindow: ""
|
||||
property bool historyLoading: false
|
||||
|
||||
function requestHistory(window) {
|
||||
historyWindow = window
|
||||
historyLoading = true
|
||||
historyProc.command = ["sh", "-c",
|
||||
"cd \"$1\" && exec python3 -m nexthopd.cli query --window \"$2\"",
|
||||
"sh", pluginDir, window]
|
||||
historyProc.running = true
|
||||
}
|
||||
|
||||
Process {
|
||||
id: historyProc
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
root.historyLoading = false
|
||||
try { root.history = JSON.parse(text) } catch (e) { root.history = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
property var testsData: null
|
||||
function requestTests() {
|
||||
testsProc.command = ["sh", "-c",
|
||||
"cd \"$1\" && exec python3 -m nexthopd.cli tests --limit 24",
|
||||
"sh", pluginDir]
|
||||
testsProc.running = true
|
||||
}
|
||||
Process {
|
||||
id: testsProc
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: { try { root.testsData = JSON.parse(text) } catch (e) {} }
|
||||
}
|
||||
}
|
||||
|
||||
property var eventsData: null
|
||||
function requestEvents(window) {
|
||||
eventsProc.command = ["sh", "-c",
|
||||
"cd \"$1\" && exec python3 -m nexthopd.cli events --window \"$2\"",
|
||||
"sh", pluginDir, window || "7d"]
|
||||
eventsProc.running = true
|
||||
}
|
||||
Process {
|
||||
id: eventsProc
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: { try { root.eventsData = JSON.parse(text) } catch (e) {} }
|
||||
}
|
||||
}
|
||||
|
||||
// A phone sharing its data, and whether we are being careful with it.
|
||||
readonly property var metered: live && live.metered ? live.metered : null
|
||||
readonly property bool meteredCare: !!(metered && metered.care)
|
||||
|
||||
// The peak test is sized to saturate the link for a fixed duration, not
|
||||
// to a fixed size, so it costs whatever the connection can carry: 285 MB
|
||||
// measured on a fibre line, tens of megabytes on a phone. It also fires
|
||||
// from a middle-click on the bar, which is easy to hit by accident. So on
|
||||
// a metered link the first press arms it and the second runs it — never
|
||||
// blocked, because measuring the cellular link is sometimes exactly what
|
||||
// you want.
|
||||
property bool peakArmed: false
|
||||
|
||||
Timer {
|
||||
id: peakDisarm
|
||||
interval: 8000
|
||||
onTriggered: root.peakArmed = false
|
||||
}
|
||||
|
||||
function runPeakTest() {
|
||||
if (meteredCare && !peakArmed) {
|
||||
peakArmed = true
|
||||
peakDisarm.restart()
|
||||
return
|
||||
}
|
||||
peakArmed = false
|
||||
peakDisarm.stop()
|
||||
peakProc.command = ["sh", "-c",
|
||||
"cd \"$1\" && exec python3 -m nexthopd.cli peak", "sh", pluginDir]
|
||||
peakProc.running = true
|
||||
}
|
||||
Process { id: peakProc }
|
||||
|
||||
function copyReport(window) {
|
||||
reportProc.command = ["sh", "-c",
|
||||
"cd \"$1\" && python3 -m nexthopd.cli report --window \"$2\" | wl-copy",
|
||||
"sh", pluginDir, window || "24h"]
|
||||
reportProc.running = true
|
||||
}
|
||||
Process { id: reportProc }
|
||||
|
||||
// ---- open / close --------------------------------------------------------
|
||||
function open() {
|
||||
root.controller.show()
|
||||
setCenterHoverRevealSuppressed(false)
|
||||
}
|
||||
|
||||
function openFromHotkey() {
|
||||
root.controller.show()
|
||||
Qt.callLater(function() {
|
||||
if (root.opened) setCenterHoverRevealSuppressed(true)
|
||||
})
|
||||
}
|
||||
|
||||
function close() {
|
||||
// Hide first, and never the other way round. This used to call the bar
|
||||
// before hiding, so when 4.0.3 made that call throw, the exception took
|
||||
// the hide with it: the panel could not be closed by click, hotkey, IPC
|
||||
// or a popout switch, and a KeyboardPanel's dismissal layer covers the
|
||||
// whole screen. What the user can see must not depend on a host call we
|
||||
// do not own.
|
||||
root.controller.hide()
|
||||
setCenterHoverRevealSuppressed(false)
|
||||
}
|
||||
|
||||
function toggle() { root.opened ? root.close() : root.openFromHotkey() }
|
||||
|
||||
function switchPanel(direction) {
|
||||
if (root.bar && typeof root.bar.switchPanelFrom === "function")
|
||||
return root.bar.switchPanelFrom(root.barIdentity, direction)
|
||||
return false
|
||||
}
|
||||
|
||||
// 4.0.3 hands third-party widgets a PluginBarApi facade where this is a
|
||||
// readonly property fed by a setter; the property still answers `in`, so
|
||||
// asking whether it exists says nothing about whether it can be written.
|
||||
function setCenterHoverRevealSuppressed(value) {
|
||||
if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function")
|
||||
root.bar.setCenterHoverRevealSuppressed(value)
|
||||
else if (root.bar && "centerHoverRevealSuppressed" in root.bar)
|
||||
root.bar.centerHoverRevealSuppressed = value
|
||||
}
|
||||
|
||||
onOpenedChanged: {
|
||||
if (opened) Qt.callLater(function() {
|
||||
if (keyCatcher) keyCatcher.forceActiveFocus()
|
||||
})
|
||||
}
|
||||
|
||||
// ---- tabs ----------------------------------------------------------------
|
||||
readonly property var tabNames: ["Overview", "Latency", "Speed", "Wi-Fi",
|
||||
"Apps", "Events", "Setup"]
|
||||
// Setup carries a glyph rather than a word: it is a destination you visit
|
||||
// rarely, and giving it an equal seventh of the strip would cost the six
|
||||
// tabs that are read constantly. Its name stays in tabNames so the IPC
|
||||
// route (`showTab Setup`) and the arrow keys treat it like any other.
|
||||
readonly property int setupTab: tabNames.length - 1
|
||||
property int currentTab: 0
|
||||
|
||||
IpcHandler {
|
||||
target: root.ipcTarget
|
||||
function open(): void { root.openFromHotkey() }
|
||||
function close(): void { root.close() }
|
||||
function show(): void { root.openFromHotkey() }
|
||||
function hide(): void { root.close() }
|
||||
function toggle(): void { root.toggle() }
|
||||
function speedTest(): void { root.runPeakTest() }
|
||||
function showTab(name: string): void {
|
||||
var i = root.tabNames.indexOf(name)
|
||||
if (i >= 0) root.currentTab = i
|
||||
root.openFromHotkey()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- surface -------------------------------------------------------------
|
||||
KeyboardPanel {
|
||||
id: panelCard
|
||||
anchorItem: root.anchorItem
|
||||
owner: root.barIdentity
|
||||
bar: root.bar
|
||||
open: root.opened
|
||||
focusTarget: keyCatcher
|
||||
contentWidth: panelCard.fittedContentWidth(Style.space(524))
|
||||
contentHeight: panelCard.fittedContentHeight(bodyColumn.implicitHeight)
|
||||
|
||||
PanelKeyCatcher {
|
||||
id: keyCatcher
|
||||
anchors.fill: parent
|
||||
onCloseRequested: root.close()
|
||||
onTabRequested: function(direction) { root.switchPanel(direction) }
|
||||
|
||||
Keys.onPressed: function(event) {
|
||||
// Left/right walk the tab strip; 1-6 jump straight to a tab (Setup,
|
||||
// the seventh, has no digit — it is reached by arrow, click or IPC).
|
||||
if (event.key === Qt.Key_Left) {
|
||||
root.currentTab = (root.currentTab + root.tabNames.length - 1) % root.tabNames.length
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Right) {
|
||||
root.currentTab = (root.currentTab + 1) % root.tabNames.length
|
||||
event.accepted = true
|
||||
} else if (event.key >= Qt.Key_1 && event.key <= Qt.Key_6) {
|
||||
root.currentTab = event.key - Qt.Key_1
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: bodyColumn
|
||||
width: parent.width
|
||||
spacing: Style.space(12)
|
||||
|
||||
// ---- header: identity + verdict ---------------------------------
|
||||
Item {
|
||||
width: parent.width
|
||||
height: Math.max(headerLeft.implicitHeight, headerRight.implicitHeight)
|
||||
|
||||
Row {
|
||||
id: headerLeft
|
||||
spacing: Style.space(10)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: {
|
||||
var s = root.live ? root.live.state : ""
|
||||
if (s === "captive") return "" // nf-md-shield_lock: a gate, not a fault
|
||||
if (s === "local-down" || s === "wan-down") return ""
|
||||
return ""
|
||||
}
|
||||
color: root.bandColor(root.live && !root.stale ? root.live.index : null)
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.fontPx(1.6)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Column {
|
||||
spacing: Style.space(2)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: {
|
||||
var l = root.live
|
||||
if (!l) return "Nexthop"
|
||||
var name = l.link
|
||||
? (l.link.ssid || l.link.name || l.link.iface || "") : ""
|
||||
if (name) return name
|
||||
// The link is gone, so there is no network to name. The
|
||||
// app's own name sat here reading like a network called
|
||||
// Nexthop; say what is true instead.
|
||||
return l.state === "local-down" || l.state === "wan-down"
|
||||
? "No network" : "Nexthop"
|
||||
}
|
||||
color: root.fg
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.heading
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
Row {
|
||||
spacing: Style.space(6)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: {
|
||||
var l = root.live
|
||||
if (!l) return "WAITING FOR DAEMON"
|
||||
if (root.stale) return "NO DATA FOR " + root.staleForS + " S"
|
||||
if (l.state === "captive") return "SIGN-IN REQUIRED"
|
||||
if (l.state === "local-down") return "ROUTER UNREACHABLE"
|
||||
if (l.state === "wan-down") return "NO INTERNET · ROUTER OK"
|
||||
// The index is a weakest-link score whose slowest
|
||||
// component can pin it, so it answers "how has this
|
||||
// connection been" and not "is it bad right now".
|
||||
// Queueing answers the second, and only earns a word
|
||||
// here when it has one to say — clear adds nothing, so
|
||||
// the common case costs no space at all.
|
||||
var band = (l.band || "").toUpperCase()
|
||||
return band + root.pressureSuffix
|
||||
}
|
||||
color: root.stale ? root.warnTone
|
||||
: (root.live && root.live.state !== "online"
|
||||
? Color.urgent : root.dim)
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
// A newer version exists. Deliberately the quietest thing
|
||||
// that can still be found: one dim glyph beside the verdict,
|
||||
// the command on hover, and nothing that acts on its own.
|
||||
// Same grammar as the bench glyph on the Overview.
|
||||
Text {
|
||||
visible: root.updateAvailable
|
||||
textFormat: Text.PlainText
|
||||
text: "" // nf-md-update
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
HoverHandler { id: updateHover }
|
||||
PanelToolTip {
|
||||
visible: updateHover.hovered && root.updateAvailable
|
||||
text: root.updateTip()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: headerRight
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: indexText.implicitWidth + experienceLabel.implicitWidth
|
||||
+ Style.space(8)
|
||||
height: indexText.implicitHeight
|
||||
|
||||
Text {
|
||||
id: indexText
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
text: root.live && !root.stale && root.live.index !== null
|
||||
&& root.live.index !== undefined ? String(root.live.index) : "--"
|
||||
color: root.bandColor(root.live && !root.stale ? root.live.index : null)
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.fontPx(2.4)
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
// On the number's baseline rather than under it — the label is
|
||||
// one word, and a whole row of header for it was dead space.
|
||||
Text {
|
||||
id: experienceLabel
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: indexText.left
|
||||
anchors.rightMargin: Style.space(8)
|
||||
anchors.baseline: indexText.baseline
|
||||
text: "EXPERIENCE"
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tab strip ---------------------------------------------------
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Style.space(6)
|
||||
|
||||
Repeater {
|
||||
model: root.tabNames
|
||||
|
||||
Rectangle {
|
||||
id: tabButton
|
||||
required property string modelData
|
||||
required property int index
|
||||
readonly property bool selected: root.currentTab === index
|
||||
|
||||
readonly property bool isSetup: index === root.setupTab
|
||||
readonly property real setupWidth: Style.space(34)
|
||||
|
||||
width: isSetup ? setupWidth
|
||||
: (parent.width - Style.space(6) * (root.tabNames.length - 1)
|
||||
- setupWidth) / (root.tabNames.length - 1)
|
||||
height: Style.space(26)
|
||||
color: selected
|
||||
? Style.selectedFillFor(root.fg, Color.accent)
|
||||
: (tabHover.hovered
|
||||
? Style.hoverFillFor(root.fg, Color.accent)
|
||||
: Style.normalFillFor(root.fg, Color.accent))
|
||||
border.width: selected ? 0 : Style.normalBorderWidth
|
||||
border.color: Style.normalBorderFor(root.fg, Color.accent)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.centerIn: parent
|
||||
text: tabButton.isSetup ? "" : tabButton.modelData // nf-md-cog
|
||||
color: tabButton.selected ? root.fg : root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
HoverHandler { id: tabHover }
|
||||
TapHandler { onTapped: root.currentTab = tabButton.index }
|
||||
// A glyph does not name itself.
|
||||
PanelToolTip {
|
||||
visible: tabButton.isSetup && tabHover.hovered
|
||||
text: "Settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
// ---- the selected tab -------------------------------------------
|
||||
Loader {
|
||||
width: parent.width
|
||||
active: root.opened
|
||||
sourceComponent: [overviewTab, latencyTab, speedTab, wifiTab,
|
||||
appsTab, eventsTab, settingsTab][root.currentTab]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component { id: overviewTab; OverviewTab { panel: root } }
|
||||
Component { id: latencyTab; LatencyTab { panel: root } }
|
||||
Component { id: speedTab; SpeedTab { panel: root } }
|
||||
Component { id: wifiTab; WifiTab { panel: root } }
|
||||
Component { id: appsTab; AppsTab { panel: root } }
|
||||
Component { id: eventsTab; EventsTab { panel: root } }
|
||||
Component { id: settingsTab; SettingsTab { panel: root } }
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "pathspark.js" as Spark
|
||||
|
||||
// laptop — router — internet, with per-leg latency on the connecting lines.
|
||||
// The answer to "is it me or is it them", drawn rather than written.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var live: null
|
||||
// The configured anchor: the far node's fallback label when no WAN
|
||||
// address has been proven yet (see middle/far node text below).
|
||||
property string anchor: ""
|
||||
property color textColor: Color.popups.text
|
||||
property color dimColor: Color.muted
|
||||
|
||||
// A phone sharing its data, as detected by the daemon from the gateway
|
||||
// range. `label` is what to call it — iPhone, Phone, Hotspot.
|
||||
readonly property var metered: live && live.metered ? live.metered : null
|
||||
readonly property bool tethered: !!(metered && metered.tethered)
|
||||
|
||||
readonly property string middleTitle: tethered ? metered.label : "Router"
|
||||
|
||||
readonly property string middleDetail: {
|
||||
if (!live || !live.link) return ""
|
||||
// On a hotspot the gateway is always the same fixed address for the
|
||||
// platform, so it carries no information; the phone's own name does.
|
||||
// iOS names the hotspot after the device, so the network name IS the
|
||||
// handset's name.
|
||||
if (tethered) return live.link.ssid || live.link.gateway || ""
|
||||
return live.link.gateway || ""
|
||||
}
|
||||
|
||||
// Three minutes of each leg for the connectors. The ISP leg arrives already
|
||||
// subtracted per point — see score.wan_point_ms — so nothing here re-derives
|
||||
// it and the inversion guard keeps one implementation.
|
||||
readonly property var points: panel ? panel.recentPoints : []
|
||||
readonly property var localSeries: Spark.slots(points, "local", Spark.SLOTS)
|
||||
readonly property var wanSeries: Spark.slots(points, "wan", Spark.SLOTS)
|
||||
// One zero-based scale for both, so a 2 ms wobble cannot outdraw the WAN.
|
||||
readonly property real sparkMax: Spark.sharedMax([localSeries, wanSeries],
|
||||
Spark.SCALE_FLOOR_MS)
|
||||
// The panel already knows whether readings are arriving: the daemon writes
|
||||
// live.json at 2 Hz and the bar widget calls it stale after five seconds.
|
||||
// The ring is that fact drawn, not a second rule with a second clock.
|
||||
readonly property bool sparkLive: !!(panel && !panel.stale)
|
||||
|
||||
readonly property var localMs: live && live.local ? live.local.p50 : null
|
||||
readonly property var wanMs: live && live.wan ? live.wan.p50 : null
|
||||
readonly property bool localDown: live && live.state === "local-down"
|
||||
readonly property bool wanDown: live && live.state === "wan-down"
|
||||
|
||||
// The address this connection appears from, published by the daemon.
|
||||
// Shown masked: Overview screenshots end up on forums, and a screenshot
|
||||
// must not carry the poster's IP. Tapping the node reveals it; the
|
||||
// reveal is never persisted anywhere.
|
||||
readonly property var wanIp: live && live.wan_ip ? live.wan_ip : null
|
||||
readonly property var seated: {
|
||||
if (!live || !live.instruments) return []
|
||||
return live.instruments.filter(function(i) { return i.active })
|
||||
}
|
||||
|
||||
// What the info glyph on the Internet node explains on hover: which
|
||||
// instruments hold the scored seats right now. A caption line said
|
||||
// "2 probes live" here once — permanent height for a once-read fact.
|
||||
function benchTip() {
|
||||
var lines = []
|
||||
for (var i = 0; i < seated.length; i++) {
|
||||
var ins = seated[i]
|
||||
lines.push(ins.kind + " \u00b7 " + ins.target
|
||||
+ (ins.p50 !== null && ins.p50 !== undefined
|
||||
? " \u2014 " + ins.p50.toFixed(1) + " ms" : ""))
|
||||
}
|
||||
var standby = live && live.instruments
|
||||
? live.instruments.length - seated.length : 0
|
||||
if (standby > 0)
|
||||
lines.push(standby + " on standby \u2014 full bench on the Latency tab")
|
||||
lines.push(detailOpen ? "tap to close" : "tap for detail")
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
// Opening the detail is the deliberate act that reveals the address, so
|
||||
// the two are one gesture. Held by the Panel so switching tabs does not
|
||||
// close it; it resets with the shell, which is the right lifetime for a
|
||||
// view preference. Never persisted — an Overview screenshot taken
|
||||
// without opening this carries a masked address.
|
||||
// Held by the Panel, like the other disclosure toggles, so switching
|
||||
// tabs does not close it. Null-guarded: without a panel the detail
|
||||
// simply never opens rather than throwing on every tap.
|
||||
property var panel: null
|
||||
readonly property bool detailOpen: !!(panel && panel.wanDetailOpen)
|
||||
readonly property bool revealIp: detailOpen
|
||||
|
||||
function ipLine() {
|
||||
if (!wanIp || !wanIp.ip) return ""
|
||||
var parts = [String(wanIp.ip)]
|
||||
// Country and edge ride along in the response the reachability check
|
||||
// already fetches. The edge is Cloudflare's datacentre, not the
|
||||
// user's location, so it is labelled as the route and not as a place.
|
||||
if (wanIp.country) parts.push(wanIp.country)
|
||||
if (wanIp.edge) parts.push("via Cloudflare " + wanIp.edge)
|
||||
return parts.join(" \u00b7 ")
|
||||
}
|
||||
|
||||
function legLine() {
|
||||
var l = localMs, w = wanMs
|
||||
if ((l === null || l === undefined) && (w === null || w === undefined))
|
||||
return ""
|
||||
var f = function(v) {
|
||||
return v === null || v === undefined ? "\u2014" : v.toFixed(2) + " ms"
|
||||
}
|
||||
return "local " + f(l) + " \u00b7 wan " + f(w)
|
||||
}
|
||||
|
||||
function probeLine() {
|
||||
if (seated.length === 0) return ""
|
||||
var parts = []
|
||||
for (var i = 0; i < seated.length; i++) {
|
||||
var ins = seated[i]
|
||||
parts.push(ins.kind + " " + ins.target
|
||||
+ (ins.p50 !== null && ins.p50 !== undefined
|
||||
? " " + ins.p50.toFixed(1) : ""))
|
||||
}
|
||||
var standby = live && live.instruments
|
||||
? live.instruments.length - seated.length : 0
|
||||
var out = parts.join(" \u00b7 ")
|
||||
if (standby > 0) out += " (+" + standby + " standby)"
|
||||
return out
|
||||
}
|
||||
|
||||
function loadLine() {
|
||||
var lag = live && live.lag ? live.lag : null
|
||||
if (!lag || lag.idle === null || lag.idle === undefined
|
||||
|| lag.loaded === null || lag.loaded === undefined) return ""
|
||||
// The daemon withholds `inflation` when the two populations are too
|
||||
// close to separate or the ratio came out backwards. Absent inflation
|
||||
// means the pair is not trustworthy either, so the whole row goes —
|
||||
// printing "idle 13.0 -> loaded 10.9" states that the link answers
|
||||
// FASTER while busy, which queueing cannot do. A result that is wrong
|
||||
// in direction is not a result, and it is not made safe by dropping
|
||||
// only the ratio computed from it.
|
||||
if (lag.inflation === null || lag.inflation === undefined) return ""
|
||||
return "idle " + lag.idle.toFixed(1)
|
||||
+ " \u2192 loaded " + lag.loaded.toFixed(1) + " ms"
|
||||
+ " (" + lag.inflation.toFixed(2) + "\u00d7)"
|
||||
}
|
||||
|
||||
function appsLine() {
|
||||
var s = live && live.sockets ? live.sockets : null
|
||||
if (!s || s.queue_p50 === null || s.queue_p50 === undefined) return ""
|
||||
var out = "queue " + s.queue_p50.toFixed(1) + " ms typical"
|
||||
if (s.queue_p95 !== null && s.queue_p95 !== undefined)
|
||||
out += ", " + s.queue_p95.toFixed(1) + " worst"
|
||||
if (s.sockets) out += " over " + s.sockets + " connections"
|
||||
return out
|
||||
}
|
||||
|
||||
function maskedIp(w) {
|
||||
if (!w || !w.ip) return ""
|
||||
var full = String(w.ip)
|
||||
if (revealIp) return full
|
||||
if (w.family === "v6")
|
||||
return full.split(":").slice(0, 2).join(":") + ":\u2026"
|
||||
return full.split(".").slice(0, 2).join(".") + ".\u2026"
|
||||
}
|
||||
|
||||
function legColor(ms, down) {
|
||||
if (down) return Color.urgent
|
||||
if (ms === null || ms === undefined) return dimColor
|
||||
if (ms <= 15) return "#9ece6a"
|
||||
if (ms <= 50) return "#e0af68"
|
||||
return Color.urgent
|
||||
}
|
||||
|
||||
// One animator for both legs, stopped the moment the liveness claim stops
|
||||
// being true: a panel left open must never keep pulsing over stale data.
|
||||
property real ringPhase: 0
|
||||
NumberAnimation on ringPhase {
|
||||
running: root.sparkLive && root.motionOk
|
||||
loops: Animation.Infinite
|
||||
from: 0; to: 1; duration: 1400
|
||||
}
|
||||
// Omarchy's own animation preference; a user who turns the bar's motion off
|
||||
// gets a static second circle rather than nothing, so the claim still reads.
|
||||
readonly property bool motionOk: {
|
||||
var b = panel ? panel.bar : null
|
||||
if (!b || !("foregroundAnimationEnabled" in b)) return true
|
||||
return b.foregroundAnimationEnabled === true
|
||||
}
|
||||
|
||||
implicitHeight: stack.implicitHeight
|
||||
|
||||
Column {
|
||||
id: stack
|
||||
width: parent.width
|
||||
spacing: Style.space(10)
|
||||
|
||||
Row {
|
||||
id: row
|
||||
width: parent.width
|
||||
|
||||
component Node: Column {
|
||||
property string icon: ""
|
||||
property string title: ""
|
||||
property string detail: ""
|
||||
width: Style.space(84)
|
||||
spacing: Style.space(4)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: parent.icon
|
||||
color: root.textColor
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.iconLarge
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: parent.title
|
||||
color: root.textColor
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: parent.detail
|
||||
color: root.dimColor
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.caption
|
||||
elide: Text.ElideMiddle
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
|
||||
component Leg: Column {
|
||||
property var ms: null
|
||||
property bool down: false
|
||||
property string label: ""
|
||||
property var series: []
|
||||
width: (row.width - Style.space(84) * 3) / 2
|
||||
spacing: Style.space(4)
|
||||
// Sits a little above the node centres so the line meets the icons.
|
||||
topPadding: Style.space(8)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: parent.down ? "down"
|
||||
: (parent.ms === null || parent.ms === undefined
|
||||
? "--" : parent.ms.toFixed(1) + " ms")
|
||||
color: root.legColor(parent.ms, parent.down)
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
Canvas {
|
||||
id: spark
|
||||
width: parent.width - Style.space(12)
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
// 16 left the plot 9 px tall with nothing spare, so the ring was
|
||||
// cut on every side it could reach. The margin the ring needs is
|
||||
// fixed; buying it out of the plot would have left a 3 px band.
|
||||
// This keeps the same 9 px of plot and costs six pixels once —
|
||||
// the connectors are side by side, so it is six for the panel,
|
||||
// not six per leg.
|
||||
height: Style.space(22)
|
||||
antialiasing: true
|
||||
// Repainting on every phase tick is what the ring costs; the series
|
||||
// only changes every five seconds.
|
||||
property real phase: root.ringPhase
|
||||
property var series: parent.series
|
||||
property real sparkScale: root.sparkMax
|
||||
onPhaseChanged: requestPaint()
|
||||
onSeriesChanged: requestPaint()
|
||||
onSparkScaleChanged: requestPaint()
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
Spark.draw(ctx, width, height, series, {
|
||||
max: sparkScale,
|
||||
phase: phase,
|
||||
live: root.sparkLive,
|
||||
motion: root.motionOk,
|
||||
downColor: Color.urgent,
|
||||
colorFor: root.legColor
|
||||
})
|
||||
}
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: parent.label
|
||||
color: root.dimColor
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
}
|
||||
|
||||
Node {
|
||||
icon: "" // nf-md-laptop
|
||||
title: "This machine"
|
||||
detail: root.live && root.live.link ? (root.live.link.iface || "") : ""
|
||||
}
|
||||
Leg {
|
||||
ms: root.localMs
|
||||
down: root.localDown
|
||||
label: "LOCAL"
|
||||
series: root.localSeries
|
||||
}
|
||||
// When the connection comes from a phone, this node is the phone. Drawing
|
||||
// a router here mislabelled both legs at once: the local leg is the hop
|
||||
// to the handset, and everything past it is cellular, not an ISP line.
|
||||
Node {
|
||||
icon: root.tethered ? "" : "" // nf-md-cellphone / nf-md-router_wireless
|
||||
title: root.middleTitle
|
||||
detail: root.middleDetail
|
||||
}
|
||||
Leg {
|
||||
ms: root.wanMs
|
||||
down: root.wanDown
|
||||
label: "WAN"
|
||||
series: root.wanSeries
|
||||
}
|
||||
Node {
|
||||
icon: "" // nf-md-web
|
||||
title: "Internet"
|
||||
// Your address out there, not the probe target: with a bench of
|
||||
// instruments there is no single anchor to name, and naming one
|
||||
// read as "this monitors Cloudflare". Masked — screenshots end up
|
||||
// on forums; tap to reveal, never persisted.
|
||||
detail: (root.wanIp ? root.maskedIp(root.wanIp) : root.anchor)
|
||||
+ (root.seated.length > 0 ? " " : "")
|
||||
|
||||
TapHandler {
|
||||
onTapped: if (root.panel) root.panel.wanDetailOpen = !root.panel.wanDetailOpen
|
||||
}
|
||||
HoverHandler { id: inetHover }
|
||||
PanelToolTip {
|
||||
visible: inetHover.hovered && root.seated.length > 0
|
||||
text: root.benchTip()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What the far node knows, shown only when asked for. Everything here
|
||||
// is already measured — no extra request, no new destination — and it
|
||||
// is deliberately what a single-number internet score cannot show: two
|
||||
// legs, which instruments produced them, and what the machine's own
|
||||
// connections are experiencing.
|
||||
Column {
|
||||
id: detail
|
||||
width: parent.width
|
||||
spacing: Style.space(4)
|
||||
visible: root.detailOpen
|
||||
// A visible:false child still takes its share of a Column's spacing.
|
||||
height: visible ? implicitHeight : 0
|
||||
|
||||
component DetailRow: Row {
|
||||
property string label: ""
|
||||
property string value: ""
|
||||
visible: value !== ""
|
||||
height: visible ? implicitHeight : 0
|
||||
spacing: Style.space(8)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: parent.label
|
||||
color: root.dimColor
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
width: Style.space(72)
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: parent.value
|
||||
color: root.textColor
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.caption
|
||||
elide: Text.ElideRight
|
||||
width: detail.width - Style.space(80)
|
||||
}
|
||||
}
|
||||
|
||||
DetailRow { label: "ADDRESS"; value: root.ipLine() }
|
||||
DetailRow { label: "LEGS"; value: root.legLine() }
|
||||
DetailRow { label: "PROBES"; value: root.probeLine() }
|
||||
DetailRow { label: "UNDER LOAD"; value: root.loadLine() }
|
||||
DetailRow { label: "APPS SEE"; value: root.appsLine() }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
# Nexthop
|
||||
|
||||
**Is it your Wi-Fi, or your ISP? Know before you reboot anything.**
|
||||
|
||||
Nexthop sits in your [Omarchy](https://omarchy.org) bar and watches both
|
||||
halves of your connection — this machine to the router, and the router to
|
||||
the internet. When things feel slow, one glance tells you which side of
|
||||
the router owns the problem.
|
||||
|
||||

|
||||
|
||||
## Why you'd want it
|
||||
|
||||
- **An answer, not a graph.** One 0–100 score in the bar, colour-coded.
|
||||
Green means stop worrying. When it drops, the panel says why — in the
|
||||
words you'd use to a person: responsiveness, reliability, speed.
|
||||
- **"Is it me or is it them", settled.** The Overview draws your laptop,
|
||||
your router and the internet with a live latency number on each leg.
|
||||
The slow leg is the guilty one.
|
||||
- **Proof your ISP can't wave away.** Every outage is logged with its
|
||||
start, duration and which side failed. *Copy report* produces the
|
||||
plain-text summary a support desk actually asks for.
|
||||
- **It sees what you can't.** A router silently kicking your laptop off
|
||||
Wi-Fi, an access point renegotiating to a crawl, the 3 a.m. outage that
|
||||
was over before breakfast — all in the log, with timestamps and
|
||||
durations.
|
||||
- **Speed answers without speed tests.** Small hourly checks keep the
|
||||
speed score honest; the big saturating test runs only when you ask.
|
||||
|
||||
## It won't get in your way
|
||||
|
||||
- **No root, ever.** No sudo, no capabilities, no packet capture. It runs
|
||||
as your user and reads what any process may read.
|
||||
- **You won't feel it.** About 3 % of one CPU core and ~30 MB of memory —
|
||||
no fan, no stutter, nothing competing with your work.
|
||||
- **It doesn't clog your line.** The probes are pings and payload-free
|
||||
handshakes and add up to a few MB an hour; the hourly speed check is
|
||||
up to about 20 MB on a fast line and a fraction of that on a slower one,
|
||||
and can be turned off. Nothing saturates your connection
|
||||
unless you press the button.
|
||||
- **Private by construction.** Everything it measures stays on your machine
|
||||
— no account, no cloud, no telemetry. Even your own IP is shown masked, so
|
||||
a screenshot of the panel is safe to post. The one thing it asks the
|
||||
outside world is whether a newer version exists: once a day it asks the
|
||||
repository you installed from, sends nothing about you, and shows a small
|
||||
marker if so. Turn that off in the panel's Setup tab (the cog) and it
|
||||
asks nothing.
|
||||
|
||||
## Made for Omarchy
|
||||
|
||||
Nexthop is not a ported app — it is built for this desktop. It follows
|
||||
your theme automatically, uses the shell's own type and spacing, and
|
||||
opens instantly from the bar like every other panel. It is MIT-licensed,
|
||||
built in the open, and shaped by community feedback — the masked WAN
|
||||
address on the Overview came from a reader's suggestion the day after
|
||||
launch.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
omarchy plugin add https://github.com/x3me/omarchy-nexthop.git --enable
|
||||
```
|
||||
|
||||
Updating is Omarchy's own `omarchy plugin update`, which shows you
|
||||
what changed before applying it. Nexthop checks once a day whether a newer
|
||||
version is published and marks the panel header if so; it never installs
|
||||
anything itself.
|
||||
|
||||
Requirements: `python3`, `ping`, `curl`, `ss`, `ip` and `git` (all present on
|
||||
a stock Omarchy; `git` only serves the daily update check), `iw` for Wi-Fi
|
||||
detail, `wl-copy` for Copy report, optionally `nmcli` for the metered flag
|
||||
and `speedtest` (Ookla) for peak tests.
|
||||
|
||||
| Latency, by leg | Speed |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Wi-Fi | Applications |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
The event log, naming who did what — including the router that kicks:
|
||||
|
||||

|
||||
|
||||
## Using it
|
||||
|
||||
- **Bar widget**: the index, the lag figure, or just the icon
|
||||
(`displayMode` on the widget's entry in `~/.config/omarchy/shell.json`).
|
||||
Colour is the verdict; during an outage it shows how long you've been
|
||||
down. Click opens the panel, middle-click runs a peak test.
|
||||
- **Panel tabs**: Overview (is it me or is it them), Latency (window picker,
|
||||
per-leg stats, latency under load), Speed (content history + last peak),
|
||||
Wi-Fi (the local leg in detail, airtime, link events), Apps (who is using
|
||||
the connection), Events (what happened + Copy report), and a Setup cog
|
||||
with the four settings worth reaching from the panel. Arrow keys or 1–6
|
||||
switch tabs.
|
||||
- **CLI**: `bin/nexthop live | query --window 24h | events | tests | report | peak`
|
||||
— all JSON except `report`.
|
||||
- **IPC**: `omarchy-shell io.github.x3me.nexthop toggle | speedTest |
|
||||
showTab Latency`
|
||||
|
||||
## What it measures
|
||||
|
||||
- **Two-leg latency, twice a second.** One persistent probe to your
|
||||
gateway, others to the internet. The difference between the legs is
|
||||
your ISP; the gateway leg is your Wi-Fi.
|
||||
- **Lag** — one number for how the connection feels, folding latency, jitter
|
||||
(RFC 3550 IPDV) and packet loss, reported as best / typical / worst.
|
||||
- **An experience index (0–100)** from three components — the weakest sets
|
||||
the number and the other two nudge it, so one broken dimension cannot
|
||||
hide behind two good ones:
|
||||
- *Responsiveness* — scored from lag
|
||||
- *Reliability* — uptime, charged in time: outages in full, brief
|
||||
self-healed interruptions at half
|
||||
- *Speed* — scored from small periodic **content checks** (up to ~20 MB on
|
||||
a fast line, ~4 MB on a slow one,
|
||||
hourly by default), not from saturating speed tests. No configuration:
|
||||
the score answers "is it fast enough" on an experience-anchored curve
|
||||
(diminishing returns past ~100 Mbps), minus a penalty when the line
|
||||
drops well below **its own recent p90** — so shared-office variance
|
||||
stays quiet while genuine degradation shows. Setting a plan in the
|
||||
widget settings switches to plan-accountability scoring instead
|
||||
- **Peak speed tests, on demand only.** Prefers the official Ookla
|
||||
`speedtest` CLI when installed; falls back to Cloudflare, then fast.com —
|
||||
both need nothing beyond `curl`. Loaded latency (bufferbloat) is captured
|
||||
during every run by the probes that were already watching.
|
||||
- **Wi-Fi health**: signal against a labelled scale, band/channel/rates,
|
||||
and the airtime counters (retries, failures, beacon loss) that explain
|
||||
why Wi-Fi feels slow when the signal bar looks full.
|
||||
- **Link events**: every change of access point says who ended the
|
||||
previous association — a *roam* (this machine chose to move), a *kick*
|
||||
(the access point deauthenticated us, with its 802.11 reason code) or a
|
||||
*drop* (the link fell over) — plus channel and signal deltas,
|
||||
associations, and sustained Wi-Fi rate drops, logged with durations.
|
||||
- **WAN address**: the IP this connection appears from, on the Overview
|
||||
path — masked (`103.87.…`) with tap-to-reveal, because panel screenshots
|
||||
end up on forums. Asked of `speed.cloudflare.com`, a host the daemon
|
||||
already talks to; shown live, never written to history.
|
||||
- **Per-application traffic** — top apps by TCP connection counters
|
||||
(`ss -tinp`, no root, no packet capture), each with a one-minute history
|
||||
strip and session totals. What Linux won't attribute without privileges
|
||||
(QUIC/UDP, overhead) is shown as its own bucket rather than hidden.
|
||||
- **Outage detection** with a notification once an outage has lasted a few
|
||||
seconds and one when it clears — naming the leg that failed. Brief
|
||||
self-healed interruptions are logged, never notified. A wan outage needs the
|
||||
probes to agree: if TCP handshakes keep succeeding while pings go
|
||||
unanswered, the log records an "ICMP went quiet" event instead — no
|
||||
alarm for downtime you are not having.
|
||||
- **History**: per-minute for 7 days (configurable), hourly for a year,
|
||||
every test and event kept. A month of monitoring stays under ~12 MB.
|
||||
- **Copy report** — a plain-text summary of the window you are looking at,
|
||||
with timestamps, both legs and loss. The thing an ISP actually asks for.
|
||||
|
||||
## How it works
|
||||
|
||||
The QML plugin is a thin reader. All measurement lives in **nexthopd**, a
|
||||
Python 3 daemon (standard library only, no pip), spawned and supervised by
|
||||
the plugin's shell service. Five files are the whole contract — four the
|
||||
daemon writes, one the panel writes for it:
|
||||
|
||||
| file | cadence | consumer |
|
||||
|---|---|---|
|
||||
| `$XDG_RUNTIME_DIR/nexthop/live.json` | 2× per second | the bar widget |
|
||||
| `$XDG_RUNTIME_DIR/nexthop/recent.json` | every 5 s | the panel's 30-min graphs |
|
||||
| `$XDG_RUNTIME_DIR/nexthop/apps.json` | every 3 s | the Apps tab |
|
||||
| `~/.local/state/nexthop/history.db` | 1-min rows | `nexthop query`, longer windows |
|
||||
| `~/.local/state/nexthop/config.json` | when a setting changes | the daemon — **derived, not edited**: the panel rewrites it from your bar entry every time the shell starts, so changes made here are overwritten. Settings live in `~/.config/omarchy/shell.json` |
|
||||
|
||||
The three snapshots are rewritten many times a minute between them and mean
|
||||
nothing after a reboot, so they live in the session's runtime directory,
|
||||
which is a tmpfs: they never touch the disk. History and settings stay
|
||||
under `~/.local/state`. The daemon never talks to the shell, so either side
|
||||
restarts without the other noticing — and your history survives every
|
||||
theme change.
|
||||
|
||||
To keep monitoring while the shell is down, install the optional
|
||||
systemd unit (see the comments in [`nexthopd.service`](nexthopd.service),
|
||||
which also confines the daemon with the systemd sandboxing it can live inside);
|
||||
the daemon holds a lock, so the shell service simply attaches.
|
||||
|
||||
### What it talks to, and what that costs
|
||||
|
||||
The internet leg is measured by a small pool of instruments: ICMP and a
|
||||
TCP handshake to the anchor you configure (`1.1.1.1` by default), plus TCP
|
||||
handshakes to `speed.cloudflare.com` and `dns.google` — one probe target
|
||||
outside Cloudflare, so a Cloudflare incident cannot silence the whole
|
||||
pool. The **two best** instruments — fewest losses, steadiest tails,
|
||||
re-ranked every five minutes with flap damping — feed the score; the rest
|
||||
idle at a tenth of the rate. One anchor having a bad day stops being your
|
||||
connection's bad day. Beyond the pool, the only other contacts are the
|
||||
speed-test hosts named at the end and the once-a-day update check against
|
||||
the repository you installed from.
|
||||
|
||||
| Probe | Where | Cost |
|
||||
| --- | --- | --- |
|
||||
| ICMP | your gateway, and the anchor while seated | ~30 MB/day per target on the wire at the default 500 ms — two 84-byte packets a second; the gateway's share never leaves your LAN |
|
||||
| TCP handshakes | the instrument pool, port 443, ~1/s while seated | ~20 MB/day per seated instrument — connections opened and closed, no payload |
|
||||
| Reachability check | speed.cloudflare.com/cdn-cgi/trace | one ~1 KB HTTPS request when the network changes and hourly after that; every 30 s only while a sign-in page is suspected. Proves the real internet answered, and supplies the WAN address |
|
||||
| Content check | speed.cloudflare.com | sized to the line — up to ~20 MB on a fast one, ~4 MB on a slow one; hourly, and can be turned off |
|
||||
| Peak test | Ookla / Cloudflare / fast.com | up to ~600 MB, **only ever when you ask** |
|
||||
| Update check | the repository you installed from (`git ls-remote`) | one request a day, carrying nothing about you; off in the Setup tab |
|
||||
|
||||
The TCP probes exist because ICMP is not what applications
|
||||
experience: routers commonly answer pings from hardware while real traffic
|
||||
waits in the queues that actually cause delay, and they rate-limit pings
|
||||
under load. Measuring both, against the same host, shows the difference
|
||||
rather than assuming it.
|
||||
|
||||
Peak tests size themselves to saturate the line for ~10 s each way — far
|
||||
less on a slow one — and run only from the panel, by middle-clicking the
|
||||
bar widget, or via IPC.
|
||||
|
||||
**Privileges: none.** No sudo, no capabilities, no packet capture. The
|
||||
daemon runs as your user; everything it reads is world-readable (`/sys`
|
||||
counters, `ping`, `iw`, `ss`).
|
||||
|
||||
## Remove
|
||||
|
||||
```bash
|
||||
omarchy plugin remove io.github.x3me.nexthop
|
||||
```
|
||||
|
||||
Measurement history stays in `~/.local/state/nexthop/`; delete that
|
||||
directory too if you want nothing left behind. If you installed the
|
||||
optional systemd unit: `systemctl --user disable --now nexthopd` and
|
||||
remove `~/.config/systemd/user/nexthopd.service`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s test # fixtures recorded from real hardware
|
||||
python3 -m nexthopd # run the daemon in the foreground
|
||||
```
|
||||
|
||||
Design mockups live in `design/` as the source artboards of the project's
|
||||
design canvas.
|
||||
|
||||
## License
|
||||
|
||||
MIT © [Extreme Labs](https://github.com/x3me)
|
||||
@@ -0,0 +1,71 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
// One of the three component scores: label, number, meter, one-line note.
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property var value: null // 0-100 or null
|
||||
property string note: ""
|
||||
property color textColor: Color.popups.text
|
||||
property color dimColor: Color.muted
|
||||
|
||||
// Set when the number alone would mislead — a 24-hour reliability score
|
||||
// still reading 100 in the first minute of an outage, say. The value is
|
||||
// true, the green is not.
|
||||
property var toneOverride: null
|
||||
|
||||
readonly property color tone: {
|
||||
if (toneOverride) return toneOverride
|
||||
if (value === null || value === undefined) return dimColor
|
||||
if (value >= 80) return "#9ece6a"
|
||||
if (value >= 50) return "#e0af68"
|
||||
return Color.urgent
|
||||
}
|
||||
|
||||
spacing: Style.space(5)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: root.label
|
||||
color: root.dimColor
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: root.value === null || root.value === undefined
|
||||
? "--" : String(Math.round(root.value))
|
||||
color: root.tone
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.fontPx(1.8)
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: Math.max(2, Style.space(3))
|
||||
color: Qt.rgba(root.textColor.r, root.textColor.g, root.textColor.b, 0.12)
|
||||
|
||||
Rectangle {
|
||||
height: parent.height
|
||||
width: parent.width * (root.value === null || root.value === undefined
|
||||
? 0 : Math.max(0, Math.min(1, root.value / 100)))
|
||||
color: root.tone
|
||||
Behavior on width { NumberAnimation { duration: 300; easing.type: Easing.OutCubic } }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: parent.width
|
||||
text: root.note
|
||||
color: root.dimColor
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.caption
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
|
||||
// Nexthop's headless half: keeps nexthopd running.
|
||||
//
|
||||
// The daemon is a separate process on purpose — history has to survive shell
|
||||
// restarts (every theme change is one), and nothing that probes twice a
|
||||
// second belongs inside the process that draws the desktop. This service
|
||||
// spawns it at shell startup and respawns it with backoff if it dies. The
|
||||
// daemon holds a flock, so if a systemd --user unit already runs one, the
|
||||
// spawn here exits immediately and cleanly.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
readonly property string pluginDir: {
|
||||
// A URL, not a path: percent-encoded, so decode before it is used as
|
||||
// a filesystem path or a space in the way becomes %20 and cd fails.
|
||||
var url = decodeURIComponent(Qt.resolvedUrl(".").toString())
|
||||
return url.replace(/^file:\/\//, "").replace(/\/$/, "")
|
||||
}
|
||||
|
||||
property int failures: 0
|
||||
|
||||
// ---- update handover -----------------------------------------------------
|
||||
//
|
||||
// `omarchy plugin update` fast-forwards the checkout and the shell
|
||||
// hot-reloads the QML, but a running daemon holds the flock and keeps
|
||||
// executing the old code until something restarts it. So the daemon
|
||||
// publishes its version in live.json, and this service compares it with
|
||||
// the manifest on disk: mismatch means the code under our feet changed —
|
||||
// retire the old daemon and let supervision (ours or systemd's) respawn
|
||||
// it fresh. A daemon too old to publish a version is treated as stale,
|
||||
// which is exactly right for the first update that ships this check.
|
||||
property string manifestVersion: ""
|
||||
property int lastRetiredPid: 0
|
||||
|
||||
// ---- liveness ------------------------------------------------------------
|
||||
//
|
||||
// A daemon that hangs keeps its flock and leaves its last live.json in
|
||||
// place, and until 0.2.24 nothing noticed: the stream emits only on
|
||||
// change, the bar kept the last number, and this service only ever
|
||||
// compared versions. The snapshot carries its own timestamp, so its age
|
||||
// is knowable. Two stale readings 15 s apart before acting, because a
|
||||
// suspend, a resume or a clock step produces one stale reading and the
|
||||
// next tick clears it, while a hung daemon produces them forever. The
|
||||
// action is the same identity-checked SIGTERM the version handover
|
||||
// uses, once per pid: if the daemon does not exit on it, the bar's own
|
||||
// stale marker keeps telling the truth and nothing loops.
|
||||
property real lastLiveT: 0
|
||||
property int livePid: 0
|
||||
property int liveStart: 0
|
||||
property int staleStrikes: 0
|
||||
readonly property int staleAfterS: 30
|
||||
|
||||
Timer {
|
||||
id: liveness
|
||||
interval: 15000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
if (root.lastLiveT <= 0 || root.livePid <= 0) return
|
||||
var age = Date.now() / 1000 - root.lastLiveT
|
||||
if (age < root.staleAfterS) {
|
||||
root.staleStrikes = 0
|
||||
return
|
||||
}
|
||||
root.staleStrikes += 1
|
||||
if (root.staleStrikes < 2) return
|
||||
root.staleStrikes = 0
|
||||
root.retire(root.livePid, root.liveStart)
|
||||
}
|
||||
}
|
||||
|
||||
// Neither the manifest nor live.json is opened from QML: `nexthop
|
||||
// stream` reads both with a bounded, non-blocking, no-follow,
|
||||
// regular-file-only read and emits them as lines. The version handover
|
||||
// below acts on that data, so a tampered state file cannot stall or
|
||||
// bloat the shell on its way to a SIGTERM decision. Two seconds is
|
||||
// plenty — this only has to notice a fast-forwarded checkout.
|
||||
Process {
|
||||
id: versionStream
|
||||
running: true
|
||||
command: ["sh", "-c",
|
||||
'cd "$1" && exec python3 -m nexthopd.cli stream manifest live --interval 2',
|
||||
"sh", root.pluginDir]
|
||||
stdout: SplitParser {
|
||||
splitMarker: "\n"
|
||||
onRead: function (line) { root.applyStream(line) }
|
||||
}
|
||||
onExited: streamRestart.start()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: streamRestart
|
||||
interval: 2000
|
||||
onTriggered: versionStream.running = true
|
||||
}
|
||||
|
||||
function applyStream(line) {
|
||||
if (!line || line.length > 262144) return
|
||||
if (line.indexOf("manifest ") === 0) {
|
||||
try {
|
||||
root.manifestVersion = String(JSON.parse(line.slice(9)).version || "")
|
||||
} catch (e) {}
|
||||
} else if (line.indexOf("live ") === 0) {
|
||||
root.checkDaemonVersion(line.slice(5))
|
||||
}
|
||||
}
|
||||
|
||||
function checkDaemonVersion(raw) {
|
||||
var live
|
||||
try { live = JSON.parse(raw) } catch (e) { return }
|
||||
var pid = Math.floor(Number(live.pid))
|
||||
if (!isFinite(pid) || pid <= 0) return
|
||||
var startTicks = Math.floor(Number(live.pid_start))
|
||||
if (!isFinite(startTicks) || startTicks <= 0) startTicks = 0
|
||||
// Every snapshot from a real daemon feeds the liveness watch above.
|
||||
var t = Number(live.t)
|
||||
if (isFinite(t) && t > 0) {
|
||||
root.lastLiveT = t
|
||||
root.livePid = pid
|
||||
root.liveStart = startTicks
|
||||
}
|
||||
if (manifestVersion === "") return
|
||||
var daemonVersion = String(live.daemon_version || "")
|
||||
if (daemonVersion === manifestVersion) return
|
||||
root.retire(pid, startTicks)
|
||||
}
|
||||
|
||||
function retire(pid, startTicks) {
|
||||
// Retire each pid once — if the respawn comes back stale too,
|
||||
// something else is wrong and looping SIGTERMs will not fix it.
|
||||
if (pid === lastRetiredPid) return
|
||||
lastRetiredPid = pid
|
||||
// Authorizing the signal is `nexthop retire`'s job, not a shell
|
||||
// one-liner's: it must belong to this user, its argv must be exactly a
|
||||
// python interpreter running `-m nexthopd`, and its start time must
|
||||
// match the one the daemon published — a recycled pid can share a
|
||||
// number, never a start time. Doing it in Python makes those three
|
||||
// checks testable, which the one-liner never was.
|
||||
retireProc.command = ["sh", "-c",
|
||||
'cd "$1" && exec python3 -m nexthopd.cli retire --pid "$2" --start "$3"',
|
||||
"sh", root.pluginDir, String(pid), String(startTicks)]
|
||||
retireProc.running = true
|
||||
respawnTimer.restart()
|
||||
}
|
||||
|
||||
Process { id: retireProc }
|
||||
|
||||
// If the retired daemon was our child, onExited respawns it with backoff.
|
||||
// If it belonged to a systemd unit or an earlier shell, nothing of ours
|
||||
// exits — so also respawn on a timer; whoever loses the flock race exits
|
||||
// cleanly, and either way exactly one fresh daemon survives.
|
||||
Timer {
|
||||
id: respawnTimer
|
||||
interval: 2500
|
||||
repeat: false
|
||||
onTriggered: daemon.running = true
|
||||
}
|
||||
|
||||
Process {
|
||||
id: daemon
|
||||
command: ["sh", "-c", 'cd "$1" && exec python3 -m nexthopd',
|
||||
"sh", root.pluginDir]
|
||||
running: true
|
||||
|
||||
onExited: function(code, status) {
|
||||
// Exit 3 is the daemon's "lock already held" code — another instance
|
||||
// owns the measurement, so there is nothing to supervise. Every
|
||||
// other exit gets a respawn with backoff: a SIGTERM'd daemon exits 0
|
||||
// and treating that as success once left the plugin unmonitored
|
||||
// until the next shell restart.
|
||||
if (code === 3) return
|
||||
root.failures += 1
|
||||
restartTimer.interval = Math.min(60000, 2000 * Math.pow(2, root.failures - 1))
|
||||
restartTimer.restart()
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: restartTimer
|
||||
repeat: false
|
||||
onTriggered: daemon.running = true
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
// The shell is going down (restart, theme change). Leave a systemd-run
|
||||
// daemon alone; only reap the one we spawned.
|
||||
daemon.running = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// The four settings worth reaching without leaving the panel, and an honest
|
||||
// pointer to the rest.
|
||||
//
|
||||
// Why this tab exists: Omarchy has no settings editor. Setup > Plugins
|
||||
// enables, disables, adds, clones and removes — nothing anywhere edits a
|
||||
// widget's own settings, and the shell keeps them inline on the bar entry in
|
||||
// shell.json. So every option this plugin has was, until now, reachable only
|
||||
// by hand-editing that file. Thirteen settings nobody can find is worse than
|
||||
// four they can.
|
||||
//
|
||||
// Only booleans are here. An enum or a number needs a picker and a keyboard,
|
||||
// and a half-built editor in a monitoring panel would be worse than sending
|
||||
// someone to the file that already works.
|
||||
Column {
|
||||
id: tab
|
||||
|
||||
required property var panel
|
||||
|
||||
spacing: Style.space(12)
|
||||
|
||||
// Writing goes through the host: the shell owns shell.json and rewrites the
|
||||
// whole entry, so a setting is changed by handing back the entry with one
|
||||
// field replaced. Absent that API — an older shell — the toggles stay
|
||||
// visible but inert, and the note below says so rather than failing
|
||||
// silently on a tap.
|
||||
readonly property bool canWrite: !!(panel.bar && panel.bar.shell
|
||||
&& typeof panel.bar.shell.updateEntryInline === "function")
|
||||
|
||||
function put(key, value) {
|
||||
if (!canWrite) return
|
||||
var id = "io.github.x3me.nexthop"
|
||||
var s = panel.settings
|
||||
// The shell REPLACES the entry with what it is handed, so writing from
|
||||
// an absent settings object would hand back {id, key} and drop every
|
||||
// other setting the user has. Better to do nothing than to do that.
|
||||
if (!s || typeof s !== "object") return
|
||||
var entry = { "id": id }
|
||||
for (var k in s) if (k !== "id") entry[k] = s[k]
|
||||
entry[key] = value
|
||||
panel.bar.shell.updateEntryInline(id, entry)
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: "WHAT NEXTHOP MAY DO"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
// One row per toggle: name and one line of consequence on the left, the
|
||||
// switch on the right. The consequence line is the point — a toggle whose
|
||||
// effect you have to guess is not a setting, it is a dare.
|
||||
component Row_: Item {
|
||||
id: row
|
||||
required property string label
|
||||
required property string detail
|
||||
required property bool value
|
||||
required property string settingKey
|
||||
|
||||
width: parent.width
|
||||
height: Math.max(texts.implicitHeight, sw.implicitHeight)
|
||||
|
||||
Column {
|
||||
id: texts
|
||||
width: parent.width - sw.width - Style.space(14)
|
||||
spacing: Style.space(2)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: row.label
|
||||
color: tab.panel.fg
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: parent.width
|
||||
wrapMode: Text.WordWrap
|
||||
text: row.detail
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
ToggleSwitch {
|
||||
id: sw
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: row.value
|
||||
interactive: tab.canWrite
|
||||
foreground: tab.panel.fg
|
||||
onToggled: tab.put(row.settingKey, !row.value)
|
||||
}
|
||||
}
|
||||
|
||||
Row_ {
|
||||
label: "Go easy on phone hotspots"
|
||||
detail: "When the connection comes from a phone, pause the hourly speed "
|
||||
+ "checks and ask twice before a full test. About 14 MB an hour of "
|
||||
+ "someone's data plan."
|
||||
value: tab.panel.setting("meteredCare", true)
|
||||
settingKey: "meteredCare"
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
Row_ {
|
||||
label: "Measure speed automatically"
|
||||
detail: "A small download every hour so the Speed score means something. "
|
||||
+ "Off, Speed goes blank rather than guessing."
|
||||
value: tab.panel.setting("contentSpeed", true)
|
||||
settingKey: "contentSpeed"
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
Row_ {
|
||||
label: "Notify on outages"
|
||||
detail: "A desktop notification when the connection drops and when it "
|
||||
+ "returns. Brief interruptions are logged either way and never "
|
||||
+ "notified."
|
||||
value: tab.panel.setting("notifyOutage", true)
|
||||
settingKey: "notifyOutage"
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
Row_ {
|
||||
label: "Tell me about updates"
|
||||
detail: "Once a day, asks the repository you installed from whether a "
|
||||
+ "newer version exists. Sends nothing about you, and installs nothing."
|
||||
value: tab.panel.setting("updateCheck", true)
|
||||
settingKey: "updateCheck"
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: "EVERYTHING ELSE"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
// The honest part. Nine more settings exist and this panel is not the
|
||||
// place to build a form for them, so say plainly where they live and what
|
||||
// they are, rather than pretending these four are all there is.
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: parent.width
|
||||
wrapMode: Text.WordWrap
|
||||
text: tab.canWrite
|
||||
? "Nine more settings live on this widget's entry in "
|
||||
+ "~/.config/omarchy/shell.json — what the bar shows, the internet "
|
||||
+ "anchor, probe interval, check frequency, speed-test engine, your "
|
||||
+ "plan speeds, throughput smoothing, and how long history is kept. "
|
||||
+ "Add them beside \"id\" and they apply without a restart."
|
||||
: "This shell cannot write widget settings back, so the switches above "
|
||||
+ "are read-only. Edit this widget's entry in "
|
||||
+ "~/.config/omarchy/shell.json instead; changes apply without a "
|
||||
+ "restart."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: parent.width
|
||||
wrapMode: Text.WordWrap
|
||||
text: "Defaults are sensible and nothing here needs changing to use it."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "readout.js" as Readout
|
||||
import "format.js" as Fmt
|
||||
|
||||
// Speed: live throughput, content-check history as paired bars, and the
|
||||
// last peak result in full.
|
||||
Column {
|
||||
id: tab
|
||||
|
||||
required property var panel
|
||||
|
||||
spacing: Style.space(12)
|
||||
|
||||
Component.onCompleted: panel.requestTests()
|
||||
|
||||
readonly property var tests: panel.testsData && panel.testsData.tests
|
||||
? panel.testsData.tests : []
|
||||
readonly property var contentTests: {
|
||||
var out = tests.filter(function(t) { return t.kind === "content" && t.ok })
|
||||
out.reverse() // oldest first for the bars
|
||||
return out.slice(-12)
|
||||
}
|
||||
readonly property var lastPeak: {
|
||||
for (var i = 0; i < tests.length; i++)
|
||||
if (tests[i].kind === "peak" && tests[i].ok) return tests[i]
|
||||
return null
|
||||
}
|
||||
readonly property real bestRate: {
|
||||
var best = 0
|
||||
for (var i = 0; i < contentTests.length; i++) {
|
||||
best = Math.max(best, contentTests[i].down_mbps || 0)
|
||||
best = Math.max(best, contentTests[i].up_mbps || 0)
|
||||
}
|
||||
return Math.max(1, best)
|
||||
}
|
||||
|
||||
|
||||
// ---- live throughput ----------------------------------------------------
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: "LIVE THROUGHPUT · LAST 3 MIN"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
// Download above the axis, upload mirrored below — the mockup's cluster
|
||||
// shape. Both directions share one scale so the asymmetry is honest.
|
||||
Canvas {
|
||||
id: flowChart
|
||||
width: parent.width
|
||||
height: Style.space(84)
|
||||
|
||||
property real hoverX: -1
|
||||
onHoverXChanged: requestPaint()
|
||||
|
||||
HoverHandler {
|
||||
onPointChanged: flowChart.hoverX = hovered ? point.position.x : -1
|
||||
onHoveredChanged: if (!hovered) flowChart.hoverX = -1
|
||||
}
|
||||
|
||||
readonly property var pts: {
|
||||
var cut = Date.now() / 1000 - 180
|
||||
return tab.panel.recentPoints.filter(function(p) { return p.t >= cut })
|
||||
}
|
||||
onPtsChanged: requestPaint()
|
||||
onWidthChanged: requestPaint()
|
||||
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
var mid = Math.round(height / 2)
|
||||
ctx.strokeStyle = Qt.rgba(tab.panel.fg.r, tab.panel.fg.g,
|
||||
tab.panel.fg.b, 0.22)
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, mid + 0.5)
|
||||
ctx.lineTo(width, mid + 0.5)
|
||||
ctx.stroke()
|
||||
|
||||
var p = pts
|
||||
if (!p || p.length < 2) return
|
||||
var peak = 10 * 1024
|
||||
for (var i = 0; i < p.length; i++) {
|
||||
if (p[i].rx !== null) peak = Math.max(peak, p[i].rx)
|
||||
if (p[i].tx !== null) peak = Math.max(peak, p[i].tx)
|
||||
}
|
||||
peak *= 1.1
|
||||
var t0 = p[0].t, span = Math.max(1, p[p.length - 1].t - t0)
|
||||
var half = mid - 2
|
||||
|
||||
function draw(key, up, tint) {
|
||||
var runs = [], cur = []
|
||||
for (var i = 0; i < p.length; i++) {
|
||||
var v = p[i][key]
|
||||
if (v === null || v === undefined) {
|
||||
if (cur.length > 1) runs.push(cur)
|
||||
cur = []
|
||||
} else {
|
||||
cur.push([(p[i].t - t0) * (width - 1) / span,
|
||||
mid + (up ? -1 : 1) * half * Math.min(1, v / peak)])
|
||||
}
|
||||
}
|
||||
if (cur.length > 1) runs.push(cur)
|
||||
for (var r = 0; r < runs.length; r++) {
|
||||
var run = runs[r]
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(run[0][0], mid)
|
||||
for (var j = 0; j < run.length; j++) ctx.lineTo(run[j][0], run[j][1])
|
||||
ctx.lineTo(run[run.length - 1][0], mid)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = Qt.rgba(tint.r, tint.g, tint.b, 0.2)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
for (j = 0; j < run.length; j++) {
|
||||
if (j === 0) ctx.moveTo(run[j][0], run[j][1])
|
||||
else ctx.lineTo(run[j][0], run[j][1])
|
||||
}
|
||||
ctx.strokeStyle = tint
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
draw("rx", true, Color.accent)
|
||||
draw("tx", false, tab.panel.warnTone)
|
||||
|
||||
// Top-of-scale label so the silhouette has magnitude.
|
||||
ctx.font = "10px " + tab.panel.fontFamily
|
||||
ctx.textBaseline = "top"
|
||||
ctx.fillStyle = tab.panel.dim
|
||||
ctx.fillText("\u2264 " + Fmt.rate(peak), 4, 3)
|
||||
|
||||
if (hoverX >= 0) {
|
||||
var tAt = t0 + hoverX * span / (width - 1)
|
||||
var best = null, bestD = Infinity
|
||||
for (var h = 0; h < p.length; h++) {
|
||||
var d = Math.abs(p[h].t - tAt)
|
||||
if (d < bestD) { bestD = d; best = p[h] }
|
||||
}
|
||||
if (best) {
|
||||
var cx = (best.t - t0) * (width - 1) / span
|
||||
ctx.strokeStyle = Qt.rgba(tab.panel.fg.r, tab.panel.fg.g,
|
||||
tab.panel.fg.b, 0.4)
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx + 0.5, 0)
|
||||
ctx.lineTo(cx + 0.5, height)
|
||||
ctx.stroke()
|
||||
var label = Qt.formatTime(new Date(best.t * 1000), "HH:mm:ss")
|
||||
+ " · \u2193 " + Fmt.rate(best.rx) + " · \u2191 " + Fmt.rate(best.tx)
|
||||
Readout.draw(ctx, tab.panel.fontFamily, label, cx, width,
|
||||
tab.panel.fg, Color.popups.background)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Style.space(14)
|
||||
readonly property real cell: (width - Style.space(14) * 3) / 4
|
||||
|
||||
component BigStat: Column {
|
||||
property string label: ""
|
||||
property string value: ""
|
||||
property color tint: tab.panel.fg
|
||||
spacing: Style.space(3)
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: parent.label
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: parent.value
|
||||
color: parent.tint
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.title
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
BigStat {
|
||||
width: parent.cell
|
||||
label: "RECEIVING"
|
||||
tint: Color.accent
|
||||
value: Fmt.rate(tab.panel.live && tab.panel.live.rates
|
||||
? tab.panel.live.rates.rx_bps : null)
|
||||
}
|
||||
BigStat {
|
||||
width: parent.cell
|
||||
label: "SENDING"
|
||||
tint: tab.panel.warnTone
|
||||
value: Fmt.rate(tab.panel.live && tab.panel.live.rates
|
||||
? tab.panel.live.rates.tx_bps : null)
|
||||
}
|
||||
BigStat {
|
||||
width: parent.cell
|
||||
label: "DOWNLOADED"
|
||||
value: Fmt.bytes(tab.panel.live && tab.panel.live.rates
|
||||
? tab.panel.live.rates.rx_total : null)
|
||||
}
|
||||
BigStat {
|
||||
width: parent.cell
|
||||
label: "UPLOADED"
|
||||
value: Fmt.bytes(tab.panel.live && tab.panel.live.rates
|
||||
? tab.panel.live.rates.tx_total : null)
|
||||
}
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
// ---- content-check history ---------------------------------------------
|
||||
Item {
|
||||
width: parent.width
|
||||
height: historyLabel.implicitHeight
|
||||
|
||||
Text {
|
||||
id: historyLabel
|
||||
textFormat: Text.PlainText
|
||||
text: "CONTENT SPEED · FEEDS YOUR SCORE"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
text: tab.contentTests.length + " checks kept"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: Style.space(80)
|
||||
visible: tab.contentTests.length > 0
|
||||
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Qt.rgba(tab.panel.fg.r, tab.panel.fg.g, tab.panel.fg.b, 0.15)
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
|
||||
readonly property real slot: width / Math.max(1, tab.contentTests.length)
|
||||
|
||||
Repeater {
|
||||
model: tab.contentTests
|
||||
|
||||
Item {
|
||||
id: barSlot
|
||||
required property var modelData
|
||||
width: parent.slot
|
||||
height: parent.height
|
||||
|
||||
// The pair sits centered in its slot with capped widths, so a
|
||||
// panel with two checks still reads as two paired results, not
|
||||
// four unrelated bars.
|
||||
Row {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Style.space(3)
|
||||
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: Math.min(Style.space(22), barSlot.width * 0.4)
|
||||
height: Math.max(2, barSlot.height *
|
||||
(barSlot.modelData.down_mbps || 0) / tab.bestRate)
|
||||
color: Color.accent
|
||||
}
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: Math.min(Style.space(12), barSlot.width * 0.25)
|
||||
height: Math.max(2, barSlot.height *
|
||||
(barSlot.modelData.up_mbps || 0) / tab.bestRate)
|
||||
color: tab.panel.warnTone
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: tab.contentTests.length === 0
|
||||
text: "No content checks yet — the first runs shortly after the daemon starts."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
// ---- last peak ----------------------------------------------------------
|
||||
Item {
|
||||
width: parent.width
|
||||
height: peakLabel.implicitHeight
|
||||
|
||||
Text {
|
||||
id: peakLabel
|
||||
textFormat: Text.PlainText
|
||||
text: tab.lastPeak
|
||||
? "PEAK · " + new Date(tab.lastPeak.ts * 1000).toLocaleString(Qt.locale(), "d MMM HH:mm").toUpperCase()
|
||||
: "PEAK SPEED"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
text: "informational, not scored"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
Grid {
|
||||
width: parent.width
|
||||
columns: 2
|
||||
columnSpacing: Style.space(24)
|
||||
rowSpacing: Style.space(6)
|
||||
visible: tab.lastPeak !== null
|
||||
|
||||
readonly property real cell: (width - Style.space(24)) / 2
|
||||
readonly property var p: tab.lastPeak
|
||||
|
||||
component KvRow: Item {
|
||||
property string k: ""
|
||||
property string v: ""
|
||||
property color vColor: tab.panel.fg
|
||||
height: kText.implicitHeight
|
||||
Text {
|
||||
id: kText
|
||||
textFormat: Text.PlainText
|
||||
text: parent.k
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
width: parent.width - kText.implicitWidth - Style.space(10)
|
||||
horizontalAlignment: Text.AlignRight
|
||||
elide: Text.ElideLeft
|
||||
text: parent.v
|
||||
color: parent.vColor
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
}
|
||||
|
||||
KvRow { width: parent.cell; k: "Download"
|
||||
v: parent.p ? (parent.p.down_mbps || 0).toFixed(1) + " Mbps" : "" }
|
||||
KvRow { width: parent.cell; k: "Upload"
|
||||
v: parent.p && parent.p.up_mbps ? parent.p.up_mbps.toFixed(1) + " Mbps" : "--" }
|
||||
KvRow { width: parent.cell; k: "Idle ping"
|
||||
v: parent.p && parent.p.ping_idle ? Math.round(parent.p.ping_idle) + " ms" : "--" }
|
||||
KvRow {
|
||||
width: parent.cell; k: "Loaded ping"
|
||||
v: parent.p && parent.p.ping_loaded ? Math.round(parent.p.ping_loaded) + " ms" : "--"
|
||||
vColor: parent.p && parent.p.ping_loaded && parent.p.ping_idle
|
||||
&& parent.p.ping_loaded > parent.p.ping_idle * 3
|
||||
? tab.panel.warnTone : tab.panel.fg
|
||||
}
|
||||
KvRow { width: parent.cell; k: "Data used"
|
||||
v: parent.p && parent.p.bytes ? (parent.p.bytes / 1e6).toFixed(0) + " MB" : "--" }
|
||||
KvRow { width: parent.cell; k: "Engine"
|
||||
v: parent.p ? parent.p.engine : "" }
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: serverKey.implicitHeight
|
||||
visible: tab.lastPeak !== null && !!tab.lastPeak.server
|
||||
|
||||
Text {
|
||||
id: serverKey
|
||||
textFormat: Text.PlainText
|
||||
text: "Server"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
width: parent.width - serverKey.implicitWidth - Style.space(10)
|
||||
horizontalAlignment: Text.AlignRight
|
||||
elide: Text.ElideLeft
|
||||
text: tab.lastPeak && tab.lastPeak.server ? tab.lastPeak.server : ""
|
||||
color: tab.panel.fg
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: tab.lastPeak === null
|
||||
text: "No peak test yet."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: Style.space(28)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - runButton.width - Style.space(12)
|
||||
text: "A peak test saturates the line for ~10 s each way — up to ~600 MB on a fast line. It only runs when you ask."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: runButton
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: runText.implicitWidth + Style.space(24)
|
||||
height: Style.space(28)
|
||||
color: runHover.hovered
|
||||
? Style.hoverFillFor(tab.panel.fg, Color.accent)
|
||||
: Style.normalFillFor(tab.panel.fg, Color.accent)
|
||||
border.width: Style.normalBorderWidth
|
||||
border.color: Style.normalBorderFor(tab.panel.fg, Color.accent)
|
||||
|
||||
Text {
|
||||
id: runText
|
||||
textFormat: Text.PlainText
|
||||
anchors.centerIn: parent
|
||||
text: tab.panel.live && tab.panel.live.peak_running ? " Testing…"
|
||||
: tab.panel.peakArmed ? " Uses data · press again"
|
||||
: " Run test"
|
||||
color: tab.panel.fg
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
HoverHandler { id: runHover }
|
||||
TapHandler {
|
||||
enabled: !(tab.panel.live && tab.panel.live.peak_running)
|
||||
onTapped: { tab.panel.runPeakTest(); refreshTimer.start() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// While a peak test runs, poll the tests list so the result appears.
|
||||
Timer {
|
||||
id: refreshTimer
|
||||
interval: 3000
|
||||
repeat: true
|
||||
running: tab.panel.live && tab.panel.live.peak_running === true
|
||||
onTriggered: tab.panel.requestTests()
|
||||
onRunningChanged: if (!running) tab.panel.requestTests()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "readout.js" as Readout
|
||||
|
||||
// The local leg in detail: signal on a labelled scale, link facts, and the
|
||||
// airtime counters that explain "signal looks fine but Wi-Fi feels slow".
|
||||
Column {
|
||||
id: tab
|
||||
|
||||
required property var panel
|
||||
|
||||
Component.onCompleted: panel.requestEvents("7d")
|
||||
|
||||
readonly property var linkEvents: {
|
||||
var all = panel.eventsData && panel.eventsData.events
|
||||
? panel.eventsData.events : []
|
||||
var kinds = {"roam": 1, "kick": 1, "drop": 1, "associate": 1,
|
||||
"rate-drop": 1, "channel-change": 1}
|
||||
var cut = Date.now() / 1000 - 86400
|
||||
var out = []
|
||||
for (var i = 0; i < all.length && out.length < 6; i++)
|
||||
if (kinds[all[i].kind] && all[i].ts >= cut) out.push(all[i])
|
||||
return out
|
||||
}
|
||||
|
||||
// Every change of access point inside the chart window, with who ended
|
||||
// the previous association: a roam was this machine's choice, a kick the
|
||||
// access point's, a drop a link that fell over.
|
||||
readonly property var roamMarks: {
|
||||
var all = panel.eventsData && panel.eventsData.events
|
||||
? panel.eventsData.events : []
|
||||
var kinds = {"roam": 1, "kick": 1, "drop": 1}
|
||||
var cut = Date.now() / 1000 - 1800
|
||||
var out = []
|
||||
for (var i = 0; i < all.length; i++)
|
||||
if (kinds[all[i].kind] && all[i].ts >= cut)
|
||||
out.push({ts: all[i].ts, kind: all[i].kind})
|
||||
return out
|
||||
}
|
||||
|
||||
readonly property var link: panel.live ? panel.live.link : null
|
||||
readonly property var station: link ? link.station : null
|
||||
readonly property bool isWifi: link && link.kind === "wifi"
|
||||
|
||||
spacing: Style.space(12)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: !tab.isWifi
|
||||
text: tab.link && tab.link.kind === "ethernet"
|
||||
? "Wired connection — no radio to report on. The local leg lives on the Latency tab."
|
||||
: "Not connected."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
wrapMode: Text.WordWrap
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
// ---- signal -------------------------------------------------------------
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: tab.isWifi
|
||||
spacing: Style.space(12)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: "THE LOCAL LEG · THIS MACHINE TO THE ROUTER"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Style.space(20)
|
||||
|
||||
readonly property var dbm: tab.link ? tab.link.signal_dbm : null
|
||||
readonly property color tone: {
|
||||
if (dbm === null || dbm === undefined) return tab.panel.dim
|
||||
if (dbm >= -60) return tab.panel.okTone
|
||||
if (dbm >= -70) return tab.panel.warnTone
|
||||
return Color.urgent
|
||||
}
|
||||
|
||||
Column {
|
||||
spacing: Style.space(2)
|
||||
anchors.bottom: parent.bottom
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: parent.parent.dbm !== null && parent.parent.dbm !== undefined
|
||||
? parent.parent.dbm + " dBm" : "--"
|
||||
color: parent.parent.tone
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.fontPx(2.0)
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: "SIGNAL"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width - Style.space(130)
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: Style.space(4)
|
||||
spacing: Style.space(5)
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: Style.space(8)
|
||||
color: Qt.rgba(tab.panel.fg.r, tab.panel.fg.g, tab.panel.fg.b, 0.10)
|
||||
|
||||
Rectangle {
|
||||
readonly property var dbm: tab.link ? tab.link.signal_dbm : null
|
||||
height: parent.height
|
||||
// -90 dBm is unusable, -30 is rail: map onto 0..1.
|
||||
width: parent.width * (dbm === null || dbm === undefined
|
||||
? 0 : Math.max(0, Math.min(1, (dbm + 90) / 60)))
|
||||
color: parent.parent.parent.tone
|
||||
Behavior on width { NumberAnimation { duration: 300 } }
|
||||
}
|
||||
// The marginal mark at -67 dBm, where video calls start to suffer.
|
||||
Rectangle {
|
||||
x: parent.width * ((-67 + 90) / 60)
|
||||
y: -Style.space(3)
|
||||
width: 1
|
||||
height: parent.height + Style.space(6)
|
||||
color: Qt.rgba(tab.panel.fg.r, tab.panel.fg.g, tab.panel.fg.b, 0.35)
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: scaleLeft.implicitHeight
|
||||
|
||||
Text {
|
||||
id: scaleLeft
|
||||
textFormat: Text.PlainText
|
||||
text: "−90 unusable"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
x: parent.width * ((-67 + 90) / 60) - implicitWidth / 2
|
||||
text: "−67 marginal"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
text: "−30 max"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- signal & local lag history ---------------------------------------
|
||||
Item {
|
||||
width: parent.width
|
||||
height: sigLabel.implicitHeight
|
||||
|
||||
Text {
|
||||
id: sigLabel
|
||||
textFormat: Text.PlainText
|
||||
text: "SIGNAL & LOCAL LAG · LAST 30 MIN"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
text: {
|
||||
var marks = tab.roamMarks, n = {roam: 0, kick: 0, drop: 0}
|
||||
for (var i = 0; i < marks.length; i++) n[marks[i].kind] += 1
|
||||
var parts = []
|
||||
if (n.roam) parts.push(n.roam === 1 ? "one roam" : n.roam + " roams")
|
||||
if (n.kick) parts.push(n.kick === 1 ? "kicked once" : "kicked " + n.kick + "\u00d7")
|
||||
if (n.drop) parts.push(n.drop === 1 ? "one drop" : n.drop + " drops")
|
||||
return parts.join(" \u00b7 ")
|
||||
}
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
Canvas {
|
||||
id: sigChart
|
||||
width: parent.width
|
||||
height: Style.space(78)
|
||||
|
||||
property real hoverX: -1
|
||||
onHoverXChanged: requestPaint()
|
||||
|
||||
HoverHandler {
|
||||
onPointChanged: sigChart.hoverX = hovered ? point.position.x : -1
|
||||
onHoveredChanged: if (!hovered) sigChart.hoverX = -1
|
||||
}
|
||||
|
||||
readonly property var pts: tab.panel.recentPoints
|
||||
readonly property var roams: tab.roamMarks
|
||||
onPtsChanged: requestPaint()
|
||||
onRoamsChanged: requestPaint()
|
||||
onWidthChanged: requestPaint()
|
||||
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
var bottom = height - 1
|
||||
ctx.strokeStyle = Qt.rgba(tab.panel.fg.r, tab.panel.fg.g,
|
||||
tab.panel.fg.b, 0.16)
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, bottom + 0.5)
|
||||
ctx.lineTo(width, bottom + 0.5)
|
||||
ctx.stroke()
|
||||
|
||||
var p = pts
|
||||
if (!p || p.length < 2) return
|
||||
var t0 = p[0].t, span = Math.max(1, p[p.length - 1].t - t0)
|
||||
function xAt(t) { return (t - t0) * (width - 1) / span }
|
||||
|
||||
// Access-point changes first, dashed, under the series: purple
|
||||
// when this machine chose to move, amber when it was kicked or
|
||||
// the link dropped.
|
||||
ctx.setLineDash([2, 3])
|
||||
for (var m = 0; m < roams.length; m++) {
|
||||
var rx = xAt(roams[m].ts)
|
||||
if (rx < 0 || rx > width) continue
|
||||
ctx.strokeStyle = roams[m].kind === "roam"
|
||||
? Qt.rgba(0.73, 0.6, 0.97, 0.55) : Qt.rgba(0.88, 0.69, 0.41, 0.7)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(rx, 0)
|
||||
ctx.lineTo(rx, height)
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.setLineDash([])
|
||||
|
||||
function runs(key, yAt) {
|
||||
var out = [], cur = []
|
||||
for (var i = 0; i < p.length; i++) {
|
||||
var v = p[i][key]
|
||||
if (v === null || v === undefined) {
|
||||
if (cur.length > 1) out.push(cur)
|
||||
cur = []
|
||||
} else {
|
||||
cur.push([xAt(p[i].t), yAt(v)])
|
||||
}
|
||||
}
|
||||
if (cur.length > 1) out.push(cur)
|
||||
return out
|
||||
}
|
||||
|
||||
// Signal on the fixed -90..-30 dBm scale, as a filled band.
|
||||
var ok = tab.panel.okTone
|
||||
var sigRuns = runs("sig", function(v) {
|
||||
var f = Math.max(0, Math.min(1, (v + 90) / 60))
|
||||
return bottom - (bottom - 3) * f
|
||||
})
|
||||
for (var r = 0; r < sigRuns.length; r++) {
|
||||
var run = sigRuns[r]
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(run[0][0], bottom)
|
||||
for (var j = 0; j < run.length; j++) ctx.lineTo(run[j][0], run[j][1])
|
||||
ctx.lineTo(run[run.length - 1][0], bottom)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = Qt.rgba(ok.r, ok.g, ok.b, 0.14)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
for (j = 0; j < run.length; j++) {
|
||||
if (j === 0) ctx.moveTo(run[j][0], run[j][1])
|
||||
else ctx.lineTo(run[j][0], run[j][1])
|
||||
}
|
||||
ctx.strokeStyle = ok
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Local lag on its own scale, a thin dim line.
|
||||
var lagPeak = 8
|
||||
for (var i = 0; i < p.length; i++) {
|
||||
if (p[i].local !== null && p[i].local !== undefined)
|
||||
lagPeak = Math.max(lagPeak, p[i].local)
|
||||
}
|
||||
lagPeak *= 1.15
|
||||
var lagRuns = runs("local", function(v) {
|
||||
return bottom - (bottom - 3) * Math.min(1, v / lagPeak)
|
||||
})
|
||||
ctx.strokeStyle = tab.panel.dim
|
||||
ctx.lineWidth = 1
|
||||
for (r = 0; r < lagRuns.length; r++) {
|
||||
run = lagRuns[r]
|
||||
ctx.beginPath()
|
||||
for (j = 0; j < run.length; j++) {
|
||||
if (j === 0) ctx.moveTo(run[j][0], run[j][1])
|
||||
else ctx.lineTo(run[j][0], run[j][1])
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
if (hoverX >= 0) {
|
||||
var tAt = t0 + hoverX * span / (width - 1)
|
||||
var best = null, bestD = Infinity
|
||||
for (i = 0; i < p.length; i++) {
|
||||
var d = Math.abs(p[i].t - tAt)
|
||||
if (d < bestD) { bestD = d; best = p[i] }
|
||||
}
|
||||
if (best) {
|
||||
var cx = xAt(best.t)
|
||||
ctx.strokeStyle = Qt.rgba(tab.panel.fg.r, tab.panel.fg.g,
|
||||
tab.panel.fg.b, 0.4)
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx + 0.5, 0)
|
||||
ctx.lineTo(cx + 0.5, height)
|
||||
ctx.stroke()
|
||||
var parts = [Qt.formatTime(new Date(best.t * 1000), "HH:mm:ss")]
|
||||
parts.push(best.sig !== null && best.sig !== undefined
|
||||
? best.sig + " dBm" : "no signal data")
|
||||
if (best.local !== null && best.local !== undefined)
|
||||
parts.push("lag " + best.local.toFixed(1) + " ms")
|
||||
var label = parts.join(" · ")
|
||||
Readout.draw(ctx, tab.panel.fontFamily, label, cx, width,
|
||||
tab.panel.fg, Color.popups.background)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: Style.space(16)
|
||||
|
||||
component ChartKey: Row {
|
||||
property color tint: "white"
|
||||
property string label: ""
|
||||
property bool dashed: false
|
||||
spacing: Style.space(6)
|
||||
Rectangle {
|
||||
width: parent.dashed ? 1 : Style.space(10)
|
||||
height: parent.dashed ? Style.space(9) : 2
|
||||
color: parent.tint
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: parent.label
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
ChartKey { tint: tab.panel.okTone; label: "signal" }
|
||||
ChartKey { tint: tab.panel.dim; label: "local lag" }
|
||||
ChartKey { tint: "#bb9af7"; label: "roam"; dashed: true }
|
||||
ChartKey { tint: tab.panel.warnTone; label: "kicked / dropped"; dashed: true }
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
// ---- link facts -------------------------------------------------------
|
||||
Grid {
|
||||
width: parent.width
|
||||
columns: 2
|
||||
columnSpacing: Style.space(24)
|
||||
rowSpacing: Style.space(6)
|
||||
|
||||
readonly property real cell: (width - Style.space(24)) / 2
|
||||
|
||||
component KvRow: Item {
|
||||
property string k: ""
|
||||
property string v: ""
|
||||
height: kText.implicitHeight
|
||||
Text {
|
||||
id: kText
|
||||
textFormat: Text.PlainText
|
||||
text: parent.k
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.right: parent.right
|
||||
text: parent.v
|
||||
color: tab.panel.fg
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
elide: Text.ElideLeft
|
||||
width: parent.width - kText.implicitWidth - Style.space(8)
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
}
|
||||
|
||||
KvRow { width: parent.cell; k: "Band"
|
||||
v: tab.link && tab.link.band ? tab.link.band : "--" }
|
||||
KvRow { width: parent.cell; k: "Channel"
|
||||
v: tab.link && tab.link.channel
|
||||
? tab.link.channel + (tab.link.width_mhz ? " · " + tab.link.width_mhz + " MHz" : "")
|
||||
: "--" }
|
||||
KvRow { width: parent.cell; k: "Tx rate"
|
||||
v: tab.link && tab.link.tx_mbps ? tab.link.tx_mbps + " Mbps" : "--" }
|
||||
KvRow { width: parent.cell; k: "Rx rate"
|
||||
v: tab.link && tab.link.rx_mbps ? tab.link.rx_mbps + " Mbps" : "--" }
|
||||
KvRow { width: parent.cell; k: "Standard"
|
||||
v: tab.link && tab.link.standard ? tab.link.standard : "--" }
|
||||
KvRow { width: parent.cell; k: "Interface"
|
||||
v: tab.link && tab.link.iface ? tab.link.iface : "--" }
|
||||
KvRow { width: parent.cell; k: "BSSID"
|
||||
v: tab.link && tab.link.bssid ? tab.link.bssid : "--" }
|
||||
KvRow { width: parent.cell; k: "Gateway"
|
||||
v: tab.link && tab.link.gateway ? tab.link.gateway : "--" }
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
// ---- airtime health ---------------------------------------------------
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: "AIRTIME · WHY WI-FI FEELS SLOW WHEN SIGNAL LOOKS FINE"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Style.space(14)
|
||||
readonly property real cell: (width - Style.space(14) * 2) / 3
|
||||
|
||||
component AirStat: Column {
|
||||
property string label: ""
|
||||
property string display: "--"
|
||||
property real frac: 0 // 0..1 meter fill
|
||||
property bool bad: false
|
||||
spacing: Style.space(4)
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: parent.display
|
||||
color: parent.bad ? tab.panel.warnTone : tab.panel.okTone
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.heading
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: parent.label
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: Math.max(2, Style.space(3))
|
||||
color: Qt.rgba(tab.panel.fg.r, tab.panel.fg.g, tab.panel.fg.b, 0.12)
|
||||
Rectangle {
|
||||
height: parent.height
|
||||
width: parent.width * Math.max(0, Math.min(1, parent.parent.frac))
|
||||
color: parent.parent.bad ? tab.panel.warnTone : tab.panel.okTone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AirStat {
|
||||
width: parent.cell
|
||||
label: "TX RETRIES"
|
||||
display: tab.station && tab.station.retry_pct !== undefined
|
||||
? tab.station.retry_pct.toFixed(1) + " %"
|
||||
: (tab.station && tab.station.tx_retries !== undefined
|
||||
? String(tab.station.tx_retries) : "--")
|
||||
// 10% retries fills the meter; past ~5% the air is genuinely busy.
|
||||
frac: tab.station && tab.station.retry_pct !== undefined
|
||||
? tab.station.retry_pct / 10 : 0
|
||||
bad: tab.station && tab.station.retry_pct > 5
|
||||
}
|
||||
AirStat {
|
||||
width: parent.cell
|
||||
label: "TX FAILED"
|
||||
display: tab.station && tab.station.tx_failed !== undefined
|
||||
? String(tab.station.tx_failed) : "--"
|
||||
frac: tab.station && tab.station.tx_failed !== undefined
|
||||
? tab.station.tx_failed / 50 : 0
|
||||
bad: tab.station && tab.station.tx_failed > 10
|
||||
}
|
||||
AirStat {
|
||||
width: parent.cell
|
||||
label: "BEACON LOSS"
|
||||
display: tab.station && tab.station.beacon_loss !== undefined
|
||||
? String(tab.station.beacon_loss) : "--"
|
||||
frac: tab.station && tab.station.beacon_loss !== undefined
|
||||
? tab.station.beacon_loss / 10 : 0
|
||||
bad: tab.station && tab.station.beacon_loss > 0
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: parent.width
|
||||
text: "Counters since association. Retries mean a noisy channel; failures mean "
|
||||
+ "frames given up on; beacon loss means the router's heartbeat went missing."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
// ---- link events ------------------------------------------------------
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: "LINK EVENTS · LAST 24 H"
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: tab.linkEvents.length === 0
|
||||
text: "None. The link has been steady."
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Style.space(8)
|
||||
|
||||
Repeater {
|
||||
model: tab.linkEvents
|
||||
|
||||
Row {
|
||||
id: linkRow
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
spacing: Style.space(10)
|
||||
|
||||
readonly property color tone: {
|
||||
var k = linkRow.modelData.kind
|
||||
if (k === "roam") return "#bb9af7"
|
||||
if (k === "kick" || k === "drop") return tab.panel.warnTone
|
||||
if (k === "rate-drop") return tab.panel.warnTone
|
||||
if (k === "associate") return tab.panel.okTone
|
||||
return Color.accent
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: Style.space(42)
|
||||
text: {
|
||||
var d = new Date(linkRow.modelData.ts * 1000)
|
||||
return d.toLocaleString(Qt.locale(), "HH:mm")
|
||||
}
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
Rectangle {
|
||||
width: Style.space(5)
|
||||
height: Style.space(5)
|
||||
color: linkRow.tone
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: parent.width - Style.space(42) - Style.space(5)
|
||||
- Style.space(58) - Style.space(10) * 3
|
||||
text: linkRow.modelData.detail
|
||||
color: tab.panel.fg
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: Style.space(58)
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: {
|
||||
var e = linkRow.modelData
|
||||
if (!e.ended_ts || e.ended_ts === e.ts) return ""
|
||||
var s = e.ended_ts - e.ts
|
||||
return s < 60 ? "for " + s + " s"
|
||||
: "for " + Math.round(s / 60) + " m"
|
||||
}
|
||||
color: tab.panel.dim
|
||||
font.family: tab.panel.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# The Nexthop query CLI. History, events, tests, reports — all in JSON,
|
||||
# except `report`, which is the plain-text ISP-ticket summary.
|
||||
exec env PYTHONPATH="$(dirname "$(dirname "$(realpath "$0")")")" \
|
||||
python3 -m nexthopd.cli "$@"
|
||||
@@ -0,0 +1,171 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap">
|
||||
<style>
|
||||
body { margin: 0; background: #0e0e14; font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
a { color: #7aa2f7; } a:hover { color: #b4bee6; }
|
||||
.band { border: 1px solid rgba(169,177,214,0.22); background: rgba(169,177,214,0.025); padding: 16px; box-sizing: border-box; }
|
||||
.bandcap { font-size: 10px; letter-spacing: 0.14em; color: #565f89; margin-bottom: 13px; }
|
||||
.box { border: 1px solid rgba(169,177,214,0.28); background: #1a1b26; padding: 12px 13px; box-sizing: border-box; display: flex; flex-direction: column; gap: 5px; }
|
||||
.bt { font-size: 12px; color: #c0caf5; font-weight: 500; }
|
||||
.bs { font-size: 10px; letter-spacing: 0.08em; color: #7aa2f7; }
|
||||
.bb { font-size: 10px; color: #565f89; line-height: 1.65; }
|
||||
.flow { display: flex; align-items: center; justify-content: center; gap: 11px; padding: 11px 0; }
|
||||
.flowt { font-size: 10px; color: #565f89; letter-spacing: 0.04em; }
|
||||
.g3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
||||
.g2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 0; width: 1120px; padding: 28px; box-sizing: border-box; color: #a9b1d6;">
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 5px; margin-bottom: 20px;">
|
||||
<div style="font-size: 15px; color: #c0caf5; font-weight: 700;">Architecture</div>
|
||||
<div style="font-size: 11px; color: #565f89; line-height: 1.65; max-width: 720px;">Plugins run unsandboxed inside the single long-lived <span style="color: #a9b1d6;">omarchy-shell</span> process, so nothing that samples twice a second belongs in QML. The shell side stays a thin reader; a small daemon owns every measurement, and three files are the whole contract between them.</div>
|
||||
</div>
|
||||
|
||||
<div class="band">
|
||||
<div class="bandcap">INSIDE OMARCHY-SHELL · QML, LOADED BY THE PLUGIN REGISTRY</div>
|
||||
<div class="g3">
|
||||
<div class="box">
|
||||
<div class="bt">BarWidget.qml</div>
|
||||
<div class="bs">kind: bar-widget</div>
|
||||
<div class="bb">Index, latency or live sparkline. Colour carries the state. Click opens the panel, middle-click runs a peak test.</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="bt">Panel.qml</div>
|
||||
<div class="bs">five tab Loaders</div>
|
||||
<div class="bb">Only the selected tab is alive, so a hidden tab costs nothing per sample. Charts are Canvas, same technique as the UniFi plugin.</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="bt">Service.qml</div>
|
||||
<div class="bs">kind: service</div>
|
||||
<div class="bb">Starts at shell startup, spawns the daemon if no lock is held, restarts it if it dies, raises outage notifications.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
<svg width="13" height="30" viewBox="0 0 13 30"><path d="M6.5 0v22" stroke="#565f89" stroke-width="1"></path><path d="M2.5 19l4 5 4-5" fill="none" stroke="#565f89" stroke-width="1"></path></svg>
|
||||
<div class="flowt">One <span style="color: #a9b1d6;">nexthop stream</span> reader for live state — bounded, no-follow, regular-file-only, so the shell never opens a state path itself · one Process call to <span style="color: #a9b1d6;">nexthop query</span> when you open a longer window</div>
|
||||
<svg width="13" height="30" viewBox="0 0 13 30"><path d="M6.5 30V8" stroke="#565f89" stroke-width="1"></path><path d="M2.5 11l4-5 4 5" fill="none" stroke="#565f89" stroke-width="1"></path></svg>
|
||||
</div>
|
||||
|
||||
<div class="band">
|
||||
<div class="bandcap">THE CONTRACT · ~/.LOCAL/STATE/NEXTHOP</div>
|
||||
<div class="g3">
|
||||
<div class="box">
|
||||
<div class="bt">live.json</div>
|
||||
<div class="bs">rewritten 2× / sec</div>
|
||||
<div class="bb">Both legs, lag, loss, rates, signal, the three component scores and the index. Under 1 KB, atomic rename. This is all the bar widget ever reads.</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="bt">recent.json</div>
|
||||
<div class="bs">rewritten every 5 sec</div>
|
||||
<div class="bb">A 30-minute ring buffer, pre-downsampled to ~360 points. The panel's default graphs paint from it with no subprocess at all.</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="bt">history.db</div>
|
||||
<div class="bs">sqlite, stdlib</div>
|
||||
<div class="bb">1-minute rows for 7 days, 1-hour rows for a year, every speed test and every event kept. QML never speaks SQL — the CLI answers in JSON.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
<svg width="13" height="30" viewBox="0 0 13 30"><path d="M6.5 30V8" stroke="#565f89" stroke-width="1"></path><path d="M2.5 11l4-5 4 5" fill="none" stroke="#565f89" stroke-width="1"></path></svg>
|
||||
<div class="flowt">writes only · the daemon never talks to the shell, so either side can restart without the other noticing</div>
|
||||
</div>
|
||||
|
||||
<div class="band">
|
||||
<div class="bandcap">NEXTHOPD · PYTHON 3, STANDARD LIBRARY ONLY, NO PIP</div>
|
||||
|
||||
<div class="g3" style="margin-bottom: 14px;">
|
||||
<div class="box">
|
||||
<div class="bt">Local leg</div>
|
||||
<div class="bs">2 Hz</div>
|
||||
<div class="bb">One persistent <span style="color: #a9b1d6;">ping -D -i 0.5</span> to the default gateway, parsed line by line. No process spawn per sample.</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="bt">Wan leg</div>
|
||||
<div class="bs">2 Hz</div>
|
||||
<div class="bb">Same again to an anchor. Wan latency is the anchor minus the gateway, which is what separates your Wi-Fi from your ISP.</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="bt">Link + counters</div>
|
||||
<div class="bs">1 Hz / 5 sec</div>
|
||||
<div class="bb"><span style="color: #a9b1d6;">/sys/class/net</span> byte counters for throughput; <span style="color: #a9b1d6;">iw station dump</span> for signal, bitrate, retries and roams.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="g3" style="margin-bottom: 14px;">
|
||||
<div class="box">
|
||||
<div class="bt">Application path</div>
|
||||
<div class="bs">1 Hz + 5 min</div>
|
||||
<div class="bb">A TCP handshake to the anchor on 443, and one HTTPS request every five minutes. Routers answer pings from hardware and rate-limit them under load, so ICMP alone is not what applications get.</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="bt">Content speed</div>
|
||||
<div class="bs">hourly, ~14 MB</div>
|
||||
<div class="bb">A short ranged fetch, enough to score Speed honestly without moving real data. This is what feeds the index.</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="bt">Scorer</div>
|
||||
<div class="bs">continuous</div>
|
||||
<div class="bb">Lag from latency, jitter and loss → Responsiveness. Downtime → Reliability. Content speed → Speed. Weakest-link, 0–100: you feel the bottleneck, not the average.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="bt">Roll-up and retention</div>
|
||||
<div class="bb" style="line-height: 1.7;">Raw 2 Hz samples never hit the disk. The daemon folds them into 1-second aggregates in memory, 1-minute rows on the way to sqlite, and 1-hour rows after a week. A month of continuous monitoring lands under 12 MB.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
<svg width="13" height="30" viewBox="0 0 13 30"><path d="M6.5 0v22" stroke="#565f89" stroke-width="1"></path><path d="M2.5 19l4 5 4-5" fill="none" stroke="#565f89" stroke-width="1"></path></svg>
|
||||
<div class="flowt">only when you ask, or when a schedule you turned on says so</div>
|
||||
</div>
|
||||
|
||||
<div class="band">
|
||||
<div class="bandcap">ON DEMAND</div>
|
||||
<div class="g2">
|
||||
<div class="box">
|
||||
<div class="bt">Peak speed test</div>
|
||||
<div class="bs">ookla → cloudflare → fast.com</div>
|
||||
<div class="bb">Uses the official <span style="color: #a9b1d6;">speedtest</span> CLI when it is on the machine, for server choice and a shareable result. Falls back to a Cloudflare or fast.com run that needs nothing installed — the same method Omarchy's own speed test uses. Idle and loaded latency are captured on every run.</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="bt">Notifications and report</div>
|
||||
<div class="bs">omarchy-notification-send</div>
|
||||
<div class="bb">One notification when a disruption starts and one when it clears, naming the leg that failed. "Copy report" renders the visible window as plain text with timestamps, both legs and loss.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; margin-top: 24px; padding-top: 20px; border-top: 1px solid rgba(169,177,214,0.14);">
|
||||
<div style="display: flex; flex-direction: column; gap: 7px;">
|
||||
<div style="font-size: 10px; letter-spacing: 0.14em; color: #565f89;">WHY A DAEMON AND NOT QML TIMERS</div>
|
||||
<div style="font-size: 11px; color: #a9b1d6; line-height: 1.7;">History has to survive a shell restart, and you restart the shell every time you change a theme. Sampling twice a second from QML would also mean a subprocess per probe inside the process that draws your desktop. And the panel must open already full of data, not start collecting when you look at it.</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 7px;">
|
||||
<div style="font-size: 10px; letter-spacing: 0.14em; color: #565f89;">TWO WAYS TO RUN IT</div>
|
||||
<div style="font-size: 11px; color: #a9b1d6; line-height: 1.7;">By default the shell service starts the daemon, so installing the plugin is still just a clone and an enable — the marketplace installer never runs code. Anyone who wants monitoring while the shell is down installs a <span style="color: #c0caf5;">systemd --user</span> unit with one command; the service sees the lock is held and simply attaches.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-dc>
|
||||
|
||||
<script data-dc-script data-props='{"$preview":{"width":1120,"height":1180}}'>
|
||||
class Component extends DCLogic {}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,164 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap">
|
||||
<style>
|
||||
body { margin: 0; background: #0e0e14; font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
a { color: #7aa2f7; } a:hover { color: #b4bee6; }
|
||||
.bar { display: flex; align-items: center; justify-content: space-between; height: 26px; background: #1a1b26; padding: 0 8px; box-sizing: border-box; flex-grow: 1; }
|
||||
.grp { display: flex; align-items: center; gap: 10px; }
|
||||
.lbl { font-size: 10px; letter-spacing: 0.1em; color: #565f89; width: 176px; flex-shrink: 0; line-height: 1.5; }
|
||||
.row { display: flex; align-items: center; gap: 18px; }
|
||||
.me { display: flex; align-items: center; gap: 5px; padding: 0 6px; height: 21px; }
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 18px; width: 900px; padding: 26px; box-sizing: border-box;">
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<div style="font-size: 13px; color: #c0caf5; letter-spacing: 0.02em;">Bar widget — the only always-visible surface</div>
|
||||
<div style="font-size: 11px; color: #565f89; line-height: 1.6;">26 px tall, drawn in the shell's bar font. Four display modes, all six states. Colour is the whole message: you should know your connection went bad without reading a number.</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 1px; background: rgba(169,177,214,0.12);"></div>
|
||||
|
||||
<div class="row">
|
||||
<div class="lbl">MODE A<br>INDEX · DEFAULT</div>
|
||||
<div class="bar">
|
||||
<div class="grp">
|
||||
<div style="display: flex; gap: 5px;"><div style="width: 7px; height: 7px; background: #7aa2f7;"></div><div style="width: 7px; height: 7px; background: rgba(169,177,214,0.28);"></div><div style="width: 7px; height: 7px; background: rgba(169,177,214,0.28);"></div></div>
|
||||
</div>
|
||||
<div class="grp">
|
||||
<div class="me" style="background: rgba(169,177,214,0.04);">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#9ece6a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13h4l3-8 4 16 3-8h6"></path></svg>
|
||||
<span style="font-size: 11px; color: #9ece6a; font-weight: 500;">94</span>
|
||||
</div>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#a9b1d6" stroke-width="1.6" stroke-linecap="round"><path d="M2 8.5a15 15 0 0 1 20 0"></path><path d="M5.5 12.4a10 10 0 0 1 13 0"></path><circle cx="12" cy="18" r="1.2" fill="#a9b1d6" stroke="none"></circle></svg>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#a9b1d6" stroke-width="1.6" stroke-linejoin="round"><path d="M4 9v6h4l5 4V5L8 9H4z"></path></svg>
|
||||
<span style="font-size: 11px; color: #a9b1d6;">14:32</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="lbl">MODE B<br>LIVE LATENCY</div>
|
||||
<div class="bar">
|
||||
<div class="grp">
|
||||
<div style="display: flex; gap: 5px;"><div style="width: 7px; height: 7px; background: #7aa2f7;"></div><div style="width: 7px; height: 7px; background: rgba(169,177,214,0.28);"></div><div style="width: 7px; height: 7px; background: rgba(169,177,214,0.28);"></div></div>
|
||||
</div>
|
||||
<div class="grp">
|
||||
<div class="me">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#a9b1d6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13h4l3-8 4 16 3-8h6"></path></svg>
|
||||
<span style="font-size: 11px; color: #a9b1d6;">9 ms</span>
|
||||
</div>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#a9b1d6" stroke-width="1.6" stroke-linecap="round"><path d="M2 8.5a15 15 0 0 1 20 0"></path><path d="M5.5 12.4a10 10 0 0 1 13 0"></path><circle cx="12" cy="18" r="1.2" fill="#a9b1d6" stroke="none"></circle></svg>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#a9b1d6" stroke-width="1.6" stroke-linejoin="round"><path d="M4 9v6h4l5 4V5L8 9H4z"></path></svg>
|
||||
<span style="font-size: 11px; color: #a9b1d6;">14:32</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="lbl">MODE C<br>LIVE SPARKLINE</div>
|
||||
<div class="bar">
|
||||
<div class="grp">
|
||||
<div style="display: flex; gap: 5px;"><div style="width: 7px; height: 7px; background: #7aa2f7;"></div><div style="width: 7px; height: 7px; background: rgba(169,177,214,0.28);"></div><div style="width: 7px; height: 7px; background: rgba(169,177,214,0.28);"></div></div>
|
||||
</div>
|
||||
<div class="grp">
|
||||
<div class="me">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#9ece6a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13h4l3-8 4 16 3-8h6"></path></svg>
|
||||
<svg width="38" height="12" viewBox="0 0 38 12"><polyline points="0,9 3,8 6,10 9,6 12,8 15,4 18,9 21,7 24,10 27,5 30,8 33,6 36,7" fill="none" stroke="#7aa2f7" stroke-width="1.2" stroke-linejoin="round"></polyline></svg>
|
||||
</div>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#a9b1d6" stroke-width="1.6" stroke-linecap="round"><path d="M2 8.5a15 15 0 0 1 20 0"></path><path d="M5.5 12.4a10 10 0 0 1 13 0"></path><circle cx="12" cy="18" r="1.2" fill="#a9b1d6" stroke="none"></circle></svg>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#a9b1d6" stroke-width="1.6" stroke-linejoin="round"><path d="M4 9v6h4l5 4V5L8 9H4z"></path></svg>
|
||||
<span style="font-size: 11px; color: #a9b1d6;">14:32</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="lbl">DEGRADED<br>WAN LEG SLOW</div>
|
||||
<div class="bar">
|
||||
<div class="grp">
|
||||
<div style="display: flex; gap: 5px;"><div style="width: 7px; height: 7px; background: #7aa2f7;"></div><div style="width: 7px; height: 7px; background: rgba(169,177,214,0.28);"></div><div style="width: 7px; height: 7px; background: rgba(169,177,214,0.28);"></div></div>
|
||||
</div>
|
||||
<div class="grp">
|
||||
<div class="me" style="background: rgba(224,175,104,0.10);">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#e0af68" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13h4l3-8 4 16 3-8h6"></path></svg>
|
||||
<span style="font-size: 11px; color: #e0af68; font-weight: 500;">61</span>
|
||||
</div>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#a9b1d6" stroke-width="1.6" stroke-linecap="round"><path d="M2 8.5a15 15 0 0 1 20 0"></path><path d="M5.5 12.4a10 10 0 0 1 13 0"></path><circle cx="12" cy="18" r="1.2" fill="#a9b1d6" stroke="none"></circle></svg>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#a9b1d6" stroke-width="1.6" stroke-linejoin="round"><path d="M4 9v6h4l5 4V5L8 9H4z"></path></svg>
|
||||
<span style="font-size: 11px; color: #a9b1d6;">14:32</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="lbl">OUTAGE<br>ROUTER OK, WAN DEAD</div>
|
||||
<div class="bar">
|
||||
<div class="grp">
|
||||
<div style="display: flex; gap: 5px;"><div style="width: 7px; height: 7px; background: #7aa2f7;"></div><div style="width: 7px; height: 7px; background: rgba(169,177,214,0.28);"></div><div style="width: 7px; height: 7px; background: rgba(169,177,214,0.28);"></div></div>
|
||||
</div>
|
||||
<div class="grp">
|
||||
<div class="me" style="background: rgba(247,118,142,0.14);">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#f7768e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13h6l4 0"></path><path d="M16 13h6"></path><path d="M12.5 8.5 15 13l-2.5 4.5"></path></svg>
|
||||
<span style="font-size: 11px; color: #f7768e; font-weight: 500;">2m 14s</span>
|
||||
</div>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#a9b1d6" stroke-width="1.6" stroke-linecap="round"><path d="M2 8.5a15 15 0 0 1 20 0"></path><path d="M5.5 12.4a10 10 0 0 1 13 0"></path><circle cx="12" cy="18" r="1.2" fill="#a9b1d6" stroke="none"></circle></svg>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#a9b1d6" stroke-width="1.6" stroke-linejoin="round"><path d="M4 9v6h4l5 4V5L8 9H4z"></path></svg>
|
||||
<span style="font-size: 11px; color: #a9b1d6;">14:32</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="lbl">SPEED TEST RUNNING<br>PROGRESS ON THE WIDGET</div>
|
||||
<div class="bar" style="position: relative;">
|
||||
<div class="grp">
|
||||
<div style="display: flex; gap: 5px;"><div style="width: 7px; height: 7px; background: #7aa2f7;"></div><div style="width: 7px; height: 7px; background: rgba(169,177,214,0.28);"></div><div style="width: 7px; height: 7px; background: rgba(169,177,214,0.28);"></div></div>
|
||||
</div>
|
||||
<div class="grp">
|
||||
<div class="me" style="background: rgba(122,162,247,0.12); position: relative;">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#7aa2f7" stroke-width="1.7" stroke-linecap="round"><path d="M4 18a9 9 0 1 1 16 0"></path><path d="M12 14.5 16.5 9"></path></svg>
|
||||
<span style="font-size: 11px; color: #7aa2f7;">386 ↓</span>
|
||||
<div style="position: absolute; left: 0; bottom: 0; width: 62%; height: 2px; background: #7aa2f7;"></div>
|
||||
</div>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#a9b1d6" stroke-width="1.6" stroke-linecap="round"><path d="M2 8.5a15 15 0 0 1 20 0"></path><path d="M5.5 12.4a10 10 0 0 1 13 0"></path><circle cx="12" cy="18" r="1.2" fill="#a9b1d6" stroke="none"></circle></svg>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#a9b1d6" stroke-width="1.6" stroke-linejoin="round"><path d="M4 9v6h4l5 4V5L8 9H4z"></path></svg>
|
||||
<span style="font-size: 11px; color: #a9b1d6;">14:32</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 1px; background: rgba(169,177,214,0.12);"></div>
|
||||
|
||||
<div style="display: flex; gap: 26px;">
|
||||
<div style="display: flex; flex-direction: column; gap: 4px; flex-grow: 1;">
|
||||
<div style="font-size: 10px; letter-spacing: 0.1em; color: #565f89;">TOOLTIP ON HOVER</div>
|
||||
<div style="font-size: 11px; color: #a9b1d6; line-height: 1.6;">Excitel · 94 excellent<br>local 1.2 ms · wan 8.4 ms · 0.0% loss</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px; flex-grow: 1;">
|
||||
<div style="font-size: 10px; letter-spacing: 0.1em; color: #565f89;">CLICK</div>
|
||||
<div style="font-size: 11px; color: #a9b1d6; line-height: 1.6;">Opens the panel.<br>Middle-click runs a speed test.</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px; flex-grow: 1;">
|
||||
<div style="font-size: 10px; letter-spacing: 0.1em; color: #565f89;">NOTIFICATION</div>
|
||||
<div style="font-size: 11px; color: #a9b1d6; line-height: 1.6;">Fires on outage start and recovery,<br>with the leg that failed named.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-dc>
|
||||
|
||||
<script data-dc-script data-props='{"$preview":{"width":900,"height":520}}'>
|
||||
class Component extends DCLogic {}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,164 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap">
|
||||
<style>
|
||||
body { margin: 0; background: #0e0e14; font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
a { color: #7aa2f7; } a:hover { color: #b4bee6; }
|
||||
.cap { font-size: 10px; letter-spacing: 0.12em; color: #565f89; }
|
||||
.tab { flex-grow: 1; height: 26px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: #a9b1d6; background: rgba(169,177,214,0.04); border: 1px solid rgba(169,177,214,0.4); }
|
||||
.tabon { flex-grow: 1; height: 26px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: #c0caf5; background: rgba(169,177,214,0.18); border: 1px solid transparent; }
|
||||
.sep { height: 1px; background: rgba(169,177,214,0.12); }
|
||||
.ev { display: flex; align-items: flex-start; gap: 10px; }
|
||||
.evt { font-size: 10px; color: #565f89; width: 60px; flex-shrink: 0; padding-top: 1px; }
|
||||
.evb { font-size: 11px; color: #a9b1d6; line-height: 1.55; flex-grow: 1; }
|
||||
.evd { font-size: 10px; color: #565f89; flex-shrink: 0; padding-top: 1px; }
|
||||
.dot { width: 5px; height: 5px; flex-shrink: 0; margin-top: 5px; }
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<div style="display: flex; flex-direction: column; width: 560px; background: #1a1b26; border: 2px solid #7aa2f7; box-sizing: border-box; padding: 18px; color: #a9b1d6;">
|
||||
|
||||
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;">
|
||||
<div style="display: flex; align-items: flex-start; gap: 10px;">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#9ece6a" stroke-width="1.6" stroke-linecap="round" style="margin-top: 2px;">
|
||||
<path d="M2 8.5a15 15 0 0 1 20 0"></path><path d="M5 12.2a10.5 10.5 0 0 1 14 0"></path><path d="M8.2 15.9a6 6 0 0 1 7.6 0"></path><circle cx="12" cy="19.3" r="1.1" fill="#9ece6a" stroke="none"></circle>
|
||||
</svg>
|
||||
<div style="display: flex; flex-direction: column; gap: 3px;">
|
||||
<div style="font-size: 16px; font-weight: 700; color: #c0caf5;">Excitel</div>
|
||||
<div class="cap" style="letter-spacing: 0.1em;">EXCELLENT · ONLINE 4H 12M</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: 1px;">
|
||||
<div style="font-size: 30px; font-weight: 700; line-height: 1; color: #9ece6a;">94</div>
|
||||
<div class="cap">EXPERIENCE</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 6px; margin-top: 16px;">
|
||||
<div class="tab">Overview</div><div class="tab">Latency</div><div class="tab">Speed</div><div class="tab">Wi-Fi</div><div class="tabon">Events</div>
|
||||
</div>
|
||||
|
||||
<div class="sep" style="margin: 16px 0 14px 0;"></div>
|
||||
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 10px;">
|
||||
<div class="cap">EXPERIENCE, LAST 24 HOURS</div>
|
||||
<div style="font-size: 10px; color: #565f89;">1-minute buckets</div>
|
||||
</div>
|
||||
|
||||
<svg width="524" height="26" viewBox="0 0 524 26" style="display: block;">
|
||||
<sc-for list="{{c.ribbon}}" as="r" hint-placeholder-count="24">
|
||||
<rect x="{{r.x}}" y="0" width="{{r.w}}" height="26" fill="{{r.fill}}"></rect>
|
||||
</sc-for>
|
||||
</svg>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; margin-top: 5px;">
|
||||
<div style="font-size: 9px; color: #414868;">14:30 yest.</div>
|
||||
<div style="font-size: 9px; color: #414868;">00:00</div>
|
||||
<div style="font-size: 9px; color: #414868;">08:00</div>
|
||||
<div style="font-size: 9px; color: #414868;">now</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 12px; margin-top: 10px; flex-wrap: wrap;">
|
||||
<div style="display: flex; align-items: center; gap: 5px;"><div style="width: 9px; height: 9px; background: #9ece6a;"></div><div style="font-size: 10px; color: #565f89;">90+ excellent</div></div>
|
||||
<div style="display: flex; align-items: center; gap: 5px;"><div style="width: 9px; height: 9px; background: #b9f27c;"></div><div style="font-size: 10px; color: #565f89;">80 good</div></div>
|
||||
<div style="display: flex; align-items: center; gap: 5px;"><div style="width: 9px; height: 9px; background: #e0af68;"></div><div style="font-size: 10px; color: #565f89;">70 okay</div></div>
|
||||
<div style="display: flex; align-items: center; gap: 5px;"><div style="width: 9px; height: 9px; background: #eb927b;"></div><div style="font-size: 10px; color: #565f89;">50 fair</div></div>
|
||||
<div style="display: flex; align-items: center; gap: 5px;"><div style="width: 9px; height: 9px; background: #f7768e;"></div><div style="font-size: 10px; color: #565f89;">under 50 poor</div></div>
|
||||
</div>
|
||||
|
||||
<div class="sep" style="margin: 16px 0 12px 0;"></div>
|
||||
|
||||
<div class="cap" style="margin-bottom: 11px;">LAST 7 DAYS</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 8px; align-items: end;">
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 5px;"><div style="font-size: 10px; color: #c0caf5;">91</div><div style="width: 100%; height: 44px; background: rgba(169,177,214,0.08); display: flex; align-items: flex-end;"><div style="width: 100%; height: 40px; background: #9ece6a;"></div></div><div style="font-size: 9px; color: #414868;">Mon</div></div>
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 5px;"><div style="font-size: 10px; color: #c0caf5;">93</div><div style="width: 100%; height: 44px; background: rgba(169,177,214,0.08); display: flex; align-items: flex-end;"><div style="width: 100%; height: 41px; background: #9ece6a;"></div></div><div style="font-size: 9px; color: #414868;">Tue</div></div>
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 5px;"><div style="font-size: 10px; color: #c0caf5;">67</div><div style="width: 100%; height: 44px; background: rgba(169,177,214,0.08); display: flex; align-items: flex-end;"><div style="width: 100%; height: 29px; background: #eb927b;"></div></div><div style="font-size: 9px; color: #414868;">Wed</div></div>
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 5px;"><div style="font-size: 10px; color: #c0caf5;">88</div><div style="width: 100%; height: 44px; background: rgba(169,177,214,0.08); display: flex; align-items: flex-end;"><div style="width: 100%; height: 39px; background: #b9f27c;"></div></div><div style="font-size: 9px; color: #414868;">Thu</div></div>
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 5px;"><div style="font-size: 10px; color: #c0caf5;">94</div><div style="width: 100%; height: 44px; background: rgba(169,177,214,0.08); display: flex; align-items: flex-end;"><div style="width: 100%; height: 41px; background: #9ece6a;"></div></div><div style="font-size: 9px; color: #414868;">Fri</div></div>
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 5px;"><div style="font-size: 10px; color: #c0caf5;">95</div><div style="width: 100%; height: 44px; background: rgba(169,177,214,0.08); display: flex; align-items: flex-end;"><div style="width: 100%; height: 42px; background: #9ece6a;"></div></div><div style="font-size: 9px; color: #414868;">Sat</div></div>
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 5px;"><div style="font-size: 10px; color: #c0caf5;">94</div><div style="width: 100%; height: 44px; background: rgba(169,177,214,0.08); display: flex; align-items: flex-end;"><div style="width: 100%; height: 41px; background: #9ece6a;"></div></div><div style="font-size: 9px; color: #414868;">Sun</div></div>
|
||||
</div>
|
||||
|
||||
<div class="sep" style="margin: 16px 0 12px 0;"></div>
|
||||
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 12px;">
|
||||
<div class="cap">WHAT HAPPENED</div>
|
||||
<div style="font-size: 10px; color: #565f89;">3 disruptions this week</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 11px;">
|
||||
<div class="ev">
|
||||
<div class="evt">Wed 19:14</div>
|
||||
<div class="dot" style="background: #f7768e;"></div>
|
||||
<div class="evb">No internet. Router still answered in 2 ms, so the fault was upstream of your line.</div>
|
||||
<div class="evd">7m 41s</div>
|
||||
</div>
|
||||
<div class="ev">
|
||||
<div class="evt">Wed 20:02</div>
|
||||
<div class="dot" style="background: #eb927b;"></div>
|
||||
<div class="evb">Lag above 120 ms on the wan leg. Speed unaffected.</div>
|
||||
<div class="evd">52m</div>
|
||||
</div>
|
||||
<div class="ev">
|
||||
<div class="evt">Thu 09:30</div>
|
||||
<div class="dot" style="background: #7aa2f7;"></div>
|
||||
<div class="evb">Public address changed, 49.36.x.x → 49.37.x.x. Same ISP, same AS.</div>
|
||||
<div class="evd">—</div>
|
||||
</div>
|
||||
<div class="ev">
|
||||
<div class="evt">Fri 12:04</div>
|
||||
<div class="dot" style="background: #bb9af7;"></div>
|
||||
<div class="evb">Roamed 3× between …25:fe ↔ …25:fa ↔ …25:b4</div>
|
||||
<div class="evd">2m</div>
|
||||
</div>
|
||||
<div class="ev">
|
||||
<div class="evt">Sat 03:00</div>
|
||||
<div class="dot" style="background: #565f89;"></div>
|
||||
<div class="evb">DNS resolver stopped answering for 90 s. Fell back to the secondary.</div>
|
||||
<div class="evd">1m 30s</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sep" style="margin: 16px 0 12px 0;"></div>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px;">
|
||||
<div style="font-size: 10px; color: #565f89; line-height: 1.6; flex-grow: 1;">Builds a plain-text summary of the window you are looking at, with timestamps, both legs and loss — the thing an ISP actually asks for.</div>
|
||||
<div style="display: flex; align-items: center; gap: 7px; height: 28px; padding: 0 12px; background: rgba(169,177,214,0.04); border: 1px solid rgba(169,177,214,0.4); color: #c0caf5; font-size: 11px; flex-shrink: 0;">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#c0caf5" stroke-width="1.6" stroke-linejoin="round"><rect x="9" y="4" width="11" height="14"></rect><path d="M15 20H4V7"></path></svg>
|
||||
Copy report
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-dc>
|
||||
|
||||
<script data-dc-script data-props='{"$preview":{"width":560,"height":840}}'>
|
||||
class Component extends DCLogic {
|
||||
rng(seed) { let s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296 } }
|
||||
|
||||
renderVals() {
|
||||
var N = 96, W = 524, rand = this.rng(31337)
|
||||
var seg = W / N
|
||||
var out = []
|
||||
for (var i = 0; i < N; i++) {
|
||||
var score = 92 + rand() * 7
|
||||
if (i > 18 && i < 23) score = 41 + rand() * 8
|
||||
if (i >= 23 && i < 31) score = 62 + rand() * 9
|
||||
if (i > 54 && i < 58) score = 76 + rand() * 6
|
||||
var fill = score >= 90 ? '#9ece6a' : score >= 80 ? '#b9f27c' : score >= 70 ? '#e0af68' : score >= 50 ? '#eb927b' : '#f7768e'
|
||||
out.push({ x: (i * seg).toFixed(2), w: (seg + 0.4).toFixed(2), fill: fill })
|
||||
}
|
||||
return { c: { ribbon: out } }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,215 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap">
|
||||
<style>
|
||||
body { margin: 0; background: #0e0e14; font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
a { color: #7aa2f7; } a:hover { color: #b4bee6; }
|
||||
.cap { font-size: 10px; letter-spacing: 0.12em; color: #565f89; }
|
||||
.tab { flex-grow: 1; height: 26px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: #a9b1d6; background: rgba(169,177,214,0.04); border: 1px solid rgba(169,177,214,0.4); }
|
||||
.tabon { flex-grow: 1; height: 26px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: #c0caf5; background: rgba(169,177,214,0.18); border: 1px solid transparent; }
|
||||
.pill { height: 22px; padding: 0 11px; display: flex; align-items: center; font-size: 10px; letter-spacing: 0.06em; color: #a9b1d6; background: rgba(169,177,214,0.04); border: 1px solid rgba(169,177,214,0.4); }
|
||||
.pillon { height: 22px; padding: 0 11px; display: flex; align-items: center; font-size: 10px; letter-spacing: 0.06em; color: #c0caf5; background: rgba(169,177,214,0.18); border: 1px solid transparent; }
|
||||
.sep { height: 1px; background: rgba(169,177,214,0.12); }
|
||||
.num { font-size: 11px; color: #c0caf5; text-align: right; }
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<div style="display: flex; flex-direction: column; width: 560px; background: #1a1b26; border: 2px solid #7aa2f7; box-sizing: border-box; padding: 18px; color: #a9b1d6;">
|
||||
|
||||
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;">
|
||||
<div style="display: flex; align-items: flex-start; gap: 10px;">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#9ece6a" stroke-width="1.6" stroke-linecap="round" style="margin-top: 2px;">
|
||||
<path d="M2 8.5a15 15 0 0 1 20 0"></path><path d="M5 12.2a10.5 10.5 0 0 1 14 0"></path><path d="M8.2 15.9a6 6 0 0 1 7.6 0"></path><circle cx="12" cy="19.3" r="1.1" fill="#9ece6a" stroke="none"></circle>
|
||||
</svg>
|
||||
<div style="display: flex; flex-direction: column; gap: 3px;">
|
||||
<div style="font-size: 16px; font-weight: 700; color: #c0caf5;">Excitel</div>
|
||||
<div class="cap" style="letter-spacing: 0.1em;">EXCELLENT · ONLINE 4H 12M</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: 1px;">
|
||||
<div style="font-size: 30px; font-weight: 700; line-height: 1; color: #9ece6a;">94</div>
|
||||
<div class="cap">EXPERIENCE</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 6px; margin-top: 16px;">
|
||||
<div class="tab">Overview</div><div class="tabon">Latency</div><div class="tab">Speed</div><div class="tab">Wi-Fi</div><div class="tab">Events</div>
|
||||
</div>
|
||||
|
||||
<div class="sep" style="margin: 16px 0 14px 0;"></div>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<div class="pill">5M</div><div class="pillon">30M</div><div class="pill">6H</div><div class="pill">24H</div><div class="pill">7D</div>
|
||||
</div>
|
||||
<div class="cap">1 SAMPLE / SEC</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between; margin: 16px 0 6px 0;">
|
||||
<div class="cap">ROUND-TRIP LATENCY, BY LEG</div>
|
||||
<div style="font-size: 10px; color: #565f89;">peak 41 ms</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<div style="display: flex; flex-direction: column; justify-content: space-between; height: 140px; padding-bottom: 2px;">
|
||||
<div style="font-size: 9px; color: #414868;">45</div>
|
||||
<div style="font-size: 9px; color: #414868;">30</div>
|
||||
<div style="font-size: 9px; color: #414868;">15</div>
|
||||
<div style="font-size: 9px; color: #414868;">0</div>
|
||||
</div>
|
||||
<svg width="496" height="140" viewBox="0 0 496 140" style="display: block;">
|
||||
<line x1="0" y1="0.5" x2="496" y2="0.5" stroke="rgba(169,177,214,0.07)"></line>
|
||||
<line x1="0" y1="45.5" x2="496" y2="45.5" stroke="rgba(169,177,214,0.07)"></line>
|
||||
<line x1="0" y1="90.5" x2="496" y2="90.5" stroke="rgba(169,177,214,0.07)"></line>
|
||||
<line x1="0" y1="135.5" x2="496" y2="135.5" stroke="rgba(169,177,214,0.16)"></line>
|
||||
<path d="{{c.wanArea}}" fill="rgba(122,162,247,0.20)"></path>
|
||||
<path d="{{c.wanLine}}" fill="none" stroke="#7aa2f7" stroke-width="1.5" stroke-linejoin="round"></path>
|
||||
<path d="{{c.localArea}}" fill="rgba(86,95,137,0.6)"></path>
|
||||
<path d="{{c.localLine}}" fill="none" stroke="#565f89" stroke-width="1" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 16px; margin-top: 7px; padding-left: 26px;">
|
||||
<div style="display: flex; align-items: center; gap: 6px;"><div style="width: 10px; height: 2px; background: #7aa2f7;"></div><div style="font-size: 10px; color: #565f89;">wan leg (router → 1.1.1.1)</div></div>
|
||||
<div style="display: flex; align-items: center; gap: 6px;"><div style="width: 10px; height: 2px; background: #565f89;"></div><div style="font-size: 10px; color: #565f89;">local leg (laptop → router)</div></div>
|
||||
</div>
|
||||
|
||||
<div class="sep" style="margin: 16px 0 12px 0;"></div>
|
||||
|
||||
<div class="cap" style="margin-bottom: 7px;">JITTER</div>
|
||||
<svg width="524" height="40" viewBox="0 0 524 40" style="display: block;">
|
||||
<line x1="0" y1="39.5" x2="524" y2="39.5" stroke="rgba(169,177,214,0.16)"></line>
|
||||
<path d="{{c.jitterArea}}" fill="rgba(187,154,247,0.22)"></path>
|
||||
<path d="{{c.jitterLine}}" fill="none" stroke="#bb9af7" stroke-width="1.2" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; margin-top: 5px;">
|
||||
<div style="font-size: 10px; color: #565f89;">mean 1.8 ms</div>
|
||||
<div style="font-size: 10px; color: #565f89;">peak 9.4 ms</div>
|
||||
</div>
|
||||
|
||||
<div class="sep" style="margin: 14px 0 12px 0;"></div>
|
||||
|
||||
<div class="cap" style="margin-bottom: 7px;">PACKET LOSS</div>
|
||||
<svg width="524" height="26" viewBox="0 0 524 26" style="display: block;">
|
||||
<line x1="0" y1="25.5" x2="524" y2="25.5" stroke="rgba(169,177,214,0.16)"></line>
|
||||
<sc-for list="{{c.lossBars}}" as="b" hint-placeholder-count="3">
|
||||
<rect x="{{b.x}}" y="{{b.y}}" width="3" height="{{b.h}}" fill="#f7768e"></rect>
|
||||
</sc-for>
|
||||
</svg>
|
||||
<div style="font-size: 10px; color: #565f89; margin-top: 5px;">2 lost of 1800 probes · 0.11% · all on the wan leg</div>
|
||||
|
||||
<div class="sep" style="margin: 14px 0 12px 0;"></div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px 14px;">
|
||||
<div class="cap"></div>
|
||||
<div class="cap" style="text-align: right;">LOCAL LEG</div>
|
||||
<div class="cap" style="text-align: right;">WAN LEG</div>
|
||||
|
||||
<div style="font-size: 11px; color: #565f89;">median</div><div class="num">1.2 ms</div><div class="num">8.4 ms</div>
|
||||
<div style="font-size: 11px; color: #565f89;">p95</div><div class="num">2.1 ms</div><div class="num">14.0 ms</div>
|
||||
<div style="font-size: 11px; color: #565f89;">p99</div><div class="num">3.4 ms</div><div class="num">27.0 ms</div>
|
||||
<div style="font-size: 11px; color: #565f89;">worst</div><div class="num">6.8 ms</div><div class="num">41.0 ms</div>
|
||||
<div style="font-size: 11px; color: #565f89;">jitter</div><div class="num">0.4 ms</div><div class="num">1.8 ms</div>
|
||||
<div style="font-size: 11px; color: #565f89;">loss</div><div class="num">0.00 %</div><div class="num" style="color: #e0af68;">0.11 %</div>
|
||||
</div>
|
||||
|
||||
<div class="sep" style="margin: 14px 0 12px 0;"></div>
|
||||
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 9px;">
|
||||
<div class="cap">LATENCY UNDER LOAD</div>
|
||||
<div style="font-size: 10px; color: #565f89;">measured during the last speed test</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<div style="flex-grow: 1; display: flex; flex-direction: column; gap: 6px;">
|
||||
<div style="display: flex; align-items: center; gap: 9px;">
|
||||
<div style="width: 46px; font-size: 10px; color: #565f89;">idle</div>
|
||||
<div style="flex-grow: 1; height: 8px; background: rgba(169,177,214,0.10);"><div style="height: 8px; width: 12%; background: #9ece6a;"></div></div>
|
||||
<div style="width: 52px; font-size: 11px; color: #c0caf5; text-align: right;">9 ms</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 9px;">
|
||||
<div style="width: 46px; font-size: 10px; color: #565f89;">loaded</div>
|
||||
<div style="flex-grow: 1; height: 8px; background: rgba(169,177,214,0.10);"><div style="height: 8px; width: 45%; background: #e0af68;"></div></div>
|
||||
<div style="width: 52px; font-size: 11px; color: #c0caf5; text-align: right;">34 ms</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 2px; width: 78px; padding-left: 12px; border-left: 1px solid rgba(169,177,214,0.12);">
|
||||
<div style="font-size: 20px; font-weight: 700; line-height: 1.1; color: #9ece6a;">A−</div>
|
||||
<div class="cap" style="letter-spacing: 0.08em;">BUFFERBLOAT</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 10px; color: #565f89; margin-top: 9px; line-height: 1.6;">+25 ms added under full load. Below 30 ms a video call stays clean while someone else is downloading.</div>
|
||||
|
||||
</div>
|
||||
</x-dc>
|
||||
|
||||
<script data-dc-script data-props='{"$preview":{"width":560,"height":880}}'>
|
||||
class Component extends DCLogic {
|
||||
rng(seed) { let s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296 } }
|
||||
|
||||
renderVals() {
|
||||
var N = 110, rand = this.rng(77003)
|
||||
var local = [], wan = [], jit = []
|
||||
for (var i = 0; i < N; i++) {
|
||||
var l = 1.2 + rand() * 0.9
|
||||
var w = 8.4 * (0.8 + rand() * 0.4)
|
||||
if (i === 38 || i === 39) w = 33
|
||||
if (i === 71) w = 24
|
||||
if (i === 90 || i === 91 || i === 92) w = 30 + rand() * 8
|
||||
local.push(l); wan.push(w)
|
||||
jit.push(0.7 + rand() * 2.4 + (i > 88 && i < 94 ? 6 : 0))
|
||||
}
|
||||
|
||||
var W = 496, H = 140, peak = 45
|
||||
var xAt = function (i) { return (i * (W - 1) / (N - 1)).toFixed(1) }
|
||||
var yAt = function (v) { return (H - (H - 4) * v / peak).toFixed(1) }
|
||||
var lp = [], tp = [], localLine = '', wanLine = ''
|
||||
for (var j = 0; j < N; j++) {
|
||||
var a = xAt(j) + ',' + yAt(local[j])
|
||||
var b = xAt(j) + ',' + yAt(local[j] + wan[j])
|
||||
lp.push(a); tp.push(b)
|
||||
localLine += (j === 0 ? 'M' : 'L') + a
|
||||
wanLine += (j === 0 ? 'M' : 'L') + b
|
||||
}
|
||||
|
||||
var JW = 524, JH = 40, jpeak = 11
|
||||
var jx = function (i) { return (i * (JW - 1) / (N - 1)).toFixed(1) }
|
||||
var jy = function (v) { return (JH - (JH - 3) * v / jpeak).toFixed(1) }
|
||||
var jp = [], jitterLine = ''
|
||||
for (var k = 0; k < N; k++) {
|
||||
var p = jx(k) + ',' + jy(jit[k])
|
||||
jp.push(p)
|
||||
jitterLine += (k === 0 ? 'M' : 'L') + p
|
||||
}
|
||||
|
||||
var lossIdx = [39, 90, 91]
|
||||
var lossBars = lossIdx.map(function (i) {
|
||||
var h = i === 39 ? 12 : 20
|
||||
return { x: jx(i), y: (26 - h).toFixed(1), h: h }
|
||||
})
|
||||
|
||||
return {
|
||||
c: {
|
||||
localLine: localLine,
|
||||
wanLine: wanLine,
|
||||
localArea: 'M0,' + H + 'L' + lp.join('L') + 'L' + W + ',' + H + 'Z',
|
||||
wanArea: 'M' + tp.join('L') + 'L' + lp.slice().reverse().join('L') + 'Z',
|
||||
jitterLine: jitterLine,
|
||||
jitterArea: 'M0,' + JH + 'L' + jp.join('L') + 'L' + JW + ',' + JH + 'Z',
|
||||
lossBars: lossBars
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,298 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap">
|
||||
<style>
|
||||
body { margin: 0; background: #0e0e14; font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
a { color: #7aa2f7; } a:hover { color: #b4bee6; }
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 0; width: 560px; background: #1a1b26; border: 2px solid #7aa2f7; box-sizing: border-box; padding: 18px; color: #a9b1d6;">
|
||||
|
||||
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;">
|
||||
<div style="display: flex; align-items: flex-start; gap: 10px;">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="{{d.tone}}" stroke-width="1.6" stroke-linecap="round" style="margin-top: 2px;">
|
||||
<path d="M2 8.5a15 15 0 0 1 20 0"></path>
|
||||
<path d="M5 12.2a10.5 10.5 0 0 1 14 0"></path>
|
||||
<path d="M8.2 15.9a6 6 0 0 1 7.6 0"></path>
|
||||
<circle cx="12" cy="19.3" r="1.1" fill="{{d.tone}}" stroke="none"></circle>
|
||||
</svg>
|
||||
<div style="display: flex; flex-direction: column; gap: 3px;">
|
||||
<div style="font-size: 16px; font-weight: 700; color: #c0caf5; letter-spacing: 0.01em;">Excitel</div>
|
||||
<div style="font-size: 10px; letter-spacing: 0.1em; color: #565f89;">{{d.verdict}} · {{d.uptime}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: 1px;">
|
||||
<div style="font-size: 30px; font-weight: 700; line-height: 1; color: {{d.tone}};">{{d.index}}</div>
|
||||
<div style="font-size: 10px; letter-spacing: 0.12em; color: #565f89;">EXPERIENCE</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 6px; margin-top: 16px;">
|
||||
<div style="flex-grow: 1; height: 26px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: #c0caf5; background: rgba(169,177,214,0.18); border: 1px solid transparent;">Overview</div>
|
||||
<div style="flex-grow: 1; height: 26px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: #a9b1d6; background: rgba(169,177,214,0.04); border: 1px solid rgba(169,177,214,0.4);">Latency</div>
|
||||
<div style="flex-grow: 1; height: 26px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: #a9b1d6; background: rgba(169,177,214,0.04); border: 1px solid rgba(169,177,214,0.4);">Speed</div>
|
||||
<div style="flex-grow: 1; height: 26px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: #a9b1d6; background: rgba(169,177,214,0.04); border: 1px solid rgba(169,177,214,0.4);">Wi-Fi</div>
|
||||
<div style="flex-grow: 1; height: 26px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: #a9b1d6; background: rgba(169,177,214,0.04); border: 1px solid rgba(169,177,214,0.4);">Events</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 1px; background: rgba(169,177,214,0.12); margin: 16px 0 14px 0;"></div>
|
||||
|
||||
<div style="font-size: 10px; letter-spacing: 0.12em; color: #565f89; margin-bottom: 12px;">PATH</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 0;">
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 5px; width: 96px;">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#c0caf5" stroke-width="1.5" stroke-linejoin="round">
|
||||
<rect x="4" y="5" width="16" height="11" rx="1"></rect>
|
||||
<path d="M2 19h20"></path>
|
||||
</svg>
|
||||
<div style="font-size: 10px; color: #c0caf5;">This laptop</div>
|
||||
<div style="font-size: 10px; color: #565f89;">wlan0</div>
|
||||
</div>
|
||||
|
||||
<div style="flex-grow: 1; display: flex; flex-direction: column; align-items: center; gap: 5px; padding-bottom: 26px;">
|
||||
<div style="font-size: 11px; color: {{d.localTone}}; font-weight: 500;">{{d.localMs}} ms</div>
|
||||
<div style="width: 100%; height: 2px; background: {{d.localTone}};"></div>
|
||||
<div style="font-size: 10px; letter-spacing: 0.1em; color: #565f89;">LOCAL LEG</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 5px; width: 96px;">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#c0caf5" stroke-width="1.5" stroke-linejoin="round">
|
||||
<rect x="3" y="13" width="18" height="7" rx="1"></rect>
|
||||
<path d="M7.5 16.5h.01M11 16.5h.01"></path>
|
||||
<path d="M12 13V9"></path>
|
||||
<path d="M8.5 6.5a5 5 0 0 1 7 0"></path>
|
||||
</svg>
|
||||
<div style="font-size: 10px; color: #c0caf5;">Router</div>
|
||||
<div style="font-size: 10px; color: #565f89;">10.10.0.1</div>
|
||||
</div>
|
||||
|
||||
<div style="flex-grow: 1; display: flex; flex-direction: column; align-items: center; gap: 5px; padding-bottom: 26px;">
|
||||
<div style="font-size: 11px; color: {{d.wanTone}}; font-weight: 500;">{{d.wanMs}} ms</div>
|
||||
<div style="width: 100%; height: 2px; background: {{d.wanTone}};"></div>
|
||||
<div style="font-size: 10px; letter-spacing: 0.1em; color: #565f89;">WAN LEG</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 5px; width: 96px;">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="{{d.tone}}" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="8.5"></circle>
|
||||
<path d="M3.5 12h17"></path>
|
||||
<path d="M12 3.5c2.6 2.6 2.6 14.4 0 17c-2.6-2.6-2.6-14.4 0-17z"></path>
|
||||
</svg>
|
||||
<div style="font-size: 10px; color: #c0caf5;">Internet</div>
|
||||
<div style="font-size: 10px; color: #565f89;">AS17754</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between; margin-top: 14px; padding-top: 12px; border-top: 1px solid rgba(169,177,214,0.12);">
|
||||
<div class="capx" style="font-size: 10px; letter-spacing: 0.12em; color: #565f89;">LAG</div>
|
||||
<div style="font-size: 10px; color: #565f89;">best {{d.lagBest}} ms · typical <span style="color: #c0caf5; font-size: 11px;">{{d.lagTypical}} ms</span> · worst {{d.lagWorst}} ms</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 1px; background: rgba(169,177,214,0.12); margin: 14px 0 14px 0;"></div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px;">
|
||||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<div style="font-size: 10px; letter-spacing: 0.1em; color: #565f89;">RESPONSIVENESS</div>
|
||||
<div style="font-size: 22px; font-weight: 700; line-height: 1; color: {{d.respTone}};">{{d.resp}}</div>
|
||||
<div style="height: 3px; background: rgba(169,177,214,0.12);">
|
||||
<div style="height: 3px; width: {{d.respPct}}; background: {{d.respTone}};"></div>
|
||||
</div>
|
||||
<div style="font-size: 10px; color: #565f89;">{{d.respNote}}</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<div style="font-size: 10px; letter-spacing: 0.1em; color: #565f89;">RELIABILITY</div>
|
||||
<div style="font-size: 22px; font-weight: 700; line-height: 1; color: {{d.relTone}};">{{d.rel}}</div>
|
||||
<div style="height: 3px; background: rgba(169,177,214,0.12);">
|
||||
<div style="height: 3px; width: {{d.relPct}}; background: {{d.relTone}};"></div>
|
||||
</div>
|
||||
<div style="font-size: 10px; color: #565f89;">{{d.relNote}}</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<div style="font-size: 10px; letter-spacing: 0.1em; color: #565f89;">SPEED</div>
|
||||
<div style="font-size: 22px; font-weight: 700; line-height: 1; color: {{d.spdTone}};">{{d.spd}}</div>
|
||||
<div style="height: 3px; background: rgba(169,177,214,0.12);">
|
||||
<div style="height: 3px; width: {{d.spdPct}}; background: {{d.spdTone}};"></div>
|
||||
</div>
|
||||
<div style="font-size: 10px; color: #565f89;">{{d.spdNote}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{d.hasRec}}" hint-placeholder-val="{{ true }}">
|
||||
<div style="display: flex; align-items: flex-start; gap: 9px; margin-top: 14px; padding: 10px 12px; background: rgba(224,175,104,0.07); border: 1px solid rgba(224,175,104,0.35); box-sizing: border-box;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#e0af68" stroke-width="1.7" stroke-linecap="round" style="flex-shrink: 0; margin-top: 1px;">
|
||||
<circle cx="12" cy="12" r="9"></circle><path d="M12 8.2v4.6"></path><circle cx="12" cy="16" r="0.9" fill="#e0af68" stroke="none"></circle>
|
||||
</svg>
|
||||
<div style="font-size: 11px; color: #a9b1d6; line-height: 1.65;">{{d.rec}}</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<div style="height: 1px; background: rgba(169,177,214,0.12); margin: 16px 0 12px 0;"></div>
|
||||
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 6px;">
|
||||
<div style="font-size: 10px; letter-spacing: 0.12em; color: #565f89;">LATENCY · LAST 30 MIN</div>
|
||||
<div style="font-size: 10px; color: #565f89;">{{d.stats}}</div>
|
||||
</div>
|
||||
|
||||
<svg width="524" height="104" viewBox="0 0 524 104" style="display: block;">
|
||||
<line x1="0" y1="95.5" x2="524" y2="95.5" stroke="rgba(169,177,214,0.15)" stroke-width="1"></line>
|
||||
<path d="{{chart.wanArea}}" fill="rgba(122,162,247,0.20)"></path>
|
||||
<path d="{{chart.wanLine}}" fill="none" stroke="#7aa2f7" stroke-width="1.5" stroke-linejoin="round"></path>
|
||||
<path d="{{chart.localArea}}" fill="rgba(86,95,137,0.55)"></path>
|
||||
<path d="{{chart.localLine}}" fill="none" stroke="#565f89" stroke-width="1" stroke-linejoin="round"></path>
|
||||
<sc-for list="{{chart.loss}}" as="tick" hint-placeholder-count="2">
|
||||
<rect x="{{tick.x}}" y="88" width="2" height="8" fill="#f7768e"></rect>
|
||||
</sc-for>
|
||||
</svg>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 16px; margin-top: 6px;">
|
||||
<div style="display: flex; align-items: center; gap: 6px;">
|
||||
<div style="width: 10px; height: 2px; background: #7aa2f7;"></div>
|
||||
<div style="font-size: 10px; color: #565f89;">wan leg</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 6px;">
|
||||
<div style="width: 10px; height: 2px; background: #565f89;"></div>
|
||||
<div style="font-size: 10px; color: #565f89;">local leg</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 6px;">
|
||||
<div style="width: 2px; height: 8px; background: #f7768e;"></div>
|
||||
<div style="font-size: 10px; color: #565f89;">packet loss</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 1px; background: rgba(169,177,214,0.12); margin: 14px 0 12px 0;"></div>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px;">
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<div style="font-size: 10px; letter-spacing: 0.1em; color: #565f89;">LAST SPEED TEST · {{d.testAge}}</div>
|
||||
<div style="display: flex; align-items: baseline; gap: 12px;">
|
||||
<div style="display: flex; align-items: baseline; gap: 5px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#7aa2f7" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="transform: translateY(1px);"><path d="M12 4v14M6 13l6 6 6-6"></path></svg>
|
||||
<span style="font-size: 15px; font-weight: 700; color: #c0caf5;">{{d.down}}</span>
|
||||
<span style="font-size: 10px; color: #565f89;">Mbps</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: baseline; gap: 5px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#f7cf7a" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="transform: translateY(1px);"><path d="M12 20V6M6 11l6-6 6 6"></path></svg>
|
||||
<span style="font-size: 15px; font-weight: 700; color: #c0caf5;">{{d.up}}</span>
|
||||
<span style="font-size: 10px; color: #565f89;">Mbps</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 7px; height: 28px; padding: 0 12px; background: rgba(169,177,214,0.04); border: 1px solid rgba(169,177,214,0.4); color: #c0caf5; font-size: 11px;">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#c0caf5" stroke-width="1.6" stroke-linecap="round">
|
||||
<path d="M4 18a9 9 0 1 1 16 0"></path>
|
||||
<path d="M12 14.5 16.5 9"></path>
|
||||
</svg>
|
||||
Run test
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 1px; background: rgba(169,177,214,0.12); margin: 14px 0 10px 0;"></div>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<div style="font-size: 10px; color: #565f89;">{{d.outages}}</div>
|
||||
<div style="font-size: 10px; color: #565f89;">monitoring used 3.1 MB today</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-dc>
|
||||
|
||||
<script data-dc-script data-props='{"state":{"editor":"enum","options":["healthy","degraded","outage"],"default":"healthy","section":"Data"},"accent":{"editor":"color","default":"#7aa2f7","options":["#7aa2f7","#9ece6a","#e0af68","#bb9af7"],"section":"Theme"},"$preview":{"width":560,"height":720}}'>
|
||||
class Component extends DCLogic {
|
||||
rng(seed) {
|
||||
let s = seed >>> 0
|
||||
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296 }
|
||||
}
|
||||
|
||||
series(state) {
|
||||
var N = 72, rand = this.rng(20260824)
|
||||
var localBase = state === 'outage' ? 2.4 : (state === 'degraded' ? 2.1 : 1.2)
|
||||
var wanBase = state === 'outage' ? 120 : (state === 'degraded' ? 46 : 8.4)
|
||||
var local = [], wan = [], loss = []
|
||||
for (var i = 0; i < N; i++) {
|
||||
var l = localBase + rand() * localBase * 0.5
|
||||
var w = wanBase * (0.82 + rand() * 0.36)
|
||||
if (state === 'healthy' && (i === 23 || i === 24)) w = wanBase * 4.6
|
||||
if (state === 'degraded' && i % 11 === 4) w = wanBase * 2.2
|
||||
if (state === 'outage' && i > 46 && i < 60) { w = wanBase * 2.4; loss.push(i) }
|
||||
if (state === 'degraded' && (i === 31 || i === 52)) loss.push(i)
|
||||
local.push(l); wan.push(w)
|
||||
}
|
||||
return { local: local, wan: wan, loss: loss, N: N }
|
||||
}
|
||||
|
||||
chartFor(state) {
|
||||
var s = this.series(state)
|
||||
var W = 524, H = 96, top = 4
|
||||
var peak = 0
|
||||
for (var i = 0; i < s.N; i++) peak = Math.max(peak, s.local[i] + s.wan[i])
|
||||
peak = peak * 1.12
|
||||
var xAt = function (i) { return (i * (W - 1) / (s.N - 1)).toFixed(1) }
|
||||
var yAt = function (v) { return (H - (H - top) * v / peak).toFixed(1) }
|
||||
|
||||
var localLine = '', wanLine = '', totalPts = [], localPts = []
|
||||
for (var j = 0; j < s.N; j++) {
|
||||
var lp = xAt(j) + ',' + yAt(s.local[j])
|
||||
var tp = xAt(j) + ',' + yAt(s.local[j] + s.wan[j])
|
||||
localPts.push(lp); totalPts.push(tp)
|
||||
localLine += (j === 0 ? 'M' : 'L') + lp
|
||||
wanLine += (j === 0 ? 'M' : 'L') + tp
|
||||
}
|
||||
var localArea = 'M0,' + H + 'L' + localPts.join('L') + 'L' + W + ',' + H + 'Z'
|
||||
var wanArea = 'M' + totalPts.join('L') + 'L' + localPts.slice().reverse().join('L') + 'Z'
|
||||
var ticks = s.loss.map(function (i) { return { x: xAt(i) } })
|
||||
return { localLine: localLine, wanLine: wanLine, localArea: localArea, wanArea: wanArea, loss: ticks }
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
var accent = this.props.accent ?? '#7aa2f7'
|
||||
var state = this.props.state ?? 'healthy'
|
||||
var GOOD = '#9ece6a', WARN = '#e0af68', BAD = '#f7768e'
|
||||
var table = {
|
||||
healthy: {
|
||||
index: 94, verdict: 'EXCELLENT', tone: GOOD, uptime: 'ONLINE 4H 12M',
|
||||
localMs: '1.2', wanMs: '8.4', localTone: GOOD, wanTone: GOOD,
|
||||
lagBest: '7', lagTypical: '11', lagWorst: '41', hasRec: false, rec: '',
|
||||
resp: 96, respTone: GOOD, respPct: '96%', respNote: 'lag 11 ms typical',
|
||||
rel: 99, relTone: GOOD, relPct: '99%', relNote: 'no disruptions in 24 h',
|
||||
spd: 88, spdTone: GOOD, spdPct: '88%', spdNote: '92% of your 450/50 plan',
|
||||
stats: 'p50 9 ms · p95 14 ms · jitter 1.8 ms',
|
||||
down: '412', up: '48', testAge: '2 H AGO', outages: 'no outages today'
|
||||
},
|
||||
degraded: {
|
||||
index: 61, verdict: 'DEGRADED', tone: WARN, uptime: 'ONLINE 41M',
|
||||
localMs: '2.1', wanMs: '46', localTone: GOOD, wanTone: WARN,
|
||||
lagBest: '34', lagTypical: '58', lagWorst: '210', hasRec: true,
|
||||
rec: 'Your local leg is clean at 2 ms \u2014 the 46 ms is upstream of the router, so moving the laptop or changing channel will not help. Lag has been above 40 ms for 26 minutes; this is worth raising with Excitel.',
|
||||
resp: 44, respTone: BAD, respPct: '44%', respNote: 'lag 58 ms typical',
|
||||
rel: 82, relTone: WARN, relPct: '82%', relNote: '2 disruptions today',
|
||||
spd: 71, spdTone: WARN, spdPct: '71%', spdNote: '318 Mbps, below plan',
|
||||
stats: 'p50 48 ms · p95 121 ms · jitter 19 ms',
|
||||
down: '318', up: '41', testAge: '18 MIN AGO', outages: '2 brief outages today'
|
||||
},
|
||||
outage: {
|
||||
index: 12, verdict: 'NO INTERNET', tone: BAD, uptime: 'WAN DOWN 2M 14S',
|
||||
localMs: '2.4', wanMs: '--', localTone: GOOD, wanTone: BAD,
|
||||
lagBest: '--', lagTypical: '--', lagWorst: '--', hasRec: true,
|
||||
rec: 'The router answers in 2.4 ms but nothing past it replies. Your Wi-Fi is fine \u2014 the fault is on the Excitel side or the line itself. Started 2 min 14 s ago.',
|
||||
resp: 0, respTone: BAD, respPct: '4%', respNote: 'no reply past the router',
|
||||
rel: 21, relTone: BAD, relPct: '21%', relNote: 'outage in progress',
|
||||
spd: 0, spdTone: BAD, spdPct: '3%', spdNote: 'last known 412 Mbps',
|
||||
stats: 'router reachable · wan unreachable',
|
||||
down: '412', up: '48', testAge: '3 H AGO', outages: '1 outage today, ongoing'
|
||||
}
|
||||
}
|
||||
var d = table[state] || table.healthy
|
||||
return { d: d, accent: accent, chart: this.chartFor(state) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,183 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap">
|
||||
<style>
|
||||
body { margin: 0; background: #0e0e14; font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
a { color: #7aa2f7; } a:hover { color: #b4bee6; }
|
||||
.cap { font-size: 10px; letter-spacing: 0.12em; color: #565f89; }
|
||||
.tab { flex-grow: 1; height: 26px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: #a9b1d6; background: rgba(169,177,214,0.04); border: 1px solid rgba(169,177,214,0.4); }
|
||||
.tabon { flex-grow: 1; height: 26px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: #c0caf5; background: rgba(169,177,214,0.18); border: 1px solid transparent; }
|
||||
.sep { height: 1px; background: rgba(169,177,214,0.12); }
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<div style="display: flex; flex-direction: column; width: 560px; background: #1a1b26; border: 2px solid #7aa2f7; box-sizing: border-box; padding: 18px; color: #a9b1d6;">
|
||||
|
||||
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;">
|
||||
<div style="display: flex; align-items: flex-start; gap: 10px;">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#9ece6a" stroke-width="1.6" stroke-linecap="round" style="margin-top: 2px;">
|
||||
<path d="M2 8.5a15 15 0 0 1 20 0"></path><path d="M5 12.2a10.5 10.5 0 0 1 14 0"></path><path d="M8.2 15.9a6 6 0 0 1 7.6 0"></path><circle cx="12" cy="19.3" r="1.1" fill="#9ece6a" stroke="none"></circle>
|
||||
</svg>
|
||||
<div style="display: flex; flex-direction: column; gap: 3px;">
|
||||
<div style="font-size: 16px; font-weight: 700; color: #c0caf5;">Excitel</div>
|
||||
<div class="cap" style="letter-spacing: 0.1em;">EXCELLENT · ONLINE 4H 12M</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: 1px;">
|
||||
<div style="font-size: 30px; font-weight: 700; line-height: 1; color: #9ece6a;">94</div>
|
||||
<div class="cap">EXPERIENCE</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 6px; margin-top: 16px;">
|
||||
<div class="tab">Overview</div><div class="tab">Latency</div><div class="tabon">Speed</div><div class="tab">Wi-Fi</div><div class="tab">Events</div>
|
||||
</div>
|
||||
|
||||
<div class="sep" style="margin: 16px 0 14px 0;"></div>
|
||||
|
||||
<div class="cap" style="margin-bottom: 9px;">LIVE THROUGHPUT · LAST 3 MIN</div>
|
||||
|
||||
<svg width="524" height="118" viewBox="0 0 524 118" style="display: block;">
|
||||
<path d="{{c.rxArea}}" fill="rgba(122,162,247,0.20)"></path>
|
||||
<path d="{{c.rxLine}}" fill="none" stroke="#7aa2f7" stroke-width="1.4" stroke-linejoin="round"></path>
|
||||
<path d="{{c.txArea}}" fill="rgba(247,207,122,0.18)"></path>
|
||||
<path d="{{c.txLine}}" fill="none" stroke="#f7cf7a" stroke-width="1.4" stroke-linejoin="round"></path>
|
||||
<line x1="0" y1="59" x2="524" y2="59" stroke="rgba(169,177,214,0.22)" stroke-width="1"></line>
|
||||
</svg>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px 14px; margin-top: 14px;">
|
||||
<div class="cap">RECEIVING</div>
|
||||
<div class="cap">SENDING</div>
|
||||
<div class="cap">DOWNLOADED</div>
|
||||
<div class="cap">UPLOADED</div>
|
||||
<div style="font-size: 15px; color: #7aa2f7; font-weight: 500;">4.0 <span style="font-size: 10px; color: #565f89;">MB/s</span></div>
|
||||
<div style="font-size: 15px; color: #f7cf7a; font-weight: 500;">2.2 <span style="font-size: 10px; color: #565f89;">MB/s</span></div>
|
||||
<div style="font-size: 15px; color: #c0caf5; font-weight: 500;">1.05 <span style="font-size: 10px; color: #565f89;">GB</span></div>
|
||||
<div style="font-size: 15px; color: #c0caf5; font-weight: 500;">416 <span style="font-size: 10px; color: #565f89;">MB</span></div>
|
||||
</div>
|
||||
|
||||
<div class="sep" style="margin: 16px 0 12px 0;"></div>
|
||||
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 9px;">
|
||||
<div class="cap">CONTENT SPEED · FEEDS YOUR SCORE</div>
|
||||
<div style="font-size: 10px; color: #565f89;">hourly · ~15 MB per check</div>
|
||||
</div>
|
||||
|
||||
<svg width="524" height="86" viewBox="0 0 524 86" style="display: block;">
|
||||
<line x1="0" y1="21.5" x2="524" y2="21.5" stroke="rgba(169,177,214,0.07)"></line>
|
||||
<line x1="0" y1="47.5" x2="524" y2="47.5" stroke="rgba(169,177,214,0.07)"></line>
|
||||
<line x1="0" y1="73.5" x2="524" y2="73.5" stroke="rgba(169,177,214,0.16)"></line>
|
||||
<sc-for list="{{c.tests}}" as="t" hint-placeholder-count="12">
|
||||
<rect x="{{t.dx}}" y="{{t.dy}}" width="14" height="{{t.dh}}" fill="#7aa2f7"></rect>
|
||||
<rect x="{{t.ux}}" y="{{t.uy}}" width="8" height="{{t.uh}}" fill="#f7cf7a"></rect>
|
||||
</sc-for>
|
||||
</svg>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 6px;">
|
||||
<div style="display: flex; align-items: center; gap: 14px;">
|
||||
<div style="display: flex; align-items: center; gap: 6px;"><div style="width: 9px; height: 9px; background: #7aa2f7;"></div><div style="font-size: 10px; color: #565f89;">download</div></div>
|
||||
<div style="display: flex; align-items: center; gap: 6px;"><div style="width: 9px; height: 9px; background: #f7cf7a;"></div><div style="font-size: 10px; color: #565f89;">upload</div></div>
|
||||
</div>
|
||||
<div style="font-size: 10px; color: #565f89;">plan 450 / 50 Mbps</div>
|
||||
</div>
|
||||
|
||||
<div class="sep" style="margin: 16px 0 12px 0;"></div>
|
||||
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 10px;">
|
||||
<div class="cap">PEAK SPEED · LAST RUN 24 AUG 12:04</div>
|
||||
<div style="font-size: 10px; color: #565f89;">informational, not scored</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 24px;">
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between;"><span style="font-size: 11px; color: #565f89;">Download</span><span style="font-size: 11px; color: #c0caf5;">412.3 Mbps</span></div>
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between;"><span style="font-size: 11px; color: #565f89;">Upload</span><span style="font-size: 11px; color: #c0caf5;">48.1 Mbps</span></div>
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between;"><span style="font-size: 11px; color: #565f89;">Idle ping</span><span style="font-size: 11px; color: #c0caf5;">9 ms</span></div>
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between;"><span style="font-size: 11px; color: #565f89;">Loaded ping</span><span style="font-size: 11px; color: #e0af68;">34 ms</span></div>
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between;"><span style="font-size: 11px; color: #565f89;">Jitter</span><span style="font-size: 11px; color: #c0caf5;">1.8 ms</span></div>
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between;"><span style="font-size: 11px; color: #565f89;">Data used</span><span style="font-size: 11px; color: #c0caf5;">287 MB</span></div>
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between;"><span style="font-size: 11px; color: #565f89;">Server</span><span style="font-size: 11px; color: #c0caf5;">Delhi · 18 km</span></div>
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between;"><span style="font-size: 11px; color: #565f89;">Engine</span><span style="font-size: 11px; color: #c0caf5;">Ookla</span></div>
|
||||
</div>
|
||||
|
||||
<div class="sep" style="margin: 14px 0 12px 0;"></div>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px;">
|
||||
<div style="display: flex; flex-direction: column; gap: 5px;">
|
||||
<div class="cap">PEAK TEST ENGINE</div>
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<div style="height: 22px; padding: 0 10px; display: flex; align-items: center; font-size: 10px; color: #c0caf5; background: rgba(169,177,214,0.18);">Ookla</div>
|
||||
<div style="height: 22px; padding: 0 10px; display: flex; align-items: center; font-size: 10px; color: #a9b1d6; background: rgba(169,177,214,0.04); border: 1px solid rgba(169,177,214,0.4);">Cloudflare</div>
|
||||
<div style="height: 22px; padding: 0 10px; display: flex; align-items: center; font-size: 10px; color: #a9b1d6; background: rgba(169,177,214,0.04); border: 1px solid rgba(169,177,214,0.4);">fast.com</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 7px; height: 28px; padding: 0 12px; background: rgba(169,177,214,0.04); border: 1px solid rgba(169,177,214,0.4); color: #c0caf5; font-size: 11px;">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#c0caf5" stroke-width="1.6" stroke-linecap="round"><path d="M4 18a9 9 0 1 1 16 0"></path><path d="M12 14.5 16.5 9"></path></svg>
|
||||
Run test
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 10px; color: #565f89; margin-top: 11px; line-height: 1.6;">A peak test sizes itself to saturate the line for about 10 s each way — up to ~600 MB on a fast one, far less on a slow one — so it only ever runs when you ask for it. The hourly content check above is what keeps the score honest, at a fraction of the data.</div>
|
||||
|
||||
</div>
|
||||
</x-dc>
|
||||
|
||||
<script data-dc-script data-props='{"$preview":{"width":560,"height":800}}'>
|
||||
class Component extends DCLogic {
|
||||
rng(seed) { let s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296 } }
|
||||
|
||||
renderVals() {
|
||||
var N = 90, rand = this.rng(4242)
|
||||
var W = 524, mid = 59, up = 55, down = 55
|
||||
var rx = [], tx = []
|
||||
for (var i = 0; i < N; i++) {
|
||||
var r = 0.25 + rand() * 0.3
|
||||
if (i > 20 && i < 46) r = 0.55 + rand() * 0.42
|
||||
if (i === 33 || i === 34) r = 1
|
||||
rx.push(r)
|
||||
tx.push(0.12 + rand() * 0.22 + (i > 58 && i < 70 ? 0.45 : 0))
|
||||
}
|
||||
var xAt = function (i) { return (i * (W - 1) / (N - 1)).toFixed(1) }
|
||||
var rxPts = [], txPts = [], rxLine = '', txLine = ''
|
||||
for (var j = 0; j < N; j++) {
|
||||
var a = xAt(j) + ',' + (mid - up * rx[j]).toFixed(1)
|
||||
var b = xAt(j) + ',' + (mid + down * tx[j]).toFixed(1)
|
||||
rxPts.push(a); txPts.push(b)
|
||||
rxLine += (j === 0 ? 'M' : 'L') + a
|
||||
txLine += (j === 0 ? 'M' : 'L') + b
|
||||
}
|
||||
|
||||
var vals = [
|
||||
[412, 48], [404, 47], [388, 46], [412, 48], [318, 41], [396, 47],
|
||||
[421, 49], [402, 48], [255, 38], [408, 47], [415, 49], [412, 48]
|
||||
]
|
||||
var maxV = 450, base = 73, plot = 66, slot = W / vals.length
|
||||
var tests = vals.map(function (v, i) {
|
||||
var dh = Math.max(2, Math.round(plot * v[0] / maxV))
|
||||
var uh = Math.max(2, Math.round(plot * v[1] / maxV * 3.2))
|
||||
var left = i * slot + (slot - 26) / 2
|
||||
return {
|
||||
dx: left.toFixed(1), dy: (base - dh).toFixed(1), dh: dh,
|
||||
ux: (left + 17).toFixed(1), uy: (base - uh).toFixed(1), uh: uh
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
c: {
|
||||
rxLine: rxLine, txLine: txLine,
|
||||
rxArea: 'M0,' + mid + 'L' + rxPts.join('L') + 'L' + W + ',' + mid + 'Z',
|
||||
txArea: 'M0,' + mid + 'L' + txPts.join('L') + 'L' + W + ',' + mid + 'Z',
|
||||
tests: tests
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,137 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap">
|
||||
<style>
|
||||
body { margin: 0; background: #0a0a0d; font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
a { color: #7aa2f7; } a:hover { color: #b4bee6; }
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<div style="position: relative; width: 1000px; height: 560px; background: #000000; overflow: hidden;">
|
||||
<div style="position: absolute; inset: 0; background: rgba(10,10,14,0.78);"></div>
|
||||
|
||||
<div style="position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 0;">
|
||||
|
||||
<div style="font-size: 11px; letter-spacing: 0.42em; color: rgba(255,255,255,0.55); margin-bottom: 22px;">EXCITEL</div>
|
||||
|
||||
<div style="display: flex; align-items: flex-start; gap: 78px;">
|
||||
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 14px;">
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" style="display: block;">
|
||||
<defs>
|
||||
<linearGradient id="ndl-down" gradientUnits="userSpaceOnUse" x1="{{g.down.nx1}}" y1="{{g.down.ny1}}" x2="{{g.down.nx2}}" y2="{{g.down.ny2}}">
|
||||
<stop offset="0" stop-color="rgba(255,255,255,0.10)"></stop>
|
||||
<stop offset="1" stop-color="#7aa2f7"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="{{g.arc}}" fill="none" stroke="rgba(255,255,255,0.13)" stroke-width="2" stroke-linecap="round"></path>
|
||||
<sc-for list="{{g.ticks}}" as="t" hint-placeholder-count="41">
|
||||
<line x1="{{t.x1}}" y1="{{t.y1}}" x2="{{t.x2}}" y2="{{t.y2}}" stroke="{{t.stroke}}" stroke-width="{{t.w}}"></line>
|
||||
</sc-for>
|
||||
<line x1="{{g.down.nx1}}" y1="{{g.down.ny1}}" x2="{{g.down.nx2}}" y2="{{g.down.ny2}}" stroke="url(#ndl-down)" stroke-width="3.2" stroke-linecap="round"></line>
|
||||
<text x="150" y="164" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="42" font-weight="700" fill="#ffffff">412.3</text>
|
||||
<text x="150" y="184" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="12" fill="rgba(255,255,255,0.55)">Mbps</text>
|
||||
</svg>
|
||||
<div style="font-size: 11px; letter-spacing: 0.3em; color: rgba(255,255,255,0.9);">DOWNLOAD</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 14px;">
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" style="display: block;">
|
||||
<defs>
|
||||
<linearGradient id="ndl-up" gradientUnits="userSpaceOnUse" x1="{{g.up.nx1}}" y1="{{g.up.ny1}}" x2="{{g.up.nx2}}" y2="{{g.up.ny2}}">
|
||||
<stop offset="0" stop-color="rgba(255,255,255,0.10)"></stop>
|
||||
<stop offset="1" stop-color="#7aa2f7"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="{{g.arc}}" fill="none" stroke="rgba(255,255,255,0.13)" stroke-width="2" stroke-linecap="round"></path>
|
||||
<sc-for list="{{g.ticks}}" as="t" hint-placeholder-count="41">
|
||||
<line x1="{{t.x1}}" y1="{{t.y1}}" x2="{{t.x2}}" y2="{{t.y2}}" stroke="{{t.stroke}}" stroke-width="{{t.w}}"></line>
|
||||
</sc-for>
|
||||
<line x1="{{g.up.nx1}}" y1="{{g.up.ny1}}" x2="{{g.up.nx2}}" y2="{{g.up.ny2}}" stroke="url(#ndl-up)" stroke-width="3.2" stroke-linecap="round"></line>
|
||||
<text x="150" y="164" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="42" font-weight="700" fill="#ffffff">48.1</text>
|
||||
<text x="150" y="184" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="12" fill="rgba(255,255,255,0.55)">Mbps</text>
|
||||
</svg>
|
||||
<div style="font-size: 11px; letter-spacing: 0.3em; color: rgba(255,255,255,0.55);">UPLOAD</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 26px; margin-top: 30px; padding-top: 18px; border-top: 1px solid rgba(255,255,255,0.10);">
|
||||
<div style="display: flex; align-items: baseline; gap: 8px;">
|
||||
<span style="font-size: 10px; letter-spacing: 0.16em; color: rgba(255,255,255,0.4);">IDLE</span>
|
||||
<span style="font-size: 14px; color: rgba(255,255,255,0.9);">9 ms</span>
|
||||
</div>
|
||||
<div style="width: 26px; height: 1px; background: rgba(255,255,255,0.2);"></div>
|
||||
<div style="display: flex; align-items: baseline; gap: 8px;">
|
||||
<span style="font-size: 10px; letter-spacing: 0.16em; color: rgba(255,255,255,0.4);">UNDER LOAD</span>
|
||||
<span style="font-size: 14px; color: #ffc678;">34 ms</span>
|
||||
</div>
|
||||
<div style="width: 26px; height: 1px; background: rgba(255,255,255,0.2);"></div>
|
||||
<div style="display: flex; align-items: baseline; gap: 8px;">
|
||||
<span style="font-size: 10px; letter-spacing: 0.16em; color: rgba(255,255,255,0.4);">BUFFERBLOAT</span>
|
||||
<span style="font-size: 14px; color: #a9e88a;">A−</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="position: absolute; left: 26px; bottom: 22px; font-size: 10px; letter-spacing: 0.14em; color: rgba(255,255,255,0.28);">SCALE 500 Mbps · OOKLA · DELHI</div>
|
||||
<div style="position: absolute; right: 26px; bottom: 22px; font-size: 10px; letter-spacing: 0.14em; color: rgba(255,255,255,0.28);">ENTER TO RUN AGAIN · ESC TO CLOSE</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
|
||||
<script data-dc-script data-props='{"$preview":{"width":1000,"height":560}}'>
|
||||
class Component extends DCLogic {
|
||||
polar(cx, cy, r, deg) {
|
||||
var t = deg * Math.PI / 180
|
||||
return { x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) }
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
var cx = 150, cy = 152, R = 120
|
||||
var START = 135, SWEEP = 270
|
||||
var a0 = this.polar(cx, cy, R, START)
|
||||
var a1 = this.polar(cx, cy, R, START + SWEEP)
|
||||
var arc = 'M' + a0.x.toFixed(2) + ',' + a0.y.toFixed(2) +
|
||||
'A' + R + ',' + R + ' 0 1 1 ' + a1.x.toFixed(2) + ',' + a1.y.toFixed(2)
|
||||
|
||||
var ticks = []
|
||||
for (var i = 0; i <= 40; i++) {
|
||||
var deg = START + SWEEP * i / 40
|
||||
var major = i % 5 === 0
|
||||
var inner = major ? R - 17 : R - 9
|
||||
var p1 = this.polar(cx, cy, R - 3, deg)
|
||||
var p2 = this.polar(cx, cy, inner, deg)
|
||||
ticks.push({
|
||||
x1: p1.x.toFixed(2), y1: p1.y.toFixed(2),
|
||||
x2: p2.x.toFixed(2), y2: p2.y.toFixed(2),
|
||||
stroke: major ? 'rgba(255,255,255,0.42)' : 'rgba(255,255,255,0.16)',
|
||||
w: major ? 2 : 1
|
||||
})
|
||||
}
|
||||
|
||||
var self = this
|
||||
function needle(value, scale) {
|
||||
var frac = Math.max(0, Math.min(1, value / scale))
|
||||
var deg = START + SWEEP * frac
|
||||
var hub = self.polar(cx, cy, 24, deg)
|
||||
var tip = self.polar(cx, cy, R - 26, deg)
|
||||
return {
|
||||
nx1: hub.x.toFixed(2), ny1: hub.y.toFixed(2),
|
||||
nx2: tip.x.toFixed(2), ny2: tip.y.toFixed(2)
|
||||
}
|
||||
}
|
||||
|
||||
return { g: { arc: arc, ticks: ticks, down: needle(412.3, 500), up: needle(48.1, 500) } }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,188 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap">
|
||||
<style>
|
||||
body { margin: 0; background: #0e0e14; font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
a { color: #7aa2f7; } a:hover { color: #b4bee6; }
|
||||
.cap { font-size: 10px; letter-spacing: 0.12em; color: #565f89; }
|
||||
.tab { flex-grow: 1; height: 26px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: #a9b1d6; background: rgba(169,177,214,0.04); border: 1px solid rgba(169,177,214,0.4); }
|
||||
.tabon { flex-grow: 1; height: 26px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: #c0caf5; background: rgba(169,177,214,0.18); border: 1px solid transparent; }
|
||||
.sep { height: 1px; background: rgba(169,177,214,0.12); }
|
||||
.kv { display: flex; align-items: baseline; justify-content: space-between; }
|
||||
.k { font-size: 11px; color: #565f89; }
|
||||
.v { font-size: 11px; color: #c0caf5; }
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<div style="display: flex; flex-direction: column; width: 560px; background: #1a1b26; border: 2px solid #7aa2f7; box-sizing: border-box; padding: 18px; color: #a9b1d6;">
|
||||
|
||||
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;">
|
||||
<div style="display: flex; align-items: flex-start; gap: 10px;">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#9ece6a" stroke-width="1.6" stroke-linecap="round" style="margin-top: 2px;">
|
||||
<path d="M2 8.5a15 15 0 0 1 20 0"></path><path d="M5 12.2a10.5 10.5 0 0 1 14 0"></path><path d="M8.2 15.9a6 6 0 0 1 7.6 0"></path><circle cx="12" cy="19.3" r="1.1" fill="#9ece6a" stroke="none"></circle>
|
||||
</svg>
|
||||
<div style="display: flex; flex-direction: column; gap: 3px;">
|
||||
<div style="font-size: 16px; font-weight: 700; color: #c0caf5;">Excitel</div>
|
||||
<div class="cap" style="letter-spacing: 0.1em;">EXCELLENT · ONLINE 4H 12M</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: 1px;">
|
||||
<div style="font-size: 30px; font-weight: 700; line-height: 1; color: #9ece6a;">94</div>
|
||||
<div class="cap">EXPERIENCE</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 6px; margin-top: 16px;">
|
||||
<div class="tab">Overview</div><div class="tab">Latency</div><div class="tab">Speed</div><div class="tabon">Wi-Fi</div><div class="tab">Events</div>
|
||||
</div>
|
||||
|
||||
<div class="sep" style="margin: 16px 0 14px 0;"></div>
|
||||
|
||||
<div class="cap" style="margin-bottom: 11px;">THE LOCAL LEG · LAPTOP TO ROUTER</div>
|
||||
|
||||
<div style="display: flex; align-items: flex-end; gap: 22px;">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px;">
|
||||
<div style="font-size: 28px; font-weight: 700; line-height: 1; color: #9ece6a;">−47<span style="font-size: 12px; color: #565f89; font-weight: 400;"> dBm</span></div>
|
||||
<div class="cap" style="letter-spacing: 0.1em;">SIGNAL</div>
|
||||
</div>
|
||||
<div style="flex-grow: 1; display: flex; flex-direction: column; gap: 6px; padding-bottom: 3px;">
|
||||
<div style="position: relative; height: 8px; background: rgba(169,177,214,0.10);">
|
||||
<div style="height: 8px; width: 78%; background: #9ece6a;"></div>
|
||||
<div style="position: absolute; left: 55%; top: -3px; width: 1px; height: 14px; background: rgba(169,177,214,0.35);"></div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<div style="font-size: 9px; color: #414868;">−90 unusable</div>
|
||||
<div style="font-size: 9px; color: #414868;">−67 marginal</div>
|
||||
<div style="font-size: 9px; color: #414868;">−30 max</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between; margin: 16px 0 6px 0;">
|
||||
<div class="cap">SIGNAL & LOCAL LAG · LAST 30 MIN</div>
|
||||
<div style="font-size: 10px; color: #565f89;">one roam</div>
|
||||
</div>
|
||||
|
||||
<svg width="524" height="82" viewBox="0 0 524 82" style="display: block;">
|
||||
<line x1="0" y1="81.5" x2="524" y2="81.5" stroke="rgba(169,177,214,0.16)"></line>
|
||||
<line x1="{{c.roamX}}" y1="0" x2="{{c.roamX}}" y2="82" stroke="rgba(187,154,247,0.5)" stroke-width="1" stroke-dasharray="2 3"></line>
|
||||
<path d="{{c.sigArea}}" fill="rgba(158,206,106,0.16)"></path>
|
||||
<path d="{{c.sigLine}}" fill="none" stroke="#9ece6a" stroke-width="1.4" stroke-linejoin="round"></path>
|
||||
<path d="{{c.lagLine}}" fill="none" stroke="#565f89" stroke-width="1" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 16px; margin-top: 6px;">
|
||||
<div style="display: flex; align-items: center; gap: 6px;"><div style="width: 10px; height: 2px; background: #9ece6a;"></div><div style="font-size: 10px; color: #565f89;">signal</div></div>
|
||||
<div style="display: flex; align-items: center; gap: 6px;"><div style="width: 10px; height: 2px; background: #565f89;"></div><div style="font-size: 10px; color: #565f89;">local lag</div></div>
|
||||
<div style="display: flex; align-items: center; gap: 6px;"><div style="width: 1px; height: 9px; background: #bb9af7;"></div><div style="font-size: 10px; color: #565f89;">roamed to a stronger AP</div></div>
|
||||
</div>
|
||||
|
||||
<div class="sep" style="margin: 16px 0 12px 0;"></div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 24px;">
|
||||
<div class="kv"><span class="k">Band</span><span class="v">5 GHz</span></div>
|
||||
<div class="kv"><span class="k">Channel</span><span class="v">44 · 80 MHz</span></div>
|
||||
<div class="kv"><span class="k">Tx rate</span><span class="v">866 Mbps</span></div>
|
||||
<div class="kv"><span class="k">Rx rate</span><span class="v">780 Mbps</span></div>
|
||||
<div class="kv"><span class="k">Standard</span><span class="v">802.11ax</span></div>
|
||||
<div class="kv"><span class="k">Security</span><span class="v">WPA2</span></div>
|
||||
<div class="kv"><span class="k">BSSID</span><span class="v">c8:3a:35:4f:12:80</span></div>
|
||||
<div class="kv"><span class="k">Interface</span><span class="v">wlan0</span></div>
|
||||
</div>
|
||||
|
||||
<div class="sep" style="margin: 14px 0 12px 0;"></div>
|
||||
|
||||
<div class="cap" style="margin-bottom: 10px;">AIRTIME HEALTH · WHY WI-FI FEELS SLOW WHEN SIGNAL LOOKS FINE</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px;">
|
||||
<div style="display: flex; flex-direction: column; gap: 5px;">
|
||||
<div style="font-size: 18px; font-weight: 700; line-height: 1; color: #9ece6a;">1.2 %</div>
|
||||
<div class="cap" style="letter-spacing: 0.08em;">TX RETRIES</div>
|
||||
<div style="height: 3px; background: rgba(169,177,214,0.12);"><div style="height: 3px; width: 12%; background: #9ece6a;"></div></div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 5px;">
|
||||
<div style="font-size: 18px; font-weight: 700; line-height: 1; color: #e0af68;">7</div>
|
||||
<div class="cap" style="letter-spacing: 0.08em;">TX FAILED</div>
|
||||
<div style="height: 3px; background: rgba(169,177,214,0.12);"><div style="height: 3px; width: 26%; background: #e0af68;"></div></div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 5px;">
|
||||
<div style="font-size: 18px; font-weight: 700; line-height: 1; color: #9ece6a;">0</div>
|
||||
<div class="cap" style="letter-spacing: 0.08em;">BEACON LOSS</div>
|
||||
<div style="height: 3px; background: rgba(169,177,214,0.12);"><div style="height: 3px; width: 3%; background: #9ece6a;"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sep" style="margin: 14px 0 12px 0;"></div>
|
||||
|
||||
<div class="cap" style="margin-bottom: 10px;">LINK EVENTS</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div style="display: flex; align-items: baseline; gap: 10px;">
|
||||
<div style="font-size: 10px; color: #565f89; width: 46px; flex-shrink: 0;">12:41</div>
|
||||
<div style="width: 5px; height: 5px; background: #bb9af7; flex-shrink: 0; transform: translateY(-1px);"></div>
|
||||
<div style="font-size: 11px; color: #a9b1d6; flex-grow: 1;">Roamed to c8:3a:35:4f:12:80, channel 149 → 44</div>
|
||||
<div style="font-size: 10px; color: #565f89;">−61 → −47 dBm</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: baseline; gap: 10px;">
|
||||
<div style="font-size: 10px; color: #565f89; width: 46px; flex-shrink: 0;">11:58</div>
|
||||
<div style="width: 5px; height: 5px; background: #e0af68; flex-shrink: 0; transform: translateY(-1px);"></div>
|
||||
<div style="font-size: 11px; color: #a9b1d6; flex-grow: 1;">Rate dropped to 173 Mbps for 40 s</div>
|
||||
<div style="font-size: 10px; color: #565f89;">microwave band</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: baseline; gap: 10px;">
|
||||
<div style="font-size: 10px; color: #565f89; width: 46px; flex-shrink: 0;">08:32</div>
|
||||
<div style="width: 5px; height: 5px; background: #9ece6a; flex-shrink: 0; transform: translateY(-1px);"></div>
|
||||
<div style="font-size: 11px; color: #a9b1d6; flex-grow: 1;">Associated with Excitel after wake</div>
|
||||
<div style="font-size: 10px; color: #565f89;">1.4 s to online</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-dc>
|
||||
|
||||
<script data-dc-script data-props='{"$preview":{"width":560,"height":800}}'>
|
||||
class Component extends DCLogic {
|
||||
rng(seed) { let s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296 } }
|
||||
|
||||
renderVals() {
|
||||
var N = 100, rand = this.rng(9111), W = 524, H = 82
|
||||
var roam = 62
|
||||
var sig = [], lag = []
|
||||
for (var i = 0; i < N; i++) {
|
||||
var base = i < roam ? -61 : -47
|
||||
sig.push(base + (rand() - 0.5) * 4)
|
||||
lag.push(1.2 + rand() * 1.1 + (i < roam ? 0.8 : 0))
|
||||
}
|
||||
var lo = -80, hi = -35
|
||||
var xAt = function (i) { return (i * (W - 1) / (N - 1)).toFixed(1) }
|
||||
var sy = function (v) { return (H - (H - 6) * (v - lo) / (hi - lo)).toFixed(1) }
|
||||
var ly = function (v) { return (H - (H - 6) * v / 8).toFixed(1) }
|
||||
|
||||
var sp = [], sigLine = '', lagLine = ''
|
||||
for (var j = 0; j < N; j++) {
|
||||
var a = xAt(j) + ',' + sy(sig[j])
|
||||
sp.push(a)
|
||||
sigLine += (j === 0 ? 'M' : 'L') + a
|
||||
lagLine += (j === 0 ? 'M' : 'L') + xAt(j) + ',' + ly(lag[j])
|
||||
}
|
||||
|
||||
return {
|
||||
c: {
|
||||
sigLine: sigLine,
|
||||
sigArea: 'M0,' + H + 'L' + sp.join('L') + 'L' + W + ',' + H + 'Z',
|
||||
lagLine: lagLine,
|
||||
roamX: xAt(roam)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
@@ -0,0 +1,31 @@
|
||||
// Byte and byte-rate formatting, shared by every surface that shows either.
|
||||
//
|
||||
// This existed five times — Overview's throughput pair, Speed's readouts and
|
||||
// its chart's own copy, Apps' per-app rates and session totals — as the same
|
||||
// tier ladder with small accidental differences: only one copy had a GB/s
|
||||
// tier, and the two byte formatters rounded megabytes to different numbers of
|
||||
// decimals. One copy means the next surface cannot inherit a stale variant,
|
||||
// which is the lesson readout.js already taught this plugin.
|
||||
//
|
||||
// Deliberately plain JS with no Qt calls, so it needs no QML context.
|
||||
|
||||
/** A byte rate, e.g. 1.4 MB/s. Non-finite or absent reads as "--". */
|
||||
function rate(bps) {
|
||||
if (bps === null || bps === undefined || !isFinite(bps)) return "--"
|
||||
if (bps >= 1e9) return (bps / 1e9).toFixed(2) + " GB/s"
|
||||
if (bps >= 1e6) return (bps / 1e6).toFixed(1) + " MB/s"
|
||||
if (bps >= 1e3) return (bps / 1e3).toFixed(1) + " KB/s"
|
||||
return Math.round(bps) + " B/s"
|
||||
}
|
||||
|
||||
/**
|
||||
* A byte total, e.g. 1.4 MB. Absent reads as "--"; zero reads as "0 B",
|
||||
* because a counter that has genuinely moved nothing is a fact, not a gap.
|
||||
*/
|
||||
function bytes(n) {
|
||||
if (n === null || n === undefined || !isFinite(n)) return "--"
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(2) + " GB"
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + " MB"
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(0) + " KB"
|
||||
return Math.round(n) + " B"
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "io.github.x3me.nexthop",
|
||||
"name": "Nexthop",
|
||||
"version": "0.2.42",
|
||||
"author": "Extreme Labs",
|
||||
"license": "MIT",
|
||||
"description": "Splits your Wi-Fi from your ISP, hop by hop. Continuous latency, jitter and loss on both legs of your connection, with history, outage detection and speed tests.",
|
||||
"homepage": "https://github.com/x3me/omarchy-nexthop",
|
||||
"repository": "https://github.com/x3me/omarchy-nexthop",
|
||||
"keywords": [
|
||||
"network",
|
||||
"internet",
|
||||
"latency",
|
||||
"jitter",
|
||||
"monitoring",
|
||||
"speedtest",
|
||||
"wifi"
|
||||
],
|
||||
"kinds": [
|
||||
"bar-widget",
|
||||
"service"
|
||||
],
|
||||
"entryPoints": {
|
||||
"barWidget": "BarWidget.qml",
|
||||
"service": "Service.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Nexthop",
|
||||
"description": "Connection quality on the bar; click for latency, speed, Wi-Fi and outage history split by leg",
|
||||
"category": "Network",
|
||||
"allowMultiple": false,
|
||||
"defaultSection": "right",
|
||||
"defaults": {
|
||||
"displayMode": "Index",
|
||||
"internetAnchor": "1.1.1.1",
|
||||
"probeIntervalMs": 500,
|
||||
"contentSpeed": true,
|
||||
"contentSpeedIntervalMin": 60,
|
||||
"peakEngine": "Auto",
|
||||
"planDownMbps": 0,
|
||||
"planUpMbps": 0,
|
||||
"notifyOutage": true,
|
||||
"historyDays": 7,
|
||||
"throughputWindowS": 3,
|
||||
"updateCheck": true,
|
||||
"meteredCare": true
|
||||
},
|
||||
"schema": [
|
||||
{
|
||||
"key": "displayMode",
|
||||
"type": "enum",
|
||||
"label": "What the bar shows",
|
||||
"options": [
|
||||
"Index",
|
||||
"Lag",
|
||||
"Icon only"
|
||||
],
|
||||
"defaultValue": "Index",
|
||||
"description": "Index is the 0-100 score. Lag is the felt-latency figure in milliseconds: p75 plus jitter and a loss penalty over the last 30 s. Icon only shows just the state glyph."
|
||||
},
|
||||
{
|
||||
"key": "internetAnchor",
|
||||
"type": "string",
|
||||
"label": "Internet anchor",
|
||||
"defaultValue": "1.1.1.1",
|
||||
"description": "One of four internet-leg probe targets (pinged, and TCP-handshaked on 443). The scored leg comes from the two best instruments, so this one need not be among them."
|
||||
},
|
||||
{
|
||||
"key": "probeIntervalMs",
|
||||
"type": "integer",
|
||||
"label": "Probe interval (ms)",
|
||||
"min": 250,
|
||||
"max": 5000,
|
||||
"step": 250,
|
||||
"defaultValue": 500,
|
||||
"description": "500 ms is two samples a second, about 30 MB a day per ICMP target on the wire. Raise it to use less."
|
||||
},
|
||||
{
|
||||
"key": "contentSpeed",
|
||||
"type": "boolean",
|
||||
"label": "Measure content speed automatically",
|
||||
"defaultValue": true,
|
||||
"description": "A short download and a small upload that score Speed honestly. About 14 MB per check."
|
||||
},
|
||||
{
|
||||
"key": "contentSpeedIntervalMin",
|
||||
"type": "integer",
|
||||
"label": "Content speed interval (minutes)",
|
||||
"min": 15,
|
||||
"max": 1440,
|
||||
"step": 15,
|
||||
"defaultValue": 60
|
||||
},
|
||||
{
|
||||
"key": "peakEngine",
|
||||
"type": "enum",
|
||||
"label": "Peak speed test engine",
|
||||
"options": [
|
||||
"Auto",
|
||||
"Ookla",
|
||||
"Cloudflare",
|
||||
"fast.com"
|
||||
],
|
||||
"defaultValue": "Auto",
|
||||
"description": "Auto prefers the official speedtest CLI when it is installed, then Cloudflare, then fast.com. Peak tests only ever run when you ask for one."
|
||||
},
|
||||
{
|
||||
"key": "planDownMbps",
|
||||
"type": "integer",
|
||||
"label": "Plan download (Mbps)",
|
||||
"min": 0,
|
||||
"max": 10000,
|
||||
"step": 10,
|
||||
"defaultValue": 0,
|
||||
"description": "Optional. When set, Speed is scored against your plan (ISP accountability) instead of the built-in experience curve and your own baseline."
|
||||
},
|
||||
{
|
||||
"key": "planUpMbps",
|
||||
"type": "integer",
|
||||
"label": "Plan upload (Mbps)",
|
||||
"min": 0,
|
||||
"max": 10000,
|
||||
"step": 10,
|
||||
"defaultValue": 0,
|
||||
"description": "Optional, used together with the plan download."
|
||||
},
|
||||
{
|
||||
"key": "throughputWindowS",
|
||||
"type": "integer",
|
||||
"label": "Throughput averaging window (seconds)",
|
||||
"min": 1,
|
||||
"max": 30,
|
||||
"step": 1,
|
||||
"defaultValue": 3,
|
||||
"description": "The receiving/sending numbers average over this window. Larger is calmer, smaller is livelier."
|
||||
},
|
||||
{
|
||||
"key": "notifyOutage",
|
||||
"type": "boolean",
|
||||
"label": "Notify on outage and recovery",
|
||||
"defaultValue": true,
|
||||
"description": "A desktop notification once an outage has lasted a few seconds, and one when it clears, naming the leg that failed. Brief interruptions are logged, never notified."
|
||||
},
|
||||
{
|
||||
"key": "updateCheck",
|
||||
"type": "boolean",
|
||||
"label": "Tell me when an update is published",
|
||||
"defaultValue": true,
|
||||
"description": "Once a day, asks the repository you installed from whether a newer version exists, and shows a small marker in the panel if so. It never installs anything and sends nothing about you. Turn it off and no check is made."
|
||||
},
|
||||
{
|
||||
"key": "meteredCare",
|
||||
"type": "boolean",
|
||||
"label": "Go easy on phone hotspots",
|
||||
"defaultValue": true,
|
||||
"description": "When the connection comes from a phone sharing its data, pause the small hourly speed checks and ask twice before a full speed test. Turn this off if your plan is unlimited and you would rather keep measuring."
|
||||
},
|
||||
{
|
||||
"key": "historyDays",
|
||||
"type": "integer",
|
||||
"label": "Keep per-minute history for (days)",
|
||||
"min": 1,
|
||||
"max": 90,
|
||||
"step": 1,
|
||||
"defaultValue": 7,
|
||||
"description": "Hourly rollups are kept for a year regardless."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# Optional: run nexthopd as a systemd --user unit so monitoring continues
|
||||
# while omarchy-shell is down (a shell restart, a different session).
|
||||
#
|
||||
# mkdir -p ~/.config/systemd/user
|
||||
# sed "s|@PLUGIN_DIR@|$HOME/.config/omarchy/plugins/io.github.x3me.nexthop|" \
|
||||
# nexthopd.service > ~/.config/systemd/user/nexthopd.service
|
||||
# systemctl --user enable --now nexthopd
|
||||
#
|
||||
# The shell's Service.qml sees the flock is held and simply attaches.
|
||||
#
|
||||
# The other direction needs a line too: if this unit starts while the
|
||||
# shell's own daemon already holds the lock, ours exits 3 ("lock held").
|
||||
# That is a clean outcome, not a failure, and without
|
||||
# RestartPreventExitStatus systemd would restart it every 5 s until the
|
||||
# next shell restart handed the lock over.
|
||||
#
|
||||
# Restart=always rather than on-failure, because the version handover
|
||||
# (Service.qml, after `omarchy plugin update`) retires the running daemon
|
||||
# with SIGTERM and the daemon exits 0 on it. on-failure would leave this
|
||||
# unit inactive after every update, and the shell would quietly take the
|
||||
# daemon over — ending the "survives a shell restart" promise this unit
|
||||
# exists for. RestartSec must beat the shell's own respawn, which fires
|
||||
# 2.5 s after the retire: whoever takes the flock first keeps it, and it
|
||||
# should be us. systemd's default start limit still stops a crash loop.
|
||||
[Unit]
|
||||
Description=Nexthop internet quality monitor daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 -m nexthopd
|
||||
WorkingDirectory=@PLUGIN_DIR@
|
||||
Restart=always
|
||||
RestartPreventExitStatus=3
|
||||
RestartSec=1
|
||||
Nice=10
|
||||
MemoryMax=256M
|
||||
|
||||
# Containment, limited to what the daemon can live inside. Each line below
|
||||
# was exercised: a second daemon ran under this exact set against its own
|
||||
# state and runtime dirs and measured everything — both probe kinds, iw,
|
||||
# nmcli, ss with socket owners, the reachability curl.
|
||||
NoNewPrivileges=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service
|
||||
|
||||
# Deliberately NOT set: ProtectSystem=, ProtectHome=, PrivateTmp=,
|
||||
# ProtectKernelTunables=, ProtectKernelModules=, ProtectControlGroups=.
|
||||
# In a user unit each of those is built on a user namespace, and from
|
||||
# inside one /proc/<pid>/fd of every other process is unreadable, so
|
||||
# `ss -p` sees every socket and can name the owner of none of them.
|
||||
# Measured: 45 sockets with owners outside, 0 under any one of those six.
|
||||
# That would blank the Apps tab and the kernel socket timing, which is
|
||||
# most of what this daemon knows about the user's own traffic.
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,8 @@
|
||||
"""nexthopd — the measurement daemon behind the Nexthop Omarchy plugin.
|
||||
|
||||
Standard library only, on purpose: the plugin is installed by cloning a git
|
||||
repo, and the marketplace installer never builds or runs anything. Anything
|
||||
that needed pip or a compiler would make installation a second, manual step.
|
||||
"""
|
||||
|
||||
__version__ = "0.2.42"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""`python3 -m nexthopd` runs the daemon; the CLI lives at nexthopd.cli."""
|
||||
import sys
|
||||
from .daemon import main
|
||||
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Per-application traffic, without root.
|
||||
|
||||
Linux only hands per-process byte counts to privileged tooling (pcap, eBPF)
|
||||
— that is why nethogs needs root. The unprivileged truth available is
|
||||
`ss -tinp`: every TCP socket's bytes_sent / bytes_received with the owning
|
||||
process. Sampled on an interval, socket deltas aggregate into honest
|
||||
per-app rates and running totals.
|
||||
|
||||
What this cannot see: UDP — and with it QUIC, which is how Chrome talks to
|
||||
much of Google. That traffic surfaces as the gap between the interface
|
||||
counters and the TCP sum, shown as its own "unattributed" bucket rather
|
||||
than silently missing.
|
||||
|
||||
The same `ss` line also carries what the kernel already knows about each
|
||||
connection's timing: `rtt:<srtt>/<rttvar>` (its smoothed round trip),
|
||||
`minrtt` (the lowest it has ever seen on that path) and `bytes_retrans`.
|
||||
That is a latency measurement of the user's own traffic, to the hosts they
|
||||
actually talk to, costing no probe and no privilege — so we read it rather
|
||||
than throw it away.
|
||||
|
||||
`minrtt` is the path's structural floor: distance, switching, serialisation.
|
||||
`srtt - minrtt` is therefore queueing delay with distance divided out, and
|
||||
every connection is its own control — which is what makes a 300 ms socket to
|
||||
another continent comparable with a 5 ms socket next door. A raw `srtt` is
|
||||
never a verdict on its own; only the difference travels.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import NamedTuple
|
||||
|
||||
from .probes import nearest_rank
|
||||
|
||||
# users:(("chrome",pid=4958,fd=66)) — first process owning the socket.
|
||||
RE_USER = re.compile(r'users:\(\("([^"]+)",pid=(\d+)')
|
||||
RE_SENT = re.compile(r"bytes_sent:(\d+)")
|
||||
RE_RECV = re.compile(r"bytes_received:(\d+)")
|
||||
# rtt:<srtt>/<rttvar> — both milliseconds. minrtt is printed separately.
|
||||
RE_RTT = re.compile(r"\brtt:([\d.]+)/([\d.]+)")
|
||||
RE_MINRTT = re.compile(r"\bminrtt:([\d.]+)")
|
||||
RE_RETRANS = re.compile(r"\bbytes_retrans:(\d+)")
|
||||
|
||||
|
||||
class Sock(NamedTuple):
|
||||
"""One socket's sample. Indexable, so older positional callers still work."""
|
||||
app: str
|
||||
pid: int
|
||||
sent: int
|
||||
recv: int
|
||||
srtt: float = None # kernel smoothed RTT, ms
|
||||
minrtt: float = None # lowest RTT seen on this path, ms
|
||||
retrans: int = 0 # bytes retransmitted by the kernel
|
||||
|
||||
|
||||
# A connection has to have carried something before its floor is worth
|
||||
# trusting: `minrtt` is a minimum over the connection's life, so a socket
|
||||
# that has only ever been busy may never have seen a quiet moment. Note the
|
||||
# error direction — a floor biased high UNDER-states queueing, so this
|
||||
# threshold is about honesty, not safety. Argue with the number, not the rule.
|
||||
MIN_LATENCY_BYTES = 4096
|
||||
# A plausibility ceiling, distinct from a sample-count floor: past this, the
|
||||
# number describes a broken measurement rather than a slow link, and a
|
||||
# measurement we cannot believe is worth less than no measurement.
|
||||
MAX_PLAUSIBLE_RTT_MS = 10_000.0
|
||||
# `ss` prints three decimals, so allow rounding before calling an inverted
|
||||
# pair (floor above the average, which cannot happen) a stale field.
|
||||
RTT_INVERSION_TOLERANCE_MS = 0.05
|
||||
# Below this many qualifying sockets we publish nothing rather than a
|
||||
# distribution drawn from a handful of connections.
|
||||
MIN_LATENCY_SOCKETS = 3
|
||||
|
||||
|
||||
def socket_timing(s) -> tuple:
|
||||
"""(srtt, floor, queue) for one socket, or None if it does not qualify.
|
||||
|
||||
One guard, used by both the aggregate and the per-app medians, so a
|
||||
socket rejected in one place cannot be silently counted in the other.
|
||||
"""
|
||||
if s.srtt is None or s.minrtt is None:
|
||||
return None # kernel has no timing for it yet
|
||||
if s.sent + s.recv < MIN_LATENCY_BYTES:
|
||||
return None # too little traffic to trust the floor
|
||||
if s.srtt <= 0 or s.minrtt <= 0:
|
||||
return None
|
||||
if s.srtt > MAX_PLAUSIBLE_RTT_MS or s.minrtt > MAX_PLAUSIBLE_RTT_MS:
|
||||
return None # implausible: a broken measurement
|
||||
if s.minrtt > s.srtt + RTT_INVERSION_TOLERANCE_MS:
|
||||
return None # floor above the average: stale field
|
||||
return (s.srtt, s.minrtt, max(0.0, s.srtt - s.minrtt))
|
||||
|
||||
|
||||
def latency_stats(socks: dict) -> dict:
|
||||
"""What the user's own TCP connections are experiencing, or None.
|
||||
|
||||
Pure and injected so it can be tested without a live socket table.
|
||||
Returns None when too few connections qualify — an honest blank beats a
|
||||
distribution invented from three sockets.
|
||||
"""
|
||||
srtts, floors, queues, retrans_socks, rejected = [], [], [], 0, 0
|
||||
for s in socks.values():
|
||||
if s.srtt is None or s.minrtt is None:
|
||||
continue # not a rejection: the kernel simply has no timing
|
||||
t = socket_timing(s)
|
||||
if t is None:
|
||||
rejected += 1
|
||||
continue
|
||||
srtts.append(t[0])
|
||||
floors.append(t[1])
|
||||
queues.append(t[2])
|
||||
if s.retrans:
|
||||
retrans_socks += 1
|
||||
if len(srtts) < MIN_LATENCY_SOCKETS:
|
||||
return None
|
||||
srtts.sort(); floors.sort(); queues.sort()
|
||||
return {
|
||||
"sockets": len(srtts),
|
||||
"rejected": rejected,
|
||||
# What the applications see, end to end, including distance.
|
||||
"rtt_p50": round(statistics.median(srtts), 2),
|
||||
"rtt_p95": round(nearest_rank(srtts, 0.95), 2),
|
||||
# The structural floor of the paths in use.
|
||||
"floor_p50": round(statistics.median(floors), 2),
|
||||
# Queueing, with distance divided out. This is the number that
|
||||
# compares across connections.
|
||||
"queue_p50": round(statistics.median(queues), 2),
|
||||
"queue_p95": round(nearest_rank(queues, 0.95), 2),
|
||||
"retrans_sockets": retrans_socks,
|
||||
}
|
||||
|
||||
|
||||
def parse_ss(raw: str, max_sockets: int = 10_000) -> dict:
|
||||
"""{socket_key: (app, pid, sent, received)} from `ss -tinpH` output.
|
||||
|
||||
Sockets are keyed by local/peer address pair plus pid, which survives
|
||||
across samples for the life of the connection. Sockets without process
|
||||
attribution (other users' processes) are skipped, and parsing stops at
|
||||
max_sockets so a pathological table cannot expand retained state.
|
||||
"""
|
||||
out = {}
|
||||
addr = None
|
||||
app = None
|
||||
pid = None
|
||||
for line in raw.splitlines():
|
||||
if not line.startswith(("\t", " ")):
|
||||
# Header line: state, queues, local, peer, users.
|
||||
parts = line.split()
|
||||
addr = None
|
||||
app = None
|
||||
m = RE_USER.search(line)
|
||||
if m and len(parts) >= 5:
|
||||
addr = parts[3] + ">" + parts[4]
|
||||
app = m.group(1)
|
||||
pid = int(m.group(2))
|
||||
continue
|
||||
if addr is None:
|
||||
continue
|
||||
sent = RE_SENT.search(line)
|
||||
recv = RE_RECV.search(line)
|
||||
if sent or recv:
|
||||
if len(out) >= max_sockets:
|
||||
break
|
||||
# The kernel's own timing, off the same line. Absent on a socket
|
||||
# it has not measured yet, which is why these stay None rather
|
||||
# than defaulting to zero.
|
||||
rtt = RE_RTT.search(line)
|
||||
minrtt = RE_MINRTT.search(line)
|
||||
retrans = RE_RETRANS.search(line)
|
||||
out[addr + "#" + str(pid)] = Sock(
|
||||
app, pid,
|
||||
int(sent.group(1)) if sent else 0,
|
||||
int(recv.group(1)) if recv else 0,
|
||||
float(rtt.group(1)) if rtt else None,
|
||||
float(minrtt.group(1)) if minrtt else None,
|
||||
int(retrans.group(1)) if retrans else 0,
|
||||
)
|
||||
addr = None
|
||||
return out
|
||||
|
||||
|
||||
# Reaping is inside the budget, not after it. This whole call runs on the
|
||||
# daemon's loop, so what the caller needs bounded is the block the loop
|
||||
# suffers — the read plus getting rid of the child. Spending the deadline
|
||||
# reading and then seconds terminating meets the letter and misses the
|
||||
# point; that gap is what let a slow `ss` push live.json past the age at
|
||||
# which the bar calls the daemon dead.
|
||||
REAP_RESERVE_S = 0.25
|
||||
|
||||
|
||||
def read_bounded(proc, max_bytes: int, deadline_s: float):
|
||||
"""The child's stdout, capped in size and in time, then the child reaped.
|
||||
|
||||
Returns the text, or None if the process did not finish within the
|
||||
deadline — it is killed and waited for either way, so nothing lingers.
|
||||
Output past the cap is discarded and the process stopped: the sample
|
||||
stays bounded and simply under-counts, which is the existing contract.
|
||||
|
||||
`deadline_s` bounds the CALL, reaping included, because that is the
|
||||
figure the caller budgets against.
|
||||
"""
|
||||
fd = proc.stdout.fileno()
|
||||
os.set_blocking(fd, False)
|
||||
reserve = max(0.05, min(REAP_RESERVE_S, deadline_s * 0.5))
|
||||
deadline = time.monotonic() + max(0.0, deadline_s - reserve)
|
||||
chunks, total, timed_out = [], 0, False
|
||||
try:
|
||||
while total <= max_bytes:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
timed_out = True
|
||||
break
|
||||
ready, _, _ = select.select([fd], [], [], remaining)
|
||||
if not ready:
|
||||
continue
|
||||
try:
|
||||
chunk = os.read(fd, min(65536, max_bytes + 1 - total))
|
||||
except BlockingIOError:
|
||||
continue
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
except OSError:
|
||||
timed_out = True
|
||||
finally:
|
||||
proc.stdout.close()
|
||||
_reap(proc, reserve)
|
||||
if timed_out:
|
||||
return None
|
||||
return b"".join(chunks)[:max_bytes].decode("utf-8", "replace")
|
||||
|
||||
|
||||
def _reap(proc, budget_s: float):
|
||||
"""Terminate if still running, escalate to kill, and wait — within
|
||||
`budget_s`, because this runs on the daemon's loop.
|
||||
|
||||
A signalled child that is never waited for is a zombie until the next
|
||||
Popen happens to collect it, so waiting is right; waiting without a
|
||||
bound is not. A child that survives SIGKILL for longer than this is in
|
||||
uninterruptible sleep, and no amount of further waiting is going to
|
||||
help — leaving it for the next poll to collect costs a transient
|
||||
zombie, while blocking here costs the snapshot the whole bar reads.
|
||||
"""
|
||||
half = max(0.05, budget_s * 0.5)
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=half)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
proc.kill()
|
||||
proc.wait(timeout=half)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class AppTraffic:
|
||||
"""Aggregates socket samples into per-app rates and session totals."""
|
||||
|
||||
# How many poll intervals of history each app keeps. At the 3-second
|
||||
# poll that is one minute — enough to show the shape of usage, small
|
||||
# enough to ride along in apps.json.
|
||||
HISTORY = 20
|
||||
|
||||
def __init__(self):
|
||||
self.prev = {}
|
||||
self.prev_t = None
|
||||
self.totals = {} # app -> [rx_bytes, tx_bytes]
|
||||
self.rates = [] # last interval's list, ready for apps.json
|
||||
self.history = {} # app -> deque of [rx_bps, tx_bps]
|
||||
self.latency = None # last latency_stats(), or None while under-sampled
|
||||
|
||||
# Enumeration bounds: a machine with an enormous socket table must not
|
||||
# make the daemon allocate without limit every three seconds. 4 MB of
|
||||
# `ss` output is roughly 8000 sockets — far past any laptop, and the
|
||||
# cap degrades to "top apps among the first N sockets", not a crash.
|
||||
MAX_SS_BYTES = 4 * 1024 * 1024
|
||||
MAX_SOCKETS = 10_000
|
||||
|
||||
# `ss -p` walks every process's descriptors to name the owners, and
|
||||
# on a busy machine that can stall. The read is bounded in time as
|
||||
# well as size, because a read with no deadline holds the daemon's
|
||||
# loop — and its outage watch — for as long as `ss` does.
|
||||
#
|
||||
# The size of the bound is not free either. This call is the only
|
||||
# blocking one left on the loop, and the loop writes live.json just
|
||||
# before it, so whatever this is budgeted for is how stale that
|
||||
# snapshot can get. The bar calls the daemon dead at
|
||||
# `BarWidget.staleAfterS` (5 s) — so a 5 s budget here, which is what
|
||||
# this was, could blank the index, the headline and the path
|
||||
# sparklines' liveness ring on a daemon that was measuring perfectly.
|
||||
# A third of the contract leaves room for the rest of the iteration.
|
||||
# Overrunning it costs one interval of app counters, which the Apps
|
||||
# tab already degrades honestly; the alternative cost the whole bar.
|
||||
# A test pins this against the QML number rather than either alone.
|
||||
POLL_DEADLINE_S = 1.5
|
||||
|
||||
def poll(self) -> bool:
|
||||
if not shutil.which("ss"):
|
||||
return False
|
||||
try:
|
||||
proc = subprocess.Popen(["ss", "-tinpH"], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL)
|
||||
except OSError:
|
||||
return False
|
||||
raw = read_bounded(proc, self.MAX_SS_BYTES, self.POLL_DEADLINE_S)
|
||||
if raw is None:
|
||||
return False
|
||||
now = time.time()
|
||||
cur = parse_ss(raw, max_sockets=self.MAX_SOCKETS)
|
||||
self._fold(cur, now)
|
||||
return True
|
||||
|
||||
def _fold(self, cur: dict, now: float):
|
||||
if self.prev_t is None:
|
||||
# First sample is the baseline: the counters carry each
|
||||
# connection's whole history, which is not this interval's
|
||||
# traffic.
|
||||
self.prev, self.prev_t = cur, now
|
||||
return
|
||||
dt = max(0.5, now - self.prev_t)
|
||||
interval = {} # app -> [rx, tx, conns]
|
||||
per_app_rtt = {} # app -> list of srtt
|
||||
per_app_queue = {} # app -> list of srtt - minrtt
|
||||
for key, s in cur.items():
|
||||
app, sent, recv = s.app, s.sent, s.recv
|
||||
prev = self.prev.get(key)
|
||||
if prev is not None:
|
||||
d_tx = max(0, sent - prev[2])
|
||||
d_rx = max(0, recv - prev[3])
|
||||
else:
|
||||
# Born since the last sample: its whole life is this
|
||||
# interval.
|
||||
d_tx, d_rx = sent, recv
|
||||
slot = interval.setdefault(app, [0, 0, 0])
|
||||
slot[0] += d_rx
|
||||
slot[1] += d_tx
|
||||
slot[2] += 1
|
||||
# Per-app timing shares the aggregate's guard, so a socket
|
||||
# rejected there cannot be silently counted here.
|
||||
t = socket_timing(s)
|
||||
if t is not None:
|
||||
per_app_rtt.setdefault(app, []).append(t[0])
|
||||
per_app_queue.setdefault(app, []).append(t[2])
|
||||
# Sockets that closed between samples take their final delta with
|
||||
# them — the tail of a closed connection is the one thing this
|
||||
# method genuinely cannot count.
|
||||
for app, (rx, tx, _conns) in interval.items():
|
||||
tot = self.totals.setdefault(app, [0, 0])
|
||||
tot[0] += rx
|
||||
tot[1] += tx
|
||||
# Every known app gets a history sample each interval — an app that
|
||||
# went quiet records zeros, so its strip shows the quiet.
|
||||
for app in self.totals:
|
||||
v = interval.get(app)
|
||||
h = self.history.setdefault(app, deque(maxlen=self.HISTORY))
|
||||
h.append([round(v[0] / dt, 1), round(v[1] / dt, 1)] if v else [0.0, 0.0])
|
||||
self.rates = [
|
||||
{
|
||||
"name": app,
|
||||
"rx_bps": round(v[0] / dt, 1),
|
||||
"tx_bps": round(v[1] / dt, 1),
|
||||
"conns": v[2],
|
||||
"rx_total": self.totals.get(app, [0, 0])[0],
|
||||
"tx_total": self.totals.get(app, [0, 0])[1],
|
||||
"hist": list(self.history.get(app, [])),
|
||||
# Median across this app's qualifying sockets. None when it
|
||||
# has none — an app talking only QUIC shows no latency here,
|
||||
# which is honest rather than zero.
|
||||
"rtt_ms": (round(statistics.median(per_app_rtt[app]), 2)
|
||||
if per_app_rtt.get(app) else None),
|
||||
"queue_ms": (round(statistics.median(per_app_queue[app]), 2)
|
||||
if per_app_queue.get(app) else None),
|
||||
}
|
||||
for app, v in interval.items()
|
||||
]
|
||||
self.latency = latency_stats(cur)
|
||||
self.prev, self.prev_t = cur, now
|
||||
|
||||
def top(self, n: int = 8) -> list:
|
||||
"""Busiest apps first; idle-but-heavy session users still listed."""
|
||||
ranked = sorted(self.rates,
|
||||
key=lambda a: (a["rx_bps"] + a["tx_bps"],
|
||||
a["rx_total"] + a["tx_total"]),
|
||||
reverse=True)
|
||||
seen = {a["name"] for a in ranked}
|
||||
# Apps with session history but no sockets this interval.
|
||||
idle = [
|
||||
{"name": app, "rx_bps": 0.0, "tx_bps": 0.0, "conns": 0,
|
||||
"rx_total": t[0], "tx_total": t[1],
|
||||
"hist": list(self.history.get(app, [])),
|
||||
"rtt_ms": None, "queue_ms": None}
|
||||
for app, t in self.totals.items() if app not in seen
|
||||
]
|
||||
idle.sort(key=lambda a: a["rx_total"] + a["tx_total"], reverse=True)
|
||||
return (ranked + idle)[:n]
|
||||
@@ -0,0 +1,365 @@
|
||||
"""`nexthop` — the query CLI the panel (and you) use for history.
|
||||
|
||||
Everything answers in JSON on stdout, because the consumer is a QML
|
||||
Process { } as often as it is a person. The daemon is not involved: reads go
|
||||
straight to the files — sqlite for history (WAL mode makes that safe), the
|
||||
JSON state files for the rest.
|
||||
|
||||
nexthop query --window 24h history series at the right resolution
|
||||
nexthop live the current live.json
|
||||
nexthop events --window 7d outages, disruptions, changes
|
||||
nexthop tests [--kind peak] speed test results
|
||||
nexthop report --window 24h plain-text summary for an ISP ticket
|
||||
nexthop peak ask the running daemon for a peak test
|
||||
nexthop stream <keys> the shell's bounded reader: one JSON line
|
||||
per named state file, re-emitted on change
|
||||
nexthop retire --pid --start SIGTERM a stale daemon, identity-checked
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
|
||||
from .paths import (apps_path, db_path, live_path, lock_path, manifest_path,
|
||||
recent_path)
|
||||
from .state import read_json, read_text_bounded
|
||||
|
||||
WINDOWS = {"m": 60, "h": 3600, "d": 86400}
|
||||
|
||||
|
||||
def parse_window(text: str) -> float:
|
||||
text = (text or "30m").strip().lower()
|
||||
unit = text[-1]
|
||||
if unit in WINDOWS:
|
||||
try:
|
||||
return float(text[:-1]) * WINDOWS[unit]
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return 1800.0
|
||||
|
||||
|
||||
def emit(obj):
|
||||
json.dump(obj, sys.stdout, separators=(",", ":"))
|
||||
print()
|
||||
|
||||
|
||||
def cmd_live(_args):
|
||||
emit(read_json(live_path(), {"state": "no-daemon"}))
|
||||
return 0
|
||||
|
||||
|
||||
# The QML side never opens a state file itself. It runs `nexthop stream`
|
||||
# and reads whole lines, so the only code that touches these paths is the
|
||||
# bounded no-follow non-blocking read in state.py — an oversized file, a
|
||||
# FIFO or a symlink swap is refused here, in a small short-lived process,
|
||||
# instead of allocating or stalling inside the long-lived shell.
|
||||
#
|
||||
# Keys, never paths: the caller picks from this table, so no argument it
|
||||
# passes can widen what gets opened. Caps match each file's real size
|
||||
# (live ~3 KB, apps ~8 KB, recent ~30 KB) with generous headroom.
|
||||
STREAMABLE = {
|
||||
"live": (live_path, 256 * 1024),
|
||||
"apps": (apps_path, 1024 * 1024),
|
||||
"recent": (recent_path, 1024 * 1024),
|
||||
"manifest": (manifest_path, 256 * 1024),
|
||||
}
|
||||
|
||||
|
||||
def cmd_stream(args):
|
||||
"""Emit `<key> <json>` lines whenever a watched file's contents change.
|
||||
|
||||
The payload is re-serialised here rather than forwarded verbatim: it
|
||||
guarantees one line per record whatever the file's own formatting
|
||||
(manifest.json is indented, the state files are not), and it means
|
||||
only JSON this process already parsed successfully is ever handed to
|
||||
the shell.
|
||||
"""
|
||||
keys = [k for k in dict.fromkeys(args.keys) if k in STREAMABLE]
|
||||
if not keys:
|
||||
print("stream: nothing to watch", file=sys.stderr)
|
||||
return 2
|
||||
interval = min(max(args.interval, 0.1), 60.0)
|
||||
last = {}
|
||||
while True:
|
||||
for key in keys:
|
||||
resolve, cap = STREAMABLE[key]
|
||||
got = read_text_bounded(resolve(), cap)
|
||||
if got is None:
|
||||
continue
|
||||
text, stamp = got
|
||||
if last.get(key) == stamp:
|
||||
continue
|
||||
last[key] = stamp
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except ValueError:
|
||||
continue # a half-written or foreign file; skip it
|
||||
# ensure_ascii escapes any newline inside a string, so the
|
||||
# record cannot break the line framing.
|
||||
line = json.dumps(payload, separators=(",", ":"))
|
||||
try:
|
||||
sys.stdout.write(f"{key} {line}\n")
|
||||
sys.stdout.flush()
|
||||
except (BrokenPipeError, ValueError):
|
||||
# The shell went away; so do we. _exit skips the
|
||||
# interpreter's final flush, which would only raise the
|
||||
# same broken pipe again and print it to stderr.
|
||||
os._exit(0)
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def authorized_to_retire(pid: int, want_start: int) -> bool:
|
||||
"""Is this pid really our daemon, and the same one live.json named?
|
||||
|
||||
Three independent facts, all read from /proc and none of them a name
|
||||
match: the process must belong to this user, its argv must be exactly
|
||||
a python interpreter running `-m nexthopd`, and its start time must
|
||||
equal the one the daemon published. A recycled pid can reproduce the
|
||||
number but never the start time.
|
||||
|
||||
This lives here rather than in a shell one-liner because the one-liner
|
||||
could not be tested and, as it turned out, did not run at all: the
|
||||
NUL it passed to `tr` truncated the script at execve.
|
||||
"""
|
||||
try:
|
||||
if os.stat(f"/proc/{pid}").st_uid != os.getuid():
|
||||
return False
|
||||
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||||
argv = [a.decode("utf-8", "replace")
|
||||
for a in f.read(4096).split(b"\0") if a]
|
||||
with open(f"/proc/{pid}/stat", "rb") as f:
|
||||
data = f.read(4096)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
if len(argv) < 3 or "python" not in os.path.basename(argv[0]):
|
||||
return False
|
||||
if argv[1] != "-m" or argv[2] != "nexthopd" or len(argv) > 3:
|
||||
return False
|
||||
if want_start:
|
||||
try:
|
||||
start = int(data[data.rindex(b")") + 2:].split()[19])
|
||||
except (ValueError, IndexError):
|
||||
return False
|
||||
if start != want_start:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def cmd_retire(args):
|
||||
"""SIGTERM a stale daemon, but only once its identity checks out."""
|
||||
if args.pid <= 0 or args.pid == os.getpid():
|
||||
return 1
|
||||
if not authorized_to_retire(args.pid, args.start):
|
||||
return 1
|
||||
try:
|
||||
os.kill(args.pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def open_store():
|
||||
# Imported here, not at module scope: `stream` runs for the life of the
|
||||
# shell and has no use for sqlite3, so it should not pay to load it.
|
||||
from .store import Store
|
||||
path = db_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return Store(path, read_only=True)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def cmd_query(args):
|
||||
store = open_store()
|
||||
if not store:
|
||||
emit({"error": "no history yet"})
|
||||
return 1
|
||||
seconds = parse_window(args.window)
|
||||
rows, table = store.series(seconds, resolution=args.resolution)
|
||||
emit({"window_s": seconds, "resolution": table, "rows": rows})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_events(args):
|
||||
store = open_store()
|
||||
if not store:
|
||||
emit({"events": []})
|
||||
return 0
|
||||
emit({"events": store.events(parse_window(args.window))})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_tests(args):
|
||||
store = open_store()
|
||||
if not store:
|
||||
emit({"tests": []})
|
||||
return 0
|
||||
emit({"tests": store.tests(limit=args.limit, kind=args.kind)})
|
||||
return 0
|
||||
|
||||
|
||||
def _lock_holder_pid() -> int:
|
||||
"""The pid written by whoever holds the daemon lock, or 0.
|
||||
|
||||
The lock file outlives a daemon that died hard and pids are recycled,
|
||||
so the number in the file is not evidence on its own. The flock is:
|
||||
if this process can take it, nobody holds it and nobody is listening,
|
||||
whatever the file says. Opened the way the daemon opens it — no
|
||||
symlink following, a regular file or nothing — and never truncated.
|
||||
"""
|
||||
try:
|
||||
fd = os.open(lock_path(), os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
except OSError:
|
||||
return 0
|
||||
try:
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
return 0
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
# Held: that is the daemon, and the pid it wrote after taking
|
||||
# the lock is the one to ring.
|
||||
try:
|
||||
return int(os.read(fd, 32).strip())
|
||||
except ValueError:
|
||||
return 0
|
||||
# We got it, so nobody was holding it. Give it straight back.
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
return 0
|
||||
except OSError:
|
||||
return 0
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def cmd_peak(_args):
|
||||
"""Ring the daemon's doorbell. SIGUSR1 is the whole protocol.
|
||||
|
||||
SIGUSR1's default disposition is *terminate*, so signalling the wrong
|
||||
pid is not a harmless no-op — and this is what a middle-click on the
|
||||
bar runs. Two checks stand before the signal: the lock must actually
|
||||
be held (see `_lock_holder_pid`), and the holder must pass the same
|
||||
owner-and-argv test the retire path applies, with the start time
|
||||
live.json published when it names the same pid. The same rule as
|
||||
`nexthop retire`: a process is authorised by identity, never by the
|
||||
number a file happens to contain.
|
||||
"""
|
||||
pid = _lock_holder_pid()
|
||||
if pid <= 0 or pid == os.getpid():
|
||||
emit({"ok": False, "error": "daemon not running"})
|
||||
return 1
|
||||
live = read_json(live_path(), {}) or {}
|
||||
want_start = 0
|
||||
if live.get("pid") == pid:
|
||||
try:
|
||||
want_start = int(live.get("pid_start") or 0)
|
||||
except (TypeError, ValueError):
|
||||
want_start = 0
|
||||
if not authorized_to_retire(pid, want_start):
|
||||
emit({"ok": False, "error": "lock holder is not nexthopd"})
|
||||
return 1
|
||||
try:
|
||||
os.kill(pid, signal.SIGUSR1)
|
||||
except OSError:
|
||||
emit({"ok": False, "error": "daemon not running"})
|
||||
return 1
|
||||
emit({"ok": True})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_report(args):
|
||||
"""The paste-into-a-ticket summary. Plain text by design."""
|
||||
store = open_store()
|
||||
live = read_json(live_path(), {})
|
||||
seconds = parse_window(args.window)
|
||||
lines = []
|
||||
link = live.get("link", {})
|
||||
lines.append(f"Nexthop report — last {args.window}")
|
||||
lines.append(f"generated {time.strftime('%Y-%m-%d %H:%M %Z')}")
|
||||
if link:
|
||||
what = link.get("ssid") or link.get("name") or link.get("iface", "?")
|
||||
lines.append(f"connection: {what} ({link.get('kind', '?')}), "
|
||||
f"gateway {link.get('gateway', '?')}")
|
||||
lines.append("")
|
||||
if store:
|
||||
rows, table = store.series(seconds)
|
||||
vals = lambda k: [r[k] for r in rows if r.get(k) is not None]
|
||||
|
||||
def block(name, p50key, p95key, losskey):
|
||||
p50, p95, loss = vals(p50key), vals(p95key), vals(losskey)
|
||||
if not p50:
|
||||
lines.append(f"{name}: no data")
|
||||
return
|
||||
lines.append(
|
||||
f"{name}: median {sum(p50)/len(p50):.1f} ms, "
|
||||
f"p95 {max(p95) if p95 else 0:.1f} ms (worst {table} bucket), "
|
||||
f"loss {sum(loss)/len(loss)*100 if loss else 0:.2f}%")
|
||||
|
||||
block("local leg (to router)", "local_p50", "local_p95", "local_loss")
|
||||
block("wan leg (past router)", "wan_p50", "wan_p95", "wan_loss")
|
||||
lines.append("")
|
||||
events = store.events(seconds)
|
||||
if events:
|
||||
lines.append("events:")
|
||||
for e in events:
|
||||
start = time.strftime("%a %H:%M", time.localtime(e["ts"]))
|
||||
dur = (f"{e['ended_ts'] - e['ts']}s" if e["ended_ts"]
|
||||
else "ongoing")
|
||||
lines.append(f" {start} {e['kind']} on {e['leg']} leg, {dur}"
|
||||
f" — {e['detail']}")
|
||||
else:
|
||||
lines.append("events: none")
|
||||
tests = store.tests(limit=5)
|
||||
if tests:
|
||||
lines.append("")
|
||||
lines.append("speed tests:")
|
||||
for t in tests:
|
||||
when = time.strftime("%a %H:%M", time.localtime(t["ts"]))
|
||||
down = f"{t['down_mbps']:.0f}" if t["down_mbps"] else "--"
|
||||
up = f"{t['up_mbps']:.0f}" if t["up_mbps"] else "--"
|
||||
lines.append(f" {when} {t['kind']:<8} {down}/{up} Mbps"
|
||||
f" ({t['engine']})")
|
||||
print("\n".join(lines))
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(prog="nexthop")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("live")
|
||||
q = sub.add_parser("query")
|
||||
q.add_argument("--window", default="30m")
|
||||
q.add_argument("--resolution", default="auto",
|
||||
choices=["auto", "minute", "hour"])
|
||||
e = sub.add_parser("events")
|
||||
e.add_argument("--window", default="7d")
|
||||
t = sub.add_parser("tests")
|
||||
t.add_argument("--kind", default=None)
|
||||
t.add_argument("--limit", type=int, default=20)
|
||||
sub.add_parser("peak")
|
||||
r = sub.add_parser("report")
|
||||
r.add_argument("--window", default="24h")
|
||||
s = sub.add_parser("stream")
|
||||
s.add_argument("keys", nargs="+", choices=sorted(STREAMABLE))
|
||||
s.add_argument("--interval", type=float, default=0.5)
|
||||
rt = sub.add_parser("retire")
|
||||
rt.add_argument("--pid", type=int, required=True)
|
||||
rt.add_argument("--start", type=int, default=0)
|
||||
args = ap.parse_args(argv)
|
||||
return {"live": cmd_live, "query": cmd_query, "events": cmd_events,
|
||||
"tests": cmd_tests, "peak": cmd_peak, "report": cmd_report,
|
||||
"stream": cmd_stream, "retire": cmd_retire}[args.cmd](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,353 @@
|
||||
"""Two best instruments for the internet leg.
|
||||
|
||||
One anchor is one opinion. A single probe target that rate-limits, gets
|
||||
DDoSed, or sits behind a bad peering path poisons the score for everyone
|
||||
behind it — and there is no way to tell "my internet is slow" from "the
|
||||
anchor is having a day" with one instrument. Orb's answer, adopted here:
|
||||
keep a small pool of instruments, score from the best two, re-rank them
|
||||
continuously, and never let one flapping target churn the pair.
|
||||
|
||||
An instrument is a (protocol, target) pair. The pool mixes protocols on
|
||||
purpose: TCP handshakes travel the application path, ICMP is the cheapest
|
||||
edge detector — each can fail alone, and the pair means neither failing
|
||||
alone moves the score. Ranking is arguable-by-design, one number at a
|
||||
time, like the score anchors:
|
||||
|
||||
penalty = 2000·loss + (p95 − p50) + 0.1·p50
|
||||
|
||||
Loss dominates (5 % of packets lost costs as much as 100 ms of tail spread), the
|
||||
tail spread comes second because Lag leans on p75, and the median is a
|
||||
tiebreak — an instrument must not win its seat merely by being close.
|
||||
|
||||
Damping, because re-selection is where naive versions of this oscillate:
|
||||
a challenger must beat the worst active instrument by 20 % on two
|
||||
consecutive evaluations; an instrument whose seat changes three or more
|
||||
times in an hour is quarantined for thirty minutes; and a dead active
|
||||
instrument is replaced immediately, hysteresis notwithstanding — waiting
|
||||
two rounds to bench a corpse helps nobody.
|
||||
|
||||
Everything here is pure bookkeeping over injected stats. No probe, no
|
||||
subprocess, no clock of its own — which is what makes it testable, and
|
||||
tested.
|
||||
"""
|
||||
|
||||
import statistics
|
||||
from collections import deque
|
||||
|
||||
from .probes import RECENT_MIN_SAMPLES, RECENT_WINDOW_S, Series
|
||||
|
||||
DEAD_PENALTY = 2000.0 # loss = 1.0 and nothing else to say
|
||||
|
||||
|
||||
def penalty(stats):
|
||||
"""Rank one instrument's recent window; None = not enough to judge."""
|
||||
if not stats:
|
||||
return None
|
||||
count = stats.get("count") or 0
|
||||
if count < Bench.MIN_SAMPLES:
|
||||
return None
|
||||
loss = stats.get("loss") or 0.0
|
||||
p50, p95 = stats.get("p50"), stats.get("p95")
|
||||
if p50 is None:
|
||||
return DEAD_PENALTY * loss if loss > 0 else None
|
||||
spread = (p95 - p50) if p95 is not None else 0.0
|
||||
return DEAD_PENALTY * loss + spread + 0.1 * p50
|
||||
|
||||
|
||||
def host_of(target: str) -> str:
|
||||
"""The address part of an instrument's target.
|
||||
|
||||
So that two instruments pointed at the same machine are recognisable as
|
||||
such: `1.1.1.1` and `1.1.1.1:443` are one host wearing two protocols. A
|
||||
trailing `:port` is stripped only when what remains holds no colon of its
|
||||
own, which leaves an IPv6 literal intact rather than truncating it.
|
||||
"""
|
||||
head, sep, tail = target.rpartition(":")
|
||||
if sep and tail.isdigit() and ":" not in head:
|
||||
return head
|
||||
return target
|
||||
|
||||
|
||||
class Instrument:
|
||||
def __init__(self, key: str, kind: str, target: str = ""):
|
||||
self.key = key
|
||||
self.kind = kind # "icmp" | "tcp"
|
||||
self.target = target
|
||||
self.active = False
|
||||
self.pending_wins = 0 # consecutive evaluations won as challenger
|
||||
self.seat_changes = deque(maxlen=32) # timestamps, for flap tracking
|
||||
self.quarantined_until = 0.0
|
||||
|
||||
def flapping(self, now, window_s, limit) -> bool:
|
||||
return sum(1 for t in self.seat_changes if now - t <= window_s) >= limit
|
||||
|
||||
|
||||
class Bench:
|
||||
"""Holds the pool, decides who sits in the two scored seats."""
|
||||
|
||||
ACTIVE_N = 2
|
||||
WINDOW_S = RECENT_WINDOW_S # ranking window, shared with TcpProbe
|
||||
RESELECT_EVERY_S = 300.0 # ordinary re-ranking cadence
|
||||
MIN_SAMPLES = RECENT_MIN_SAMPLES # below this a window judges nothing
|
||||
MARGIN = 0.8 # challenger must be 20% better than the seat
|
||||
CONSECUTIVE_WINS = 2
|
||||
FLAP_WINDOW_S = 3600.0
|
||||
FLAP_LIMIT = 3
|
||||
QUARANTINE_S = 1800.0
|
||||
# An active instrument at or past this penalty is not "worse", it is
|
||||
# gone — full loss, or no samples arriving at all.
|
||||
DEAD_AT = DEAD_PENALTY * 0.95
|
||||
|
||||
def __init__(self, pool):
|
||||
"""pool: ordered [(key, kind, target)].
|
||||
|
||||
The opening seats span two DISTINCT hosts rather than being the first
|
||||
two in the pool. They used to be `pool[:2]` — ICMP and TCP to the
|
||||
anchor, the pre-0.2.0 pair, kept for continuity until the first
|
||||
ranking. On any network that blocks the anchor outright that put both
|
||||
scored seats on a dead host at every start: the outage watch opens at
|
||||
4 s, the notification fires at 5 s, and the bench cannot reseat until
|
||||
its next pass a minute later. A false outage and a desktop alert on
|
||||
every daemon start, and the daemon restarts on a shell restart, a
|
||||
version handover and a probe-settings change (#5).
|
||||
|
||||
One working seat is enough to prevent it — the leg answers if either
|
||||
instrument does — so the rule is simply that the pair must not be one
|
||||
host twice. The bench re-ranks from there as it always did; this only
|
||||
decides what is seated before there is anything to rank.
|
||||
|
||||
Not fixed by changing the default anchor: every candidate address is
|
||||
blocked on somebody's network, so that moves the report rather than
|
||||
closing it.
|
||||
"""
|
||||
self.instruments = {}
|
||||
for key, kind, target in pool:
|
||||
self.instruments[key] = Instrument(key, kind, target)
|
||||
for inst in self._opening_seats():
|
||||
inst.active = True
|
||||
self._last_reselect = 0.0
|
||||
|
||||
def _opening_seats(self):
|
||||
"""The first instruments of the pool that do not share a host."""
|
||||
seats, hosts = [], set()
|
||||
for inst in self.instruments.values():
|
||||
if len(seats) >= self.ACTIVE_N:
|
||||
break
|
||||
host = host_of(inst.target)
|
||||
if host in hosts:
|
||||
continue
|
||||
seats.append(inst)
|
||||
hosts.add(host)
|
||||
# A pool offering fewer distinct hosts than there are seats fills the
|
||||
# rest in order: fewer scored instruments than the bench expects is a
|
||||
# worse failure than two of them sharing a host.
|
||||
if len(seats) < self.ACTIVE_N:
|
||||
for inst in self.instruments.values():
|
||||
if len(seats) >= self.ACTIVE_N:
|
||||
break
|
||||
if inst not in seats:
|
||||
seats.append(inst)
|
||||
return seats
|
||||
|
||||
def actives(self):
|
||||
return [i for i in self.instruments.values() if i.active]
|
||||
|
||||
def _healthy(self, pens, inst, now):
|
||||
p = pens.get(inst.key)
|
||||
return (p is not None and p < self.DEAD_AT
|
||||
and now >= inst.quarantined_until)
|
||||
|
||||
def _seat(self, inst, now, active: bool):
|
||||
if inst.active == active:
|
||||
return None
|
||||
inst.active = active
|
||||
inst.pending_wins = 0
|
||||
inst.seat_changes.append(now)
|
||||
if inst.flapping(now, self.FLAP_WINDOW_S, self.FLAP_LIMIT):
|
||||
inst.quarantined_until = now + self.QUARANTINE_S
|
||||
return (inst.key, active)
|
||||
|
||||
def evaluate(self, now, stats_by_key):
|
||||
"""One pass; returns [(key, now_active)] seat changes.
|
||||
|
||||
Call every minute or so: emergency replacement of a dead seat acts
|
||||
on any pass, ordinary re-ranking only every RESELECT_EVERY_S.
|
||||
"""
|
||||
pens = {k: penalty(stats_by_key.get(k)) for k in self.instruments}
|
||||
changes = []
|
||||
|
||||
# A dead seat is replaced now. No hysteresis for corpses — but no
|
||||
# churn during a full outage either: promotion needs a healthy
|
||||
# standby, and when everything is dead the pair stands still.
|
||||
for inst in self.actives():
|
||||
p = pens.get(inst.key)
|
||||
if p is not None and p < self.DEAD_AT:
|
||||
continue
|
||||
standbys = [i for i in self.instruments.values()
|
||||
if not i.active and self._healthy(pens, i, now)]
|
||||
if not standbys:
|
||||
continue
|
||||
best = min(standbys, key=lambda i: pens[i.key])
|
||||
changes += filter(None, [self._seat(inst, now, False),
|
||||
self._seat(best, now, True)])
|
||||
|
||||
if now - self._last_reselect < self.RESELECT_EVERY_S:
|
||||
return changes
|
||||
self._last_reselect = now
|
||||
|
||||
# Ordinary re-ranking, damped. The worst seat defends against the
|
||||
# best healthy challenger; a challenger that stops winning starts
|
||||
# over from zero.
|
||||
actives = [i for i in self.actives() if pens.get(i.key) is not None]
|
||||
challengers = [i for i in self.instruments.values()
|
||||
if not i.active and self._healthy(pens, i, now)]
|
||||
for inst in self.instruments.values():
|
||||
if not inst.active and inst not in challengers:
|
||||
inst.pending_wins = 0
|
||||
if not actives or not challengers:
|
||||
return changes
|
||||
worst = max(actives, key=lambda i: pens[i.key])
|
||||
best = min(challengers, key=lambda i: pens[i.key])
|
||||
for c in challengers:
|
||||
if c is not best:
|
||||
c.pending_wins = 0
|
||||
if pens[best.key] < pens[worst.key] * self.MARGIN:
|
||||
best.pending_wins += 1
|
||||
if best.pending_wins >= self.CONSECUTIVE_WINS:
|
||||
changes += filter(None, [self._seat(worst, now, False),
|
||||
self._seat(best, now, True)])
|
||||
else:
|
||||
best.pending_wins = 0
|
||||
return changes
|
||||
|
||||
def snapshot(self, now, stats_by_key):
|
||||
"""For live.json: who is in the pool, who holds a seat, and how
|
||||
each has been measuring — so the shell can show the bench."""
|
||||
out = []
|
||||
for inst in self.instruments.values():
|
||||
st = stats_by_key.get(inst.key) or {}
|
||||
out.append({
|
||||
"key": inst.key, "kind": inst.kind, "target": inst.target,
|
||||
"active": inst.active,
|
||||
"quarantined": now < inst.quarantined_until,
|
||||
"p50": st.get("p50"), "p95": st.get("p95"),
|
||||
# Per instrument, so the field can show what the merged
|
||||
# figure used to hide: jitter measured within one stream.
|
||||
"jitter": st.get("jitter"),
|
||||
"loss": st.get("loss"), "count": st.get("count") or 0,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def merged_stats(sample_lists) -> dict:
|
||||
"""Series.stats over several instruments, each counting once.
|
||||
|
||||
Pooling the seated instruments' raw samples and calling Series.stats on
|
||||
the pile — what this replaces — got two things wrong, and both moved
|
||||
the score:
|
||||
|
||||
* Jitter is RFC 3550 IPDV, the difference between consecutive replies,
|
||||
and consecutive replies in a pooled stream come from different
|
||||
instruments. Two perfectly stable instruments with different base
|
||||
round trips read as jittery: replayed at this line's own figures
|
||||
(ICMP 3.41 ms at 500 ms, TCP 4.82 ms at 1 s) the pool reported
|
||||
0.93 ms of jitter from two streams with none, and on a router that
|
||||
fast-paths ICMP (5 vs 15 ms) it reported 6.6 ms and took ten points
|
||||
off Responsiveness for nothing.
|
||||
* Every instrument was weighted by how often it happened to probe.
|
||||
ICMP follows the probeIntervalMs setting and TCP is fixed at one a
|
||||
second, so changing a setting changed p75, loss and the index while
|
||||
the network stayed the same: the 5/15 case scored 100, 92.7 or 90.3
|
||||
depending only on that number.
|
||||
|
||||
Here each instrument's replies carry weight 1/n, percentiles are the
|
||||
weighted nearest rank over the pool, jitter is the mean of the
|
||||
instruments' own IPDVs, and loss is the mean of their loss rates.
|
||||
One instrument reduces to Series.stats exactly, so `lag_icmp` and the
|
||||
local leg are untouched.
|
||||
"""
|
||||
lists = [lst for lst in sample_lists if lst]
|
||||
if not lists:
|
||||
return Series.stats([])
|
||||
if len(lists) == 1:
|
||||
return Series.stats(lists[0])
|
||||
per = [Series.stats(lst) for lst in lists]
|
||||
count = sum(p["count"] for p in per)
|
||||
loss = statistics.fmean(p["loss"] for p in per)
|
||||
replies = [[smp[1] for smp in lst if smp[1] is not None] for lst in lists]
|
||||
voiced = [r for r in replies if r]
|
||||
if not voiced:
|
||||
return {"count": count, "loss": loss, "p50": None, "p75": None,
|
||||
"p95": None, "jitter": None, "last": None, "max": None}
|
||||
pooled = []
|
||||
for r in voiced:
|
||||
w = 1.0 / len(r)
|
||||
pooled.extend((v, w) for v in r)
|
||||
pooled.sort(key=lambda x: x[0])
|
||||
total_w = float(len(voiced))
|
||||
|
||||
def pct(p):
|
||||
target = p * total_w - 1e-9
|
||||
cum = 0.0
|
||||
for v, w in pooled:
|
||||
cum += w
|
||||
if cum >= target:
|
||||
return v
|
||||
return pooled[-1][0]
|
||||
|
||||
jitters = [p["jitter"] for p in per if p["jitter"] is not None]
|
||||
newest = None
|
||||
for lst in lists:
|
||||
for smp in reversed(lst):
|
||||
if smp[1] is not None:
|
||||
if newest is None or smp[0] > newest[0]:
|
||||
newest = smp
|
||||
break
|
||||
return {
|
||||
"count": count,
|
||||
"loss": loss,
|
||||
"p50": round(pct(0.5), 2),
|
||||
"p75": round(pct(0.75), 2),
|
||||
"p95": round(pct(0.95), 2),
|
||||
"max": round(pooled[-1][0], 2),
|
||||
"jitter": round(statistics.fmean(jitters), 2) if jitters else 0.0,
|
||||
"last": round(newest[1], 2) if newest else None,
|
||||
}
|
||||
|
||||
|
||||
class MergedSeries:
|
||||
"""A read-only view over whichever instruments hold the seats.
|
||||
|
||||
`.since()` and `.all()` return the pooled stream in time order — right
|
||||
for anything that asks "did anyone reply between these two moments",
|
||||
which is what outage detection does. Anything that turns the leg into
|
||||
statistics goes through `.stats()` / `.each()` and `merged_stats`,
|
||||
where the instruments count equally; see that function for why the
|
||||
pooled stream must not be fed to Series.stats.
|
||||
"""
|
||||
|
||||
def __init__(self, series_fn):
|
||||
self._series_fn = series_fn # -> [Series] of the active seats
|
||||
|
||||
def each(self, seconds: float = None):
|
||||
"""One sample list per seated instrument, the shape merged_stats wants."""
|
||||
if seconds is None:
|
||||
return [s.all() for s in self._series_fn()]
|
||||
return [s.since(seconds) for s in self._series_fn()]
|
||||
|
||||
def stats(self, seconds: float) -> dict:
|
||||
return merged_stats(self.each(seconds))
|
||||
|
||||
def since(self, seconds: float):
|
||||
out = []
|
||||
for s in self._series_fn():
|
||||
out.extend(s.since(seconds))
|
||||
out.sort(key=lambda smp: smp[0])
|
||||
return out
|
||||
|
||||
def all(self):
|
||||
out = []
|
||||
for s in self._series_fn():
|
||||
out.extend(s.all())
|
||||
out.sort(key=lambda smp: smp[0])
|
||||
return out
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Who ended the last Wi-Fi association: the access point, this machine,
|
||||
or nobody (the link fell over).
|
||||
|
||||
A BSSID change seen through `iw dev link` is only a fact: we were on one
|
||||
radio and now we are on another. Whether that was the client's decision,
|
||||
the access point's, or a link failure is the difference between a laptop
|
||||
that roams, a router that kicks, and a driver that drops — three problems
|
||||
with three owners, and the log used to call all of them "Roamed to".
|
||||
|
||||
`iw event` is the unprivileged view of nl80211's mlme multicast group.
|
||||
Every deauthentication and disassociation frame the kernel sends or
|
||||
receives is reported with its sender, receiver and 802.11 reason code, and
|
||||
the authentication that follows is reported too. That is enough:
|
||||
|
||||
- the frame's sender says who ended it — the AP's address means it kicked
|
||||
us, our own means this machine did;
|
||||
- the reason code says why, in the AP's or the driver's own words;
|
||||
- the delay before the next authentication says whether the client already
|
||||
knew where it was going. mac80211 emits the local deauth from inside the
|
||||
call that starts the new authentication when it roams, so a roam's gap is
|
||||
milliseconds; a lost link is followed by a scan first, and its gap is
|
||||
seconds.
|
||||
|
||||
Only the kernel talks to this module, through `iw`. Lines are read with a
|
||||
length cap and matched against fixed patterns; two MACs and a reason code
|
||||
are the only fields kept, and only our own reason texts reach the shell.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
# `iw event -t` line: "<secs>.<usecs>: <ifname> (phy #N): <event...>". The
|
||||
# timestamp is CLOCK_REALTIME — the clock the daemon stamps with — and it
|
||||
# is optional so recorded fixtures read the same with or without it.
|
||||
_PREFIX = r"^(?:(\d+\.\d+): )?\S+(?: \(phy #\d+\))?: "
|
||||
_MAC = r"([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})"
|
||||
_ANY_MAC = r"[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}"
|
||||
# A deauth/disassoc frame: "<sender> -> <receiver> reason N: <text>".
|
||||
# "unprotected deauth" (a forged frame the kernel refused) does not match:
|
||||
# the frame word must follow the prefix directly.
|
||||
RE_FRAME = re.compile(_PREFIX + r"(deauth|disassoc) " + _MAC + " -> " + _MAC
|
||||
+ r" reason (\d{1,5})\b")
|
||||
# The first sign of the next association landing.
|
||||
RE_NEXT = re.compile(_PREFIX + r"(?:(?:auth|assoc) " + _ANY_MAC + " -> "
|
||||
+ _ANY_MAC + r" status: 0\b|(?:connected|roamed) to "
|
||||
+ _ANY_MAC + r"\b)")
|
||||
|
||||
# 802.11 reason codes, in the words a person would use. Codes 3 and 4 mean
|
||||
# different things depending on who sent them, so they get a side each
|
||||
# (a GX gateway's per-station kick arrives as 8, for instance).
|
||||
REASONS = {
|
||||
1: "unspecified",
|
||||
2: "previous authentication no longer valid",
|
||||
5: "the AP is full",
|
||||
6: "class 2 frame from an unauthenticated station",
|
||||
7: "class 3 frame from an unassociated station",
|
||||
9: "not authenticated",
|
||||
14: "MIC failure",
|
||||
15: "4-way handshake timeout",
|
||||
16: "group key handshake timeout",
|
||||
17: "IE mismatch in the 4-way handshake",
|
||||
23: "802.1X authentication failed",
|
||||
34: "poor channel conditions",
|
||||
39: "timeout",
|
||||
}
|
||||
AP_REASONS = {3: "the AP is leaving", 4: "inactivity", 8: "the AP is leaving the BSS"}
|
||||
LOCAL_REASONS = {3: "leaving", 4: "beacon loss", 8: "leaving the BSS"}
|
||||
|
||||
|
||||
def reason_text(code, by_ap):
|
||||
"""'reason 2: previous authentication no longer valid'. Only our own
|
||||
words, never the tool's: nothing from the wire reaches the shell."""
|
||||
side = AP_REASONS if by_ap else LOCAL_REASONS
|
||||
text = side.get(code) or REASONS.get(code)
|
||||
return "reason %d: %s" % (code, text) if text else "reason %d" % code
|
||||
|
||||
|
||||
class NlEvents(threading.Thread):
|
||||
"""Runs `iw event` forever, restarting it if it dies, keeping the last
|
||||
few deauth/disassoc frames and when the next authentication followed.
|
||||
|
||||
Like the probes, this never raises into the daemon: without `iw`, or
|
||||
when the socket is refused, the thread backs off and `cause_for`
|
||||
answers None — and the link log falls back to plain "Roamed to".
|
||||
"""
|
||||
|
||||
MAX_CAUSES = 64
|
||||
LINE_CAP = 1024
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="nl-events", daemon=True)
|
||||
self._stop = threading.Event()
|
||||
self._proc = None
|
||||
self._lock = threading.Lock()
|
||||
self._causes = deque(maxlen=self.MAX_CAUSES)
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
proc = self._proc
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
backoff = 1.0
|
||||
while not self._stop.is_set():
|
||||
if not shutil.which("iw"):
|
||||
time.sleep(min(backoff, 30.0))
|
||||
backoff = min(backoff * 2, 30.0)
|
||||
continue
|
||||
try:
|
||||
self._run_once()
|
||||
except Exception:
|
||||
pass # a spawn or parse failure is a retry, not a crash
|
||||
if not self._stop.is_set():
|
||||
# `iw event` never exits on its own; if it did, nl80211
|
||||
# refused us or the tool is broken — do not spin on it.
|
||||
time.sleep(min(backoff, 30.0))
|
||||
backoff = min(backoff * 2, 30.0)
|
||||
|
||||
def _run_once(self):
|
||||
self._proc = subprocess.Popen(
|
||||
["iw", "event", "-t"], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, text=True, errors="replace", bufsize=1,
|
||||
)
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
# A capped read: an over-long line comes back in pieces
|
||||
# that match nothing, instead of growing a buffer.
|
||||
line = self._proc.stdout.readline(self.LINE_CAP)
|
||||
if not line:
|
||||
break
|
||||
self.consume(line)
|
||||
finally:
|
||||
proc, self._proc = self._proc, None
|
||||
if proc:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Would not go quietly: do not leave it running.
|
||||
try:
|
||||
proc.kill()
|
||||
proc.wait(timeout=2)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def consume(self, line: str, now: float = None):
|
||||
m = RE_FRAME.match(line)
|
||||
if m:
|
||||
t = float(m.group(1)) if m.group(1) else (now or time.time())
|
||||
with self._lock:
|
||||
self._causes.append({
|
||||
"t": t, "frame": m.group(2),
|
||||
"sa": m.group(3).lower(), "da": m.group(4).lower(),
|
||||
"reason": min(int(m.group(5)), 65535), "next_at": None,
|
||||
})
|
||||
return
|
||||
m = RE_NEXT.match(line)
|
||||
if m:
|
||||
t = float(m.group(1)) if m.group(1) else (now or time.time())
|
||||
with self._lock:
|
||||
# Only the newest cause is still waiting for its follow-up.
|
||||
if self._causes and self._causes[-1]["next_at"] is None:
|
||||
self._causes[-1]["next_at"] = t
|
||||
|
||||
def cause_for(self, bssid: str, now: float, window: float):
|
||||
"""The latest frame within `window` seconds that ended our
|
||||
association with `bssid`, or None.
|
||||
|
||||
by_ap: the AP sent it. gap_s: seconds until the next authentication
|
||||
was seen, None if none has been yet.
|
||||
"""
|
||||
bssid = (bssid or "").lower()
|
||||
if not bssid:
|
||||
return None
|
||||
with self._lock:
|
||||
for c in reversed(self._causes):
|
||||
if now - c["t"] > window:
|
||||
break
|
||||
if bssid not in (c["sa"], c["da"]):
|
||||
continue
|
||||
gap = None if c["next_at"] is None else max(0.0, c["next_at"] - c["t"])
|
||||
return {"t": c["t"], "frame": c["frame"], "by_ap": c["sa"] == bssid,
|
||||
"reason": c["reason"], "gap_s": gap}
|
||||
return None
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Reading the local end of the connection: route, interface, Wi-Fi link.
|
||||
|
||||
Everything here is a cheap read of /sys or a short-lived `ip` / `iw` call.
|
||||
Nothing in this module blocks for longer than its subprocess timeout, and
|
||||
every function degrades to None or {} rather than raising, because a laptop
|
||||
that just suspended will fail all of them at once.
|
||||
"""
|
||||
|
||||
import json
|
||||
import ipaddress
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _run(cmd, timeout=2.0) -> Optional[str]:
|
||||
if not shutil.which(cmd[0]):
|
||||
return None
|
||||
try:
|
||||
out = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=timeout, check=False
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return None
|
||||
return out.stdout if out.returncode == 0 else None
|
||||
|
||||
|
||||
# Gateway ranges that phone and desktop tethering hand out. Each is fixed by
|
||||
# its vendor and documented, so this is a table lookup with no network call
|
||||
# and nothing to keep up to date. It is the only reliable signal available:
|
||||
# iOS randomises both the hotspot BSSID and the gateway's hardware address
|
||||
# (checked on a live hotspot — `66:f8:f9:…` and `a2:ee:1a:…`, both with the
|
||||
# locally-administered bit set), so vendor lookup on either is useless, and
|
||||
# NetworkManager reports such a connection as "no (guessed)" rather than
|
||||
# metered.
|
||||
TETHER_RANGES = (
|
||||
# iOS Personal Hotspot, over Wi-Fi or USB. A /28 with the phone at .1.
|
||||
("172.20.10.0/28", "ios", "iPhone"),
|
||||
# Android Wi-Fi tethering, and its USB counterpart.
|
||||
("192.168.43.0/24", "android", "Phone"),
|
||||
("192.168.42.0/24", "android", "Phone"),
|
||||
# Windows mobile hotspot.
|
||||
("192.168.137.0/24", "windows", "Hotspot"),
|
||||
)
|
||||
|
||||
|
||||
def tether_from_gateway(gateway: str):
|
||||
"""Is this gateway a phone sharing its connection? Pure, so it is tested.
|
||||
|
||||
Returns `{"kind", "label"}` or None. `label` is what to call the middle
|
||||
node of the path — it is a phone, not a router, and drawing a router
|
||||
there quietly mislabels both legs.
|
||||
"""
|
||||
if not gateway:
|
||||
return None
|
||||
try:
|
||||
addr = ipaddress.ip_address(gateway)
|
||||
except ValueError:
|
||||
return None
|
||||
for cidr, kind, label in TETHER_RANGES:
|
||||
try:
|
||||
if addr in ipaddress.ip_network(cidr):
|
||||
return {"kind": kind, "label": label}
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def nm_metered(iface: str) -> bool:
|
||||
"""Has the user explicitly marked this connection metered?
|
||||
|
||||
Only an explicit answer counts. NetworkManager guesses by default and
|
||||
guesses wrong on a phone hotspot — a live one reports
|
||||
`no (guessed)` — so a guess is treated as no answer at all rather than
|
||||
as evidence either way.
|
||||
"""
|
||||
if not iface:
|
||||
return False
|
||||
# No binary check here: _run already answers None when nmcli is absent,
|
||||
# and a second check in front of it kept the parser out of reach of the
|
||||
# tests on a machine without NetworkManager — which is what CI is.
|
||||
raw = _run(["nmcli", "-t", "-f", "GENERAL.METERED", "dev", "show", iface],
|
||||
timeout=4.0)
|
||||
if not raw:
|
||||
return False
|
||||
value = raw.split(":", 1)[-1].strip().lower() if ":" in raw else ""
|
||||
return value.startswith("yes") and "guess" not in value
|
||||
|
||||
|
||||
def route_to(anchor: str = "1.1.1.1") -> dict:
|
||||
"""The interface, gateway and source address used to reach the anchor.
|
||||
|
||||
This is the single source of truth for "which connection am I on" — the
|
||||
gateway it returns is the router leg's ping target.
|
||||
"""
|
||||
raw = _run(["ip", "-j", "route", "get", anchor])
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
rows = json.loads(raw)
|
||||
except ValueError:
|
||||
return {}
|
||||
if not rows:
|
||||
return {}
|
||||
r = rows[0]
|
||||
return {
|
||||
"iface": r.get("dev") or "",
|
||||
"gateway": r.get("gateway") or "",
|
||||
"src": r.get("prefsrc") or "",
|
||||
}
|
||||
|
||||
|
||||
def is_wireless(iface: str) -> bool:
|
||||
if not iface:
|
||||
return False
|
||||
from pathlib import Path
|
||||
|
||||
return Path(f"/sys/class/net/{iface}/wireless").is_dir()
|
||||
|
||||
|
||||
def counters(iface: str) -> Optional[tuple]:
|
||||
"""(rx_bytes, tx_bytes) straight off /sys, or None if the iface vanished."""
|
||||
if not iface:
|
||||
return None
|
||||
try:
|
||||
base = f"/sys/class/net/{iface}/statistics/"
|
||||
with open(base + "rx_bytes") as f:
|
||||
rx = int(f.read().strip())
|
||||
with open(base + "tx_bytes") as f:
|
||||
tx = int(f.read().strip())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return rx, tx
|
||||
|
||||
|
||||
def _num(text: str):
|
||||
"""First number in a string, as int when it is whole.
|
||||
|
||||
`iw` is inconsistent across versions — this machine reports
|
||||
`freq: 5180.0` where older builds print `freq: 5180`.
|
||||
"""
|
||||
m = re.search(r"-?\d+(?:\.\d+)?", text or "")
|
||||
if not m:
|
||||
return None
|
||||
v = float(m.group(0))
|
||||
return int(v) if v.is_integer() else v
|
||||
|
||||
|
||||
def wifi_link(iface: str) -> dict:
|
||||
"""SSID, signal, band and negotiated rates from `iw dev <iface> link`."""
|
||||
raw = _run(["iw", "dev", iface, "link"])
|
||||
if not raw or "Not connected" in raw:
|
||||
return {}
|
||||
info = {}
|
||||
bssid = re.search(r"Connected to ([0-9a-fA-F:]{17})", raw)
|
||||
if bssid:
|
||||
info["bssid"] = bssid.group(1)
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("SSID:"):
|
||||
info["ssid"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("freq:"):
|
||||
info["freq_mhz"] = _num(line)
|
||||
elif line.startswith("signal:"):
|
||||
info["signal_dbm"] = _num(line)
|
||||
elif line.startswith("rx bitrate:"):
|
||||
info["rx_mbps"] = _num(line)
|
||||
elif line.startswith("tx bitrate:"):
|
||||
info["tx_mbps"] = _num(line)
|
||||
width = re.search(r"(\d+)MHz", line)
|
||||
if width:
|
||||
info["width_mhz"] = int(width.group(1))
|
||||
for std, tag in (("HE", "802.11ax"), ("VHT", "802.11ac"), ("HT", "802.11n")):
|
||||
if f"{std}-MCS" in line:
|
||||
info["standard"] = tag
|
||||
break
|
||||
freq = info.get("freq_mhz")
|
||||
if freq:
|
||||
info["band"] = "6 GHz" if freq >= 5955 else "5 GHz" if freq >= 4900 else "2.4 GHz"
|
||||
info["channel"] = _freq_to_channel(freq)
|
||||
return info
|
||||
|
||||
|
||||
def _freq_to_channel(freq) -> Optional[int]:
|
||||
f = int(freq)
|
||||
if f == 2484:
|
||||
return 14
|
||||
if 2412 <= f <= 2472:
|
||||
return (f - 2407) // 5
|
||||
if 5160 <= f <= 5885:
|
||||
return (f - 5000) // 5
|
||||
if 5955 <= f <= 7115:
|
||||
return (f - 5950) // 5
|
||||
return None
|
||||
|
||||
|
||||
def wifi_station(iface: str) -> dict:
|
||||
"""Airtime health from `iw station dump`.
|
||||
|
||||
Retries and failures are why Wi-Fi feels slow while the signal bar still
|
||||
looks full, so they are worth the extra call.
|
||||
"""
|
||||
raw = _run(["iw", "dev", iface, "station", "dump"])
|
||||
if not raw:
|
||||
return {}
|
||||
fields = {
|
||||
"tx retries:": "tx_retries",
|
||||
"tx failed:": "tx_failed",
|
||||
"beacon loss:": "beacon_loss",
|
||||
"rx drop misc:": "rx_drop_misc",
|
||||
"tx packets:": "tx_packets",
|
||||
"rx packets:": "rx_packets",
|
||||
"signal avg:": "signal_avg_dbm",
|
||||
"inactive time:": "inactive_ms",
|
||||
}
|
||||
out = {}
|
||||
for line in raw.splitlines():
|
||||
s = line.strip()
|
||||
for prefix, key in fields.items():
|
||||
if s.startswith(prefix):
|
||||
v = _num(s[len(prefix):])
|
||||
if v is not None:
|
||||
out[key] = v
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def connection_name(iface: str) -> str:
|
||||
"""The name NetworkManager shows, which is what the user calls this network."""
|
||||
raw = _run(["nmcli", "-t", "-f", "GENERAL.CONNECTION", "dev", "show", iface])
|
||||
if not raw:
|
||||
return ""
|
||||
for line in raw.splitlines():
|
||||
if line.startswith("GENERAL.CONNECTION:"):
|
||||
name = line.split(":", 1)[1].strip()
|
||||
return "" if name in ("", "--") else name
|
||||
return ""
|
||||
|
||||
|
||||
# The connection's name is a NetworkManager fact: it changes when the
|
||||
# route does, or when the user renames it, and nmcli is a D-Bus client
|
||||
# that costs ~27 ms per call on this laptop — three quarters of what a
|
||||
# whole snapshot cost when it was asked twice a second. The name is
|
||||
# cached per (interface, gateway, BSSID) and re-asked on that key
|
||||
# changing or every NAME_CACHE_TTL_S, whichever comes first.
|
||||
NAME_CACHE_TTL_S = 60.0
|
||||
_name_cache = {} # iface -> (key, name, expires_at)
|
||||
|
||||
|
||||
def connection_name_cached(iface: str, key, now: float = None,
|
||||
ttl: float = NAME_CACHE_TTL_S) -> str:
|
||||
now = time.time() if now is None else now
|
||||
hit = _name_cache.get(iface)
|
||||
if hit and hit[0] == key and now < hit[2]:
|
||||
return hit[1]
|
||||
name = connection_name(iface)
|
||||
_name_cache[iface] = (key, name, now + ttl)
|
||||
return name
|
||||
|
||||
|
||||
def snapshot(anchor: str = "1.1.1.1") -> dict:
|
||||
"""Everything about the local end, in one call, safe to run twice a second."""
|
||||
route = route_to(anchor)
|
||||
iface = route.get("iface", "")
|
||||
snap = {
|
||||
"iface": iface,
|
||||
"gateway": route.get("gateway", ""),
|
||||
"src": route.get("src", ""),
|
||||
"kind": "none",
|
||||
}
|
||||
if not iface:
|
||||
return snap
|
||||
snap["kind"] = "wifi" if is_wireless(iface) else "ethernet"
|
||||
if snap["kind"] == "wifi":
|
||||
snap.update(wifi_link(iface))
|
||||
snap["station"] = wifi_station(iface)
|
||||
key = (iface, snap["gateway"], snap.get("bssid", ""))
|
||||
snap["name"] = connection_name_cached(iface, key) or iface
|
||||
return snap
|
||||
|
||||
|
||||
# ------------------------------------------------------------- wan address
|
||||
|
||||
TRACE_URL = "https://speed.cloudflare.com/cdn-cgi/trace"
|
||||
|
||||
|
||||
def parse_trace(text: str) -> Optional[dict]:
|
||||
"""The address, country and edge from a cdn-cgi/trace response.
|
||||
|
||||
Every value is validated against its own shape and anything else the
|
||||
response carries is discarded unread — an address `ipaddress` accepts,
|
||||
a two-letter country, a short alphabetic colo code. Nothing that fails
|
||||
its check is passed on, so no free text from the wire reaches the
|
||||
shell. Input is bounded before it is split, so an oversized body costs
|
||||
one slice.
|
||||
|
||||
The country and edge come free: they are already in the response the
|
||||
reachability check fetches every hour. Reading them adds no request
|
||||
and no new destination, which is the whole reason they are here rather
|
||||
than from a geolocation service that would learn every user's address.
|
||||
The edge is Cloudflare's, not the user's — it says which datacentre
|
||||
answered, so present it as provenance and never as a location.
|
||||
"""
|
||||
out = {}
|
||||
for line in text[:4096].splitlines()[:64]:
|
||||
if line.startswith("ip="):
|
||||
try:
|
||||
addr = ipaddress.ip_address(line[3:].strip())
|
||||
except ValueError:
|
||||
return None
|
||||
out["ip"] = str(addr)
|
||||
out["family"] = "v6" if addr.version == 6 else "v4"
|
||||
elif line.startswith("loc="):
|
||||
# Cloudflare answers XX when it does not know, which is not a
|
||||
# country and must not be shown as one.
|
||||
code = line[4:].strip()
|
||||
if len(code) == 2 and code.isascii() and code.isalpha() \
|
||||
and code.isupper() and code != "XX":
|
||||
out["country"] = code
|
||||
elif line.startswith("colo="):
|
||||
edge = line[5:].strip()
|
||||
if 2 <= len(edge) <= 5 and edge.isascii() and edge.isalpha() \
|
||||
and edge.isupper():
|
||||
out["edge"] = edge
|
||||
# The address is the point; country and edge are decoration on it.
|
||||
return out if "ip" in out else None
|
||||
|
||||
|
||||
def trace_verdict(raw) -> str:
|
||||
"""Did the real internet answer? `open` | `intercepted` | `silent`.
|
||||
|
||||
The same fetch that reads the WAN address is also the only thing here
|
||||
that can tell the real internet from something standing in for it. A
|
||||
probe reply proves a packet came back; it does not prove what sent it.
|
||||
A captive portal, a transparent proxy or any middlebox will happily
|
||||
complete a handshake and answer for an address it does not own — which
|
||||
is how an unauthenticated hotel network produced a healthy-looking
|
||||
internet leg with no internet behind it.
|
||||
|
||||
`open` is the only positive claim, and it needs the response to parse as
|
||||
a trace with an address `ipaddress` accepts. Something that answered with
|
||||
anything else is `intercepted`. Nothing at all is `silent`.
|
||||
|
||||
Honest limit: the fetch runs `curl -f`, so a portal that answers with a
|
||||
4xx/5xx, a redirect with an empty body, or a certificate that does not
|
||||
validate for the host all come back as nothing — `silent`. In practice
|
||||
`intercepted` needs a portal that serves a 200 over a certificate valid
|
||||
for speed.cloudflare.com, which is rare. The captive decision does not
|
||||
care (both verdicts count against `open`); only this label does.
|
||||
"""
|
||||
if not raw:
|
||||
return "silent"
|
||||
return "open" if parse_trace(raw) else "intercepted"
|
||||
|
||||
|
||||
def reachability() -> dict:
|
||||
"""One reachability check: the verdict, plus the address when proven.
|
||||
|
||||
This is also where the WAN address comes from — the `proof` — so the
|
||||
machine's address is only ever asked of a host the daemon talks to
|
||||
anyway, never of an ifconfig-style third party. The URL is our
|
||||
constant, never anything a response handed us. Cadence is the caller's
|
||||
(CaptiveWatch): on every new network, hourly once the internet has
|
||||
answered, every 30 s only while it has not.
|
||||
"""
|
||||
raw = _run(["curl", "-sf", "--proto", "=https", "--max-time", "5",
|
||||
"--max-filesize", "4096", TRACE_URL], timeout=8.0)
|
||||
verdict = trace_verdict(raw)
|
||||
return {"verdict": verdict,
|
||||
"proof": parse_trace(raw) if verdict == "open" else None}
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Where Nexthop keeps its state.
|
||||
|
||||
Two directories since 0.2.22. The state dir (XDG_STATE_HOME, like the rest
|
||||
of Omarchy) holds what must outlive the session: history.db, the
|
||||
config.json the panel writes for the daemon, and the lock so two daemons
|
||||
never fight. The runtime dir (XDG_RUNTIME_DIR, a per-user tmpfs) holds the
|
||||
three snapshots the daemon rewrites continuously — live.json twice a
|
||||
second, apps.json every three, recent.json every five. They are derived,
|
||||
session-scoped, and worth nothing after a reboot, and on this laptop's
|
||||
btrfs each 2.5 KB rewrite cost 62.5 KiB at the block layer with fsync:
|
||||
14.65 GB a day for a bar widget, measured from the kernel's own counter.
|
||||
On tmpfs it is nothing. Without XDG_RUNTIME_DIR they fall back to the
|
||||
state dir, so reader and writer always agree in one session environment.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def state_dir() -> Path:
|
||||
base = os.environ.get("XDG_STATE_HOME") or os.path.join(Path.home(), ".local", "state")
|
||||
return Path(base) / "nexthop"
|
||||
|
||||
|
||||
def ensure_state_dir() -> Path:
|
||||
"""Create the state dir, private to the user (0700).
|
||||
|
||||
The files inside carry the daemon's pid and the version string the
|
||||
shell service uses to authorize a SIGTERM — nothing another account
|
||||
has any business reading, let alone writing.
|
||||
"""
|
||||
d = state_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
d.chmod(0o700)
|
||||
except OSError:
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
def runtime_dir() -> Path:
|
||||
"""The volatile snapshots' home: the session's tmpfs, else the state dir."""
|
||||
base = os.environ.get("XDG_RUNTIME_DIR")
|
||||
if base and os.path.isdir(base):
|
||||
return Path(base) / "nexthop"
|
||||
return state_dir()
|
||||
|
||||
|
||||
def ensure_runtime_dir() -> Path:
|
||||
"""Create the runtime dir, private to the user like the state dir."""
|
||||
d = runtime_dir()
|
||||
d.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
try:
|
||||
d.chmod(0o700)
|
||||
except OSError:
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
LIVE = "live.json"
|
||||
RECENT = "recent.json"
|
||||
APPS = "apps.json"
|
||||
DB = "history.db"
|
||||
LOCK = "nexthopd.lock"
|
||||
|
||||
|
||||
def live_path() -> Path:
|
||||
return runtime_dir() / LIVE
|
||||
|
||||
|
||||
def recent_path() -> Path:
|
||||
return runtime_dir() / RECENT
|
||||
|
||||
|
||||
def apps_path() -> Path:
|
||||
return runtime_dir() / APPS
|
||||
|
||||
|
||||
def manifest_path() -> Path:
|
||||
"""The plugin's own manifest, beside this package rather than in the
|
||||
state dir — the shell service reads it to spot a fast-forwarded
|
||||
checkout."""
|
||||
return Path(__file__).resolve().parent.parent / "manifest.json"
|
||||
|
||||
|
||||
def db_path() -> Path:
|
||||
return state_dir() / DB
|
||||
|
||||
|
||||
def lock_path() -> Path:
|
||||
return state_dir() / LOCK
|
||||
@@ -0,0 +1,484 @@
|
||||
"""Persistent ping and TCP-handshake probes, and the rolling windows they feed.
|
||||
|
||||
One long-lived `ping` process per target rather than one process per sample.
|
||||
At two samples a second, spawning a process each time would mean 172,800
|
||||
forks a day inside a laptop's idle budget; `ping -i` already does the timing
|
||||
for us, and `-O` makes it say so out loud when a packet goes missing.
|
||||
"""
|
||||
|
||||
import collections
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import statistics
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
|
||||
def nearest_rank(ordered, p: float):
|
||||
"""Nearest-rank percentile of a NON-EMPTY sorted sequence.
|
||||
|
||||
One implementation shared by the probe stats, the per-app socket stats
|
||||
and the speed baseline, so the three cannot drift apart — they read the
|
||||
same figure off the same rule. The index formula already collapses to
|
||||
element 0 for a single-element input, so no length special-case.
|
||||
"""
|
||||
i = min(len(ordered) - 1, max(0, int(round(p * (len(ordered) - 1)))))
|
||||
return ordered[i]
|
||||
|
||||
# [1787562260.703963] 64 bytes from 10.10.0.1: icmp_seq=1 ttl=64 time=9.13 ms
|
||||
RE_REPLY = re.compile(r"^\[(\d+\.\d+)\].*icmp_seq=(\d+).*time=([\d.]+)\s*ms")
|
||||
# [1787562369.690501] no answer yet for icmp_seq=1
|
||||
RE_PENDING = re.compile(r"^\[(\d+\.\d+)\]\s+no answer yet for icmp_seq=(\d+)")
|
||||
# [...] From 10.10.0.147 icmp_seq=1 Destination Host Unreachable
|
||||
RE_UNREACH = re.compile(r"^\[(\d+\.\d+)\].*icmp_seq=(\d+).*(?:Unreachable|unreachable)")
|
||||
|
||||
|
||||
# How much recent history judges an instrument, and the fewest samples that
|
||||
# can carry a judgement at all.
|
||||
#
|
||||
# Shared, deliberately, by the two places that ask "what has this instrument
|
||||
# been doing lately": the bench, which ranks instruments over a window, and
|
||||
# TcpProbe, which needs its own recent p50 to tell a retransmit from a slow
|
||||
# path. They live here rather than on the bench because `instruments` imports
|
||||
# this module and not the other way round, and an alias in the direction the
|
||||
# imports already run is the only one Python will take.
|
||||
#
|
||||
# The point of aliasing rather than repeating the number: two literals plus a
|
||||
# test catches drift on the next test run, an alias makes the drift
|
||||
# impossible. `test_the_window_matches_what_the_bench_ranks_on` is kept even
|
||||
# though it now passes by construction — it catches someone replacing an
|
||||
# alias with a literal, which is the drift it was written against.
|
||||
RECENT_WINDOW_S = 300.0
|
||||
RECENT_MIN_SAMPLES = 8
|
||||
|
||||
|
||||
class Series:
|
||||
"""A rolling window of (timestamp, rtt_ms or None) for one target.
|
||||
|
||||
None means the probe went out and nothing came back. Keeping losses in
|
||||
the same series as the replies is what lets a single pass compute both
|
||||
latency and loss over any sub-window.
|
||||
"""
|
||||
|
||||
def __init__(self, window_s: float = 1830.0):
|
||||
self.window_s = window_s
|
||||
self._samples = deque()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def add(self, t: float, rtt, loaded: bool = False):
|
||||
"""Record one probe result, tagged with whether the link was busy.
|
||||
|
||||
The tag is what makes bufferbloat visible: the same connection can
|
||||
answer in 15 ms while idle and 300 ms while a download runs, and a
|
||||
score built only on the idle number calls that line excellent right
|
||||
up until someone uses it.
|
||||
"""
|
||||
with self._lock:
|
||||
self._samples.append((t, rtt, bool(loaded)))
|
||||
cutoff = t - self.window_s
|
||||
while self._samples and self._samples[0][0] < cutoff:
|
||||
self._samples.popleft()
|
||||
|
||||
def since(self, seconds: float):
|
||||
cutoff = time.time() - seconds
|
||||
with self._lock:
|
||||
return [s for s in self._samples if s[0] >= cutoff]
|
||||
|
||||
def all(self):
|
||||
with self._lock:
|
||||
return list(self._samples)
|
||||
|
||||
@staticmethod
|
||||
def split_by_load(samples):
|
||||
"""(idle, loaded) — probes taken while the link was quiet vs busy.
|
||||
|
||||
Samples are indexed rather than unpacked throughout, so a caller
|
||||
holding older two-element samples still reads as idle instead of
|
||||
raising.
|
||||
"""
|
||||
idle = [s for s in samples if not (len(s) > 2 and s[2])]
|
||||
loaded = [s for s in samples if len(s) > 2 and s[2]]
|
||||
return idle, loaded
|
||||
|
||||
@staticmethod
|
||||
def stats(samples) -> dict:
|
||||
"""Latency percentiles, jitter and loss over the samples given.
|
||||
|
||||
Jitter is mean absolute difference between consecutive replies
|
||||
(RFC 3550's IPDV), not standard deviation: a connection that
|
||||
alternates 10/40/10/40 ms feels far worse than one that drifts
|
||||
smoothly across the same range, and only IPDV says so.
|
||||
"""
|
||||
total = len(samples)
|
||||
if total == 0:
|
||||
return {"count": 0, "loss": None, "p50": None, "p75": None,
|
||||
"p95": None, "jitter": None, "last": None, "max": None}
|
||||
|
||||
rtts = [s[1] for s in samples if s[1] is not None]
|
||||
lost = total - len(rtts)
|
||||
loss = lost / total
|
||||
|
||||
if not rtts:
|
||||
return {"count": total, "loss": loss, "p50": None, "p75": None,
|
||||
"p95": None, "jitter": None, "last": None, "max": None}
|
||||
|
||||
ordered = sorted(rtts)
|
||||
|
||||
deltas = [abs(rtts[i] - rtts[i - 1]) for i in range(1, len(rtts))]
|
||||
last = next((x[1] for x in reversed(samples) if x[1] is not None), None)
|
||||
|
||||
return {
|
||||
"count": total,
|
||||
"loss": loss,
|
||||
"p50": round(statistics.median(ordered), 2),
|
||||
"p75": round(nearest_rank(ordered, 0.75), 2),
|
||||
"p95": round(nearest_rank(ordered, 0.95), 2),
|
||||
"max": round(ordered[-1], 2),
|
||||
"jitter": round(statistics.fmean(deltas), 2) if deltas else 0.0,
|
||||
"last": round(last, 2) if last is not None else None,
|
||||
}
|
||||
|
||||
|
||||
class PingProbe(threading.Thread):
|
||||
"""Runs one `ping` forever, restarting it if it dies, feeding a Series.
|
||||
|
||||
A probe never raises into the daemon: if `ping` is missing, the target
|
||||
stops resolving, or the interface goes away, the thread backs off and
|
||||
keeps trying while the series simply records losses.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, target: str, series: Series, interval_ms: int = 500,
|
||||
name: str = "", loaded_fn=None):
|
||||
super().__init__(name=f"probe-{name or target}", daemon=True)
|
||||
self.target = target
|
||||
self.series = series
|
||||
# Asked at the moment a sample lands, so each probe is tagged with
|
||||
# the link state it actually experienced rather than whatever the
|
||||
# link was doing when the window is later read.
|
||||
self.loaded_fn = loaded_fn
|
||||
self.interval = max(0.2, interval_ms / 1000.0)
|
||||
self._stop = threading.Event()
|
||||
self._proc = None
|
||||
# seq -> timestamp first seen unanswered, drained by _expire()
|
||||
self._pending = {}
|
||||
# seq -> when it was charged as lost. A packet the grace period gave
|
||||
# up on can still be reported afterwards — the gateway's Destination
|
||||
# Host Unreachable for it arrives later than the grace, in the real
|
||||
# recording by 0.35 s — and without this it would be charged twice.
|
||||
# Held for one further grace period, which is as long as a late report
|
||||
# can be believed to belong to that packet.
|
||||
self._charged = {}
|
||||
|
||||
def _loaded(self) -> bool:
|
||||
try:
|
||||
return bool(self.loaded_fn()) if self.loaded_fn else False
|
||||
except Exception:
|
||||
return False # a probe never raises into the daemon
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
proc = self._proc
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def set_interval(self, seconds: float):
|
||||
"""Change cadence in place — a benched instrument idles, a seated
|
||||
one probes at full rate, without tearing the thread down. `ping`
|
||||
takes its interval on the command line, so the running process is
|
||||
retired and the run loop respawns it with the new one."""
|
||||
seconds = max(0.2, float(seconds))
|
||||
if abs(seconds - self.interval) < 1e-9:
|
||||
return
|
||||
self.interval = seconds
|
||||
proc = self._proc
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _reset_tracking(self):
|
||||
"""Forget both maps together.
|
||||
|
||||
`ping` numbers from 1 again on every respawn, so a seq remembered past
|
||||
the process that produced it would suppress a real loss on the next
|
||||
one — turning a guard against overcharging into an undercount, which
|
||||
is the same defect facing the other way.
|
||||
"""
|
||||
self._pending.clear()
|
||||
self._charged.clear()
|
||||
|
||||
def _expire(self, now: float):
|
||||
"""A packet still unanswered after the grace period is a lost packet.
|
||||
|
||||
`ping -O` reports "no answer yet" as soon as it sends the next probe,
|
||||
but a slow reply can still land, so a pending seq is only counted as
|
||||
lost once it is too old to come back.
|
||||
"""
|
||||
grace = self.interval * 2.5 + 1.0
|
||||
for seq, t in list(self._pending.items()):
|
||||
if now - t > grace:
|
||||
del self._pending[seq]
|
||||
self._charged[seq] = now
|
||||
self.series.add(t, None, self._loaded())
|
||||
# Bounded by the same clock that fills it: a seq stops being
|
||||
# remembered once no report about it could still arrive.
|
||||
for seq, t in list(self._charged.items()):
|
||||
if now - t > grace:
|
||||
del self._charged[seq]
|
||||
|
||||
def run(self):
|
||||
backoff = 1.0
|
||||
while not self._stop.is_set():
|
||||
if not shutil.which("ping") or not self.target:
|
||||
time.sleep(min(backoff, 30.0))
|
||||
backoff = min(backoff * 2, 30.0)
|
||||
continue
|
||||
try:
|
||||
self._run_once()
|
||||
backoff = 1.0
|
||||
except Exception:
|
||||
# Never let a parse or spawn failure take the daemon with it.
|
||||
time.sleep(min(backoff, 30.0))
|
||||
backoff = min(backoff * 2, 30.0)
|
||||
|
||||
def _run_once(self):
|
||||
cmd = ["ping", "-n", "-O", "-D", "-i", f"{self.interval:g}",
|
||||
"-W", "1", self.target]
|
||||
self._reset_tracking()
|
||||
self._proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
text=True, bufsize=1,
|
||||
)
|
||||
try:
|
||||
for line in self._proc.stdout:
|
||||
if self._stop.is_set():
|
||||
break
|
||||
self._consume(line)
|
||||
# ping exited: whatever was outstanding never arrived.
|
||||
for seq, t in self._pending.items():
|
||||
self.series.add(t, None, self._loaded())
|
||||
self._reset_tracking()
|
||||
finally:
|
||||
proc, self._proc = self._proc, None
|
||||
if proc:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Would not go quietly: do not leave it running.
|
||||
try:
|
||||
proc.kill()
|
||||
proc.wait(timeout=2)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _consume(self, line: str):
|
||||
m = RE_REPLY.match(line)
|
||||
if m:
|
||||
t, seq, rtt = float(m.group(1)), int(m.group(2)), float(m.group(3))
|
||||
self._pending.pop(seq, None)
|
||||
# A reply this late cannot un-lose the packet — the window it
|
||||
# belonged to has already been read — and recording the RTT as
|
||||
# well would put two samples on the wire's one packet.
|
||||
if seq not in self._charged:
|
||||
self.series.add(t, rtt, self._loaded())
|
||||
self._expire(t)
|
||||
return
|
||||
|
||||
m = RE_UNREACH.match(line)
|
||||
if m:
|
||||
t, seq = float(m.group(1)), int(m.group(2))
|
||||
self._pending.pop(seq, None)
|
||||
if seq not in self._charged:
|
||||
self.series.add(t, None, self._loaded())
|
||||
self._expire(t)
|
||||
return
|
||||
|
||||
m = RE_PENDING.match(line)
|
||||
if m:
|
||||
t, seq = float(m.group(1)), int(m.group(2))
|
||||
# `ping -O` repeats "no answer yet" for the same seq, so one that
|
||||
# has already been charged must not be put back on the pending
|
||||
# list to be charged a second time.
|
||||
if seq not in self._charged:
|
||||
self._pending.setdefault(seq, t)
|
||||
self._expire(t)
|
||||
|
||||
|
||||
class TcpProbe(threading.Thread):
|
||||
"""Connect-time RTT to the anchor's TLS port, feeding a Series.
|
||||
|
||||
ICMP measures what routers choose to answer, and they answer it fast:
|
||||
many devices handle it in hardware, in an ASIC or via XDP, while real
|
||||
traffic waits in the user-space path behind the queues that actually
|
||||
hold it up. Anything on the way can also reply on the destination's
|
||||
behalf, because there is nothing in ICMP to prove otherwise.
|
||||
|
||||
A TCP handshake cannot be shortcut that way. The SYN has to reach a
|
||||
listener that completes it, over port 443 where the user's own traffic
|
||||
goes, so its round trip is the one applications experience. One
|
||||
connection per sample, opened and closed — no payload, no TLS, nothing
|
||||
kept.
|
||||
|
||||
Since 0.2.0 these are seated instruments in the bench (instruments.py):
|
||||
the two best of four feed the scored internet leg, so a TCP series moves
|
||||
the score whenever it holds a seat. The anchor's ICMP figure is still
|
||||
recorded beside it per minute (`lag_icmp`) so the switch stays auditable.
|
||||
"""
|
||||
|
||||
CONNECT_TIMEOUT_S = 2.0
|
||||
# Linux and macOS both start TCP's retransmit timer at one second, so a
|
||||
# handshake that comes back at or past this did not measure a slow path:
|
||||
# its SYN was dropped and the kernel sent another. The number is the
|
||||
# kernel's constant, not the network's round trip, and folding it into a
|
||||
# latency percentile reports the line as slow when what happened is that
|
||||
# a packet was lost.
|
||||
#
|
||||
# The connect timeout was already drawing this line, in the wrong place
|
||||
# and for the wrong reason: a handshake needing TWO retransmits waits
|
||||
# 1 s + 2 s, exceeds CONNECT_TIMEOUT_S and is recorded as loss, while one
|
||||
# needing a single retransmit returns at ~1 s and was recorded as a round
|
||||
# trip. The same event, accounted two opposite ways, with the boundary
|
||||
# wherever the timeout happened to fall.
|
||||
# One initial RTO, with slop for timer granularity and scheduling. The
|
||||
# first retransmit fires at 1000 ms on Linux, macOS and Windows alike.
|
||||
RETRANSMIT_MARGIN_MS = 900.0
|
||||
# The baseline is this instrument's own recent p50 over the shared window
|
||||
# above — aliases, not copies, so "recent" cannot come to mean two things.
|
||||
RETRANSMIT_WINDOW_S = RECENT_WINDOW_S
|
||||
RETRANSMIT_MIN_SAMPLES = RECENT_MIN_SAMPLES
|
||||
|
||||
def __init__(self, target: str, series: Series, interval_s: float = 1.0,
|
||||
name: str = "", loaded_fn=None, port: int = 443):
|
||||
super().__init__(name=f"tcp-{name or target}", daemon=True)
|
||||
self.target = target
|
||||
self.port = port
|
||||
self.series = series
|
||||
self.interval = max(0.25, interval_s)
|
||||
self.loaded_fn = loaded_fn
|
||||
self._stop = threading.Event()
|
||||
self.ever_connected = False
|
||||
# The recent round trips this probe has actually seen, for the
|
||||
# comparison above. Bounded, and its own — the series it feeds is
|
||||
# merged with other instruments and cannot answer "what does THIS
|
||||
# path usually do". Held as (when, rtt) so the window is a duration
|
||||
# rather than a count, which is what makes it survive a cadence
|
||||
# change: a benched instrument probes at a fraction of the rate.
|
||||
self._recent = collections.deque(maxlen=1024)
|
||||
# Samples this probe declined to call latency, so the reclassification
|
||||
# can be seen rather than inferred from a loss rate. A dropped SYN and
|
||||
# a slow line are different faults with different owners.
|
||||
self.retransmits = 0
|
||||
self.unclassified = 0
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
|
||||
def set_interval(self, seconds: float):
|
||||
"""Picked up on the next cycle; nothing to tear down here."""
|
||||
self.interval = max(0.25, float(seconds))
|
||||
|
||||
def _loaded(self) -> bool:
|
||||
try:
|
||||
return bool(self.loaded_fn()) if self.loaded_fn else False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _once(self):
|
||||
started = time.time()
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
sock = socket.create_connection((self.target, self.port),
|
||||
timeout=self.CONNECT_TIMEOUT_S)
|
||||
except (OSError, ValueError):
|
||||
self.series.add(started, None, self._loaded())
|
||||
return
|
||||
rtt = (time.monotonic() - t0) * 1000.0
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
# The handshake completed, so the target is reachable, whatever the
|
||||
# kernel had to do to get there.
|
||||
self.ever_connected = True
|
||||
verdict = self._classify(started, rtt)
|
||||
if verdict == "retransmit":
|
||||
# Loss on new connections, which is what it is. Recorded the same
|
||||
# way a refused or timed-out connect already is, so it charges the
|
||||
# loss term and Reliability rather than the latency percentiles.
|
||||
self.retransmits += 1
|
||||
self.series.add(started, None, self._loaded())
|
||||
return
|
||||
if verdict == "unknown":
|
||||
# Past the floor before this probe has a baseline to judge it
|
||||
# against. It is either a retransmit or a genuinely slow path and
|
||||
# nothing here can tell which, so it is not recorded as either —
|
||||
# inventing a loss and publishing a suspect latency are both
|
||||
# claims, and the honest move is to make neither.
|
||||
#
|
||||
# It still feeds the baseline, and that is not an oversight. A
|
||||
# link whose real round trip is past the floor — p50 1200 ms, say
|
||||
# — has every sample land here, so a deque that only accepted
|
||||
# classified samples would never reach its minimum, the baseline
|
||||
# would never form, and the instrument would stay unclassified
|
||||
# for ever: nothing recorded, count never growing, `penalty()`
|
||||
# returning None, and the bench able neither to seat it nor to
|
||||
# call it dead. A silent unrankable instrument, invisible because
|
||||
# it is not failing, merely absent.
|
||||
self.unclassified += 1
|
||||
self._recent.append((started, rtt))
|
||||
return
|
||||
self._recent.append((started, rtt))
|
||||
self.series.add(started, round(rtt, 2), self._loaded())
|
||||
|
||||
def _baseline_ms(self, now: float):
|
||||
"""This instrument's own recent p50, or None while it has too few."""
|
||||
cutoff = now - self.RETRANSMIT_WINDOW_S
|
||||
recent = [rtt for t, rtt in self._recent if t >= cutoff]
|
||||
if len(recent) < self.RETRANSMIT_MIN_SAMPLES:
|
||||
return None
|
||||
return statistics.median(recent)
|
||||
|
||||
def _classify(self, now: float, rtt_ms: float) -> str:
|
||||
""""reply", "retransmit" or "unknown" for a handshake that completed.
|
||||
|
||||
A connect rescued by a retransmitted SYN is a lost packet, not a slow
|
||||
path, and the threshold has to be relative or it mislabels distance as
|
||||
loss: one full RTO ABOVE what this instrument usually sees. A satellite
|
||||
link whose p50 is 600 ms gets a threshold of 1500, so a 1045 ms sample
|
||||
there stays the measurement it is.
|
||||
"""
|
||||
if rtt_ms < self.RETRANSMIT_MARGIN_MS:
|
||||
return "reply"
|
||||
baseline = self._baseline_ms(now)
|
||||
if baseline is None:
|
||||
return "unknown"
|
||||
# Note what does NOT reach the baseline once one exists: a sample this
|
||||
# returns "retransmit" for. Feeding those back would raise the
|
||||
# threshold on the instrument's own retransmits and the rule would
|
||||
# quietly stop firing exactly where it is needed most.
|
||||
return "retransmit" if rtt_ms >= baseline + self.RETRANSMIT_MARGIN_MS \
|
||||
else "reply"
|
||||
|
||||
def run(self):
|
||||
while not self._stop.is_set():
|
||||
if not self.target:
|
||||
self._stop.wait(5.0)
|
||||
continue
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
self._once()
|
||||
except Exception:
|
||||
# Never let a socket or DNS failure take the daemon with it.
|
||||
pass
|
||||
self._stop.wait(max(0.0, self.interval - (time.monotonic() - t0)))
|
||||
@@ -0,0 +1,553 @@
|
||||
"""Turning measurements into the three component scores and the index.
|
||||
|
||||
The shape follows Orb: Responsiveness, Reliability and Speed each score
|
||||
0-100, and the index is the weakest of them with a nudge from the other two
|
||||
(`index`, 0.1.10 — a plain mean let one dead dimension hide behind two good
|
||||
ones). Ranking a connection by its download number alone is exactly the
|
||||
habit that leaves people with a fast line that feels broken on a video call.
|
||||
|
||||
Every threshold below is an anchor table rather than a formula. Anchors are
|
||||
arguable in public, which is the point: someone who disagrees that 60 ms of
|
||||
lag is a 78 can say so about one number instead of reverse-engineering a
|
||||
curve.
|
||||
"""
|
||||
|
||||
# Lag in ms -> Responsiveness score. Interpolated linearly between anchors.
|
||||
LAG_ANCHORS = [
|
||||
(10, 100), (20, 95), (35, 88), (60, 78), (100, 68),
|
||||
(200, 50), (400, 30), (800, 10), (1500, 0),
|
||||
]
|
||||
|
||||
# Fraction of the plan achieved -> Speed score, used only when the user has
|
||||
# configured a plan. Deliberately forgiving in the middle: an ISP delivering
|
||||
# 80% of a sold plan is doing fine, and a score that punished that would cry
|
||||
# wolf every evening.
|
||||
SPEED_ANCHORS = [
|
||||
(0.0, 0), (0.1, 15), (0.25, 35), (0.5, 60),
|
||||
(0.7, 75), (0.85, 88), (1.0, 96), (1.15, 100),
|
||||
]
|
||||
|
||||
# The default basis: Mbps -> score, anchored to what applications need
|
||||
# rather than to any plan. Speed has steep diminishing returns — 25 Mbps
|
||||
# carries a 4K stream, ~100 feels instant for nearly everything, and past
|
||||
# ~300 a person cannot tell the difference — so the curve saturates.
|
||||
SPEED_ABS_DOWN = [
|
||||
(0, 0), (5, 25), (25, 55), (50, 70), (100, 82),
|
||||
(200, 90), (300, 94), (500, 98), (750, 100),
|
||||
]
|
||||
SPEED_ABS_UP = [
|
||||
(0, 0), (2, 30), (5, 55), (10, 70), (20, 82),
|
||||
(50, 92), (100, 100),
|
||||
]
|
||||
|
||||
BANDS = [(90, "excellent"), (80, "good"), (70, "okay"), (50, "fair"), (0, "poor")]
|
||||
|
||||
|
||||
def _interp(anchors, x):
|
||||
if x <= anchors[0][0]:
|
||||
return float(anchors[0][1])
|
||||
if x >= anchors[-1][0]:
|
||||
return float(anchors[-1][1])
|
||||
for i in range(1, len(anchors)):
|
||||
x0, y0 = anchors[i - 1]
|
||||
x1, y1 = anchors[i]
|
||||
if x <= x1:
|
||||
span = x1 - x0
|
||||
return float(y0 + (y1 - y0) * ((x - x0) / span if span else 0))
|
||||
return float(anchors[-1][1])
|
||||
|
||||
|
||||
# What one lost packet costs, in milliseconds. It is a retransmit timeout,
|
||||
# so it scales with the link's own round trip rather than being a constant:
|
||||
# RTO is roughly SRTT + 4·RTTVAR, and nothing recovers faster than Linux's
|
||||
# 200 ms floor. The stall factor is on top, because a drop costs more than
|
||||
# the resent packet — everything behind it waits (head-of-line blocking)
|
||||
# and the congestion window has to climb back.
|
||||
LOSS_RTO_FLOOR_MS = 200.0
|
||||
LOSS_RTT_MULTIPLIER = 3.0
|
||||
LOSS_STALL_FACTOR = 5.0
|
||||
|
||||
|
||||
def loss_cost_ms(p75_ms: float) -> float:
|
||||
"""Milliseconds of felt lag per unit of loss, for a link this fast.
|
||||
|
||||
Below ~67 ms p75 this returns 1000, which is exactly the flat constant
|
||||
it replaces — so ordinary connections score as they always did. Above
|
||||
it the charge grows with the round trip, which is the part the constant
|
||||
got wrong: on a 600 ms satellite link a dropped packet does not cost the
|
||||
same 10 ms per percent that it costs on fibre.
|
||||
"""
|
||||
return LOSS_STALL_FACTOR * max(LOSS_RTO_FLOOR_MS,
|
||||
LOSS_RTT_MULTIPLIER * max(0.0, p75_ms))
|
||||
|
||||
|
||||
def lag_ms(stats: dict):
|
||||
"""One number for how the connection feels, in milliseconds.
|
||||
|
||||
Latency alone under-reports: a link that is 10 ms most of the time but
|
||||
swings to 90 ms and drops a packet every few seconds feels much worse
|
||||
than its median suggests. So lag leans on p75 rather than the median,
|
||||
adds the jitter the user actually perceives, and charges for loss at a
|
||||
rate that reflects a retransmit round trip on THIS link.
|
||||
"""
|
||||
if not stats or stats.get("count", 0) == 0:
|
||||
return None
|
||||
loss = stats.get("loss") or 0.0
|
||||
if stats.get("p75") is None:
|
||||
# Everything was lost. There is no latency to report, only a verdict.
|
||||
return None if loss < 1.0 else 1500.0
|
||||
base = stats["p75"]
|
||||
jitter = stats.get("jitter") or 0.0
|
||||
return round(base + 1.5 * jitter + loss * loss_cost_ms(base), 1)
|
||||
|
||||
|
||||
def lag_band(stats: dict) -> dict:
|
||||
"""Lag at three latency percentiles: best, typical, worst.
|
||||
|
||||
All three go through the same fold, differing only in which percentile
|
||||
they lean on, and that is the whole point. The panel used to pair a
|
||||
loss-charged "typical" with raw millisecond figures either side of it,
|
||||
so a lossy link displayed "best 4 · typical 644 · worst 26" — three
|
||||
numbers that cannot all be true at once, because two were round trips
|
||||
and one was a composite.
|
||||
|
||||
Sharing the fold makes the ordering hold by construction and makes loss
|
||||
move all three together, which is what a reader assumes a range means.
|
||||
"""
|
||||
if not stats or stats.get("count", 0) == 0:
|
||||
return {"best": None, "typical": None, "worst": None}
|
||||
if stats.get("p75") is None:
|
||||
# Everything in the window was lost. `lag_ms` answers 1500 here so
|
||||
# Responsiveness lands on zero, which is its job — but 1500 is an
|
||||
# anchor, not a measurement, and the panel used to print it three
|
||||
# times as though the link were replying slowly. There is no latency
|
||||
# to display, so display none.
|
||||
return {"best": None, "typical": None, "worst": None}
|
||||
out = {}
|
||||
prev = None
|
||||
for name, key in (("best", "p50"), ("typical", "p75"), ("worst", "p95")):
|
||||
v = lag_ms(dict(stats, p75=stats.get(key)))
|
||||
# p95 can equal p75 on a short window, and a percentile can be
|
||||
# missing; neither may let the range read backwards.
|
||||
if v is not None and prev is not None:
|
||||
v = max(v, prev)
|
||||
out[name] = v
|
||||
if v is not None:
|
||||
prev = v
|
||||
return out
|
||||
|
||||
|
||||
def responsiveness(lag):
|
||||
if lag is None:
|
||||
return 0.0
|
||||
return round(_interp(LAG_ANCHORS, lag), 1)
|
||||
|
||||
|
||||
RELIABILITY_WINDOW_S = 24 * 3600
|
||||
|
||||
# A self-healed interruption is real but not as bad as being down, so its
|
||||
# time is charged at a discount.
|
||||
DISRUPTION_TIME_WEIGHT = 0.5
|
||||
# Each interruption also costs recovery beyond its own length — a dropped
|
||||
# call is redialled, a stream rebuffers, a download restarts — so every
|
||||
# event carries this much equivalent disruption. Expressed in SECONDS on
|
||||
# purpose: a penalty in raw points cannot be compared with downtime, which
|
||||
# is exactly how the old flat "6 points per disruption" ended up charging a
|
||||
# brief blip more than an hour offline.
|
||||
DISRUPTION_RECOVERY_S = 300.0
|
||||
# Backstop so a pathological count can never dominate the component.
|
||||
DISRUPTION_MAX_PENALTY = 25.0
|
||||
|
||||
|
||||
def reliability(outage_fraction: float, disruptions: int, covered: bool = True,
|
||||
disruption_fraction: float = 0.0,
|
||||
window_s: float = RELIABILITY_WINDOW_S):
|
||||
"""Uptime, not smoothness — everything charged in one currency: time.
|
||||
|
||||
Orb moved reliability to bite only during true outages, and that is the
|
||||
right call: a wobbly ten minutes is already punished by responsiveness,
|
||||
and double-counting it made the overall score swing on a single bad
|
||||
evening. Here an outage is total loss on the wan leg; a disruption is a
|
||||
shorter interruption that resolved on its own.
|
||||
|
||||
Both are now charged by DURATION. They used to be charged in different
|
||||
currencies — outages by their share of the window, disruptions at a flat
|
||||
6 points each — and the units did not meet: over a 24 h window one
|
||||
ten-minute outage cost 0.7 points while three self-healed blips cost 18,
|
||||
so the milder event was punished twenty-six times harder, and seventeen
|
||||
blips zeroed the component outright. Time is the honest unit for "how
|
||||
much of today was this connection unusable", and an event's recovery
|
||||
cost is expressed in seconds so it lands on the same scale.
|
||||
"""
|
||||
if not covered:
|
||||
return 100.0
|
||||
window = window_s if window_s and window_s > 0 else RELIABILITY_WINDOW_S
|
||||
down = max(0.0, min(1.0, outage_fraction))
|
||||
disrupted_s = (max(0.0, min(1.0, disruption_fraction)) * window
|
||||
+ max(0, disruptions) * DISRUPTION_RECOVERY_S)
|
||||
penalty = min(DISRUPTION_MAX_PENALTY,
|
||||
100.0 * DISRUPTION_TIME_WEIGHT * min(1.0, disrupted_s / window))
|
||||
score = 100.0 - 100.0 * down - penalty
|
||||
return round(max(0.0, min(100.0, score)), 1)
|
||||
|
||||
|
||||
def speed_absolute(down_mbps, up_mbps):
|
||||
"""Is it fast enough — scored against what applications need.
|
||||
|
||||
Download is weighted 3:1 over upload. That is not a claim that upload
|
||||
matters less in general — it is that most lines are asymmetric by
|
||||
design, so equal weighting would score every ordinary connection as
|
||||
broken.
|
||||
"""
|
||||
if down_mbps is None:
|
||||
return None
|
||||
parts = [(_interp(SPEED_ABS_DOWN, down_mbps), 3.0)]
|
||||
if up_mbps is not None:
|
||||
parts.append((_interp(SPEED_ABS_UP, up_mbps), 1.0))
|
||||
weighted = sum(v * w for v, w in parts)
|
||||
return round(weighted / sum(w for _, w in parts), 1)
|
||||
|
||||
|
||||
# A Speed figure may be reported and still be too thin to set the headline.
|
||||
# Two samples is the floor because the guard above it is a median: with one
|
||||
# sample there is no median to take, so the single check IS the verdict —
|
||||
# and the least trustworthy check of all is the first one after joining a
|
||||
# network, taken while the link is still settling.
|
||||
MIN_SPEED_SAMPLES = 2
|
||||
# A saturating test that read at least this much more than the everyday
|
||||
# basis has disproved it. Peak stays unscored — a manual test must not
|
||||
# flatter the score — but it can withdraw a figure it contradicts.
|
||||
PEAK_CONTRADICTION_RATIO = 2.0
|
||||
|
||||
|
||||
def speed_scored(down_mbps, samples: int, peak_down=None) -> bool:
|
||||
"""May this Speed figure set the index, or only be displayed?
|
||||
|
||||
The index is weakest-link, so whichever component is lowest becomes the
|
||||
headline. Responsiveness and Reliability are built from thousands of
|
||||
probes a minute; Speed is one 12 MB sample an hour. Letting the thinnest
|
||||
input hold a veto is how a healthy 380 Mbps line reported POOR off a
|
||||
single check taken 55 seconds after associating, while a peak test on
|
||||
the same line minutes later read four times higher.
|
||||
|
||||
So the rule is not new weighting, it is eligibility: a figure that is
|
||||
under-sampled, or contradicted by a faster measurement of the same line,
|
||||
is shown with its reason and left out of the index. `index` already
|
||||
skips a component it does not have rather than inventing one — this
|
||||
gives it the same honesty for a component we have but do not trust.
|
||||
"""
|
||||
if down_mbps is None:
|
||||
return False
|
||||
if samples < MIN_SPEED_SAMPLES:
|
||||
return False
|
||||
if peak_down and peak_down >= down_mbps * PEAK_CONTRADICTION_RATIO:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def degradation_penalty(down_mbps, baseline_down):
|
||||
"""Is it normal for this network — a penalty for big drops only.
|
||||
|
||||
The baseline is the connection's own recent p90. Sharing an office line
|
||||
means honest hour-to-hour variance, so nothing below a 40% shortfall
|
||||
counts; from there the penalty grows to 35 points at zero. This is what
|
||||
catches "we normally get 300 here and today it is 60" on a line whose
|
||||
absolute score would still look comfortable.
|
||||
"""
|
||||
if down_mbps is None or not baseline_down or baseline_down <= 0:
|
||||
return 0.0
|
||||
ratio = down_mbps / baseline_down
|
||||
if ratio >= 0.6:
|
||||
return 0.0
|
||||
return round((0.6 - ratio) / 0.6 * 35.0, 1)
|
||||
|
||||
|
||||
def speed(down_mbps, up_mbps, plan_down=0, plan_up=0, baseline_down=None):
|
||||
"""The Speed component.
|
||||
|
||||
With a configured plan: scored against the plan (ISP accountability —
|
||||
opt-in, because almost nobody configures a plan and shared office lines
|
||||
have no meaningful one). Without: the absolute experience curve, minus
|
||||
the degradation penalty against the connection's own baseline.
|
||||
"""
|
||||
if down_mbps is None:
|
||||
return None
|
||||
if plan_down and plan_down > 0:
|
||||
ratios = [(down_mbps / plan_down, 3.0)]
|
||||
if plan_up and plan_up > 0 and up_mbps is not None:
|
||||
ratios.append((up_mbps / plan_up, 1.0))
|
||||
weighted = sum(_interp(SPEED_ANCHORS, r) * w for r, w in ratios)
|
||||
return round(weighted / sum(w for _, w in ratios), 1)
|
||||
base = speed_absolute(down_mbps, up_mbps)
|
||||
if base is None:
|
||||
return None
|
||||
return round(max(0.0, base - degradation_penalty(down_mbps, baseline_down)), 1)
|
||||
|
||||
|
||||
# How much of the index the worst component owns. The remainder lets the
|
||||
# other two nudge it up a little, so "everything else is excellent" still
|
||||
# reads differently from "everything is mediocre".
|
||||
INDEX_WORST_WEIGHT = 0.92
|
||||
|
||||
|
||||
def index(resp, rel, spd):
|
||||
"""Weakest-link, skipping any component we genuinely cannot measure.
|
||||
|
||||
A mean let one broken dimension hide behind two good ones: a line
|
||||
scoring Responsiveness 40, Reliability 100, Speed 95 averaged to 78 and
|
||||
read as "okay" — while video calls on it did not work. That is exactly
|
||||
the habit this module exists to avoid, reintroduced at the last step.
|
||||
People experience the bottleneck, not the average, so the worst
|
||||
component sets the number and the others only nudge it.
|
||||
|
||||
This also puts us where the rest of the field is: Pulse aggregates
|
||||
weakest-link (validated against a real fleet) and IETF
|
||||
draft-ietf-ippm-qoo takes a strict minimum. A mean was the outlier.
|
||||
|
||||
Scoring an unmeasured component as zero would be a lie; scoring it as
|
||||
100 would be a different lie. Leaving it out and saying so is honest,
|
||||
and it means the index is useful within seconds of starting rather than
|
||||
after the first speed test lands.
|
||||
"""
|
||||
parts = [p for p in (resp, rel, spd) if p is not None]
|
||||
if not parts:
|
||||
return None
|
||||
worst = min(parts)
|
||||
others = list(parts)
|
||||
others.remove(worst) # by equality: one instance, ties keep the rest
|
||||
if not others:
|
||||
return int(round(worst))
|
||||
rest = sum(others) / len(others)
|
||||
return int(round(INDEX_WORST_WEIGHT * worst + (1.0 - INDEX_WORST_WEIGHT) * rest))
|
||||
|
||||
|
||||
# The states in which a headline index is not a current reading.
|
||||
OUTAGE_STATES = ("local-down", "wan-down")
|
||||
|
||||
|
||||
def scored_now(state):
|
||||
"""Whether an index may stand as the headline in this state.
|
||||
|
||||
Not while a leg is confirmed down. Every input to the index describes a
|
||||
window that mostly predates the outage: Lag reads 30 s that still holds
|
||||
pre-outage replies, and Reliability charges the downtime against 24 h,
|
||||
where a minute is 0.07 % and rounds away. Speed is skipped honestly.
|
||||
Weakest-link over two components that both still read 100 therefore
|
||||
reports 100 — seen in the wild on a real 61 s Wi-Fi drop, the panel
|
||||
showing EXPERIENCE 100 directly beneath its own ROUTER UNREACHABLE.
|
||||
|
||||
Withheld rather than lowered, because any number chosen here would be
|
||||
invented, and the state is already the honest headline: the panel draws
|
||||
the verdict beside it and the bar counts the outage. Same rule as
|
||||
scoring None rather than fabricating a figure.
|
||||
|
||||
A quiet spell is deliberately not an outage. gateway-quiet and
|
||||
icmp-quiet leave the state calm precisely because traffic is still
|
||||
crossing the leg, so the index keeps standing there and should.
|
||||
"""
|
||||
return state not in OUTAGE_STATES
|
||||
|
||||
|
||||
def band(score):
|
||||
if score is None:
|
||||
return "unknown"
|
||||
for floor, name in BANDS:
|
||||
if score >= floor:
|
||||
return name
|
||||
return "poor"
|
||||
|
||||
|
||||
# Below this the two legs are indistinguishable at our resolution, and a
|
||||
# near-zero ISP leg is physically possible (an anchor a hop past the
|
||||
# gateway). Above it, the router answering slower than the internet behind
|
||||
# it means the two independent distributions disagree and the subtraction is
|
||||
# void — not that the ISP adds nothing.
|
||||
WAN_INVERSION_TOLERANCE_MS = 1.0
|
||||
|
||||
|
||||
def wan_point_ms(total_ms, local_ms):
|
||||
"""The ISP leg for ONE pair of readings, or None when it says nothing.
|
||||
|
||||
The same rule `wan_from` applies per statistic, factored out so the
|
||||
per-point series in recent.json and the per-window statistics cannot
|
||||
drift apart. A gateway that answers slower than the internet behind it
|
||||
is common — plenty of them deprioritise ICMP addressed to themselves —
|
||||
and the subtraction has nothing to say about the line when it happens,
|
||||
so the answer is None rather than a clamped zero.
|
||||
"""
|
||||
if total_ms is None or local_ms is None:
|
||||
return None
|
||||
if local_ms > total_ms + WAN_INVERSION_TOLERANCE_MS:
|
||||
return None
|
||||
return round(max(0.0, total_ms - local_ms), 2)
|
||||
|
||||
|
||||
def wan_from(total: dict, local: dict) -> dict:
|
||||
"""The ISP leg: what is left of the round trip once the router's share is gone.
|
||||
|
||||
The two probes are not synchronised, so this subtracts distributions
|
||||
rather than individual packets — p50 from p50, p75 from p75. Loss on the
|
||||
wan leg is whatever the internet probe lost beyond what the router probe
|
||||
lost, since loss on the local link shows up in both.
|
||||
"""
|
||||
out = {"count": total.get("count", 0)}
|
||||
prev = 0.0
|
||||
for key in ("p50", "p75", "p95", "max"):
|
||||
t, l = total.get(key), local.get(key)
|
||||
# One refusal, in one place — `wan_point_ms` — for both reasons it
|
||||
# refuses, because a rule whose whole job is to withhold must not
|
||||
# have a second copy that can forget to.
|
||||
#
|
||||
# It withholds when the router answered SLOWER than the internet
|
||||
# behind it, because `total = local + wan` does not hold and the
|
||||
# subtraction has nothing to say. Clamping the negative to zero used
|
||||
# to report the ISP leg as 0.0 ms — the best possible answer, from an
|
||||
# invalid measurement, on the number the whole panel is built around.
|
||||
# It happens for a real reason: plenty of gateways deprioritise or
|
||||
# rate-limit ICMP addressed to themselves, so their own replies are
|
||||
# slow while everything they forward is fast. That says something
|
||||
# about the gateway's control plane, not about the link.
|
||||
#
|
||||
# And when either reading is missing: unknown, not zero. Substituting
|
||||
# 0 for a local statistic we do not have made the derived leg equal
|
||||
# the whole round trip, so a silent gateway produced a confident,
|
||||
# healthy-looking internet figure that was really the total wearing
|
||||
# the wan leg's label.
|
||||
v = wan_point_ms(t, l)
|
||||
if v is None:
|
||||
out[key] = None
|
||||
continue
|
||||
# Subtracting two independent distributions statistic-by-statistic
|
||||
# can invert the order (a wan p95 below the wan p50) when the local
|
||||
# leg's tail is fatter than the total's. Each statistic is floored
|
||||
# at the one before it so the derived leg reads like a distribution.
|
||||
# The floor has to carry forward FLOORED, not raw: `prev` is what the
|
||||
# previous statistic ended up reporting, so a p95 that subtracts lower
|
||||
# than the p50 still reads as a distribution.
|
||||
v = max(prev, v)
|
||||
out[key] = round(v, 2)
|
||||
prev = v
|
||||
t, l = total.get("last"), local.get("last")
|
||||
if t is None or (l is not None and l > t + WAN_INVERSION_TOLERANCE_MS):
|
||||
out["last"] = None
|
||||
else:
|
||||
out["last"] = round(max(0.0, t - (l or 0.0)), 2)
|
||||
# Jitter does not subtract: variance on the local link propagates into
|
||||
# the total, so the honest reading is "no less than the total's jitter
|
||||
# minus the local's", floored at zero.
|
||||
tj, lj = total.get("jitter"), local.get("jitter")
|
||||
out["jitter"] = None if tj is None else round(max(0.0, tj - (lj or 0.0)), 2)
|
||||
tl, ll = total.get("loss"), local.get("loss")
|
||||
out["loss"] = None if tl is None else max(0.0, tl - (ll or 0.0))
|
||||
return out
|
||||
|
||||
|
||||
# How close to the idle baseline counts as drained. A queue does not empty
|
||||
# to the exact millisecond it started from, and demanding that would report
|
||||
# "never recovered" on a link that plainly had.
|
||||
DRAIN_TOLERANCE = 1.25
|
||||
# Longest drain worth reporting. Past this the link did not recover from a
|
||||
# burst, it is simply in a different state, and calling that a drain time
|
||||
# would flatter it.
|
||||
DRAIN_MAX_S = 30.0
|
||||
|
||||
|
||||
def drain_after_load(samples, baseline_ms: float) -> dict:
|
||||
"""How long latency took to fall back to baseline after load stopped.
|
||||
|
||||
Bufferbloat is reported everywhere as a depth — how much delay a busy
|
||||
link adds. Depth alone cannot tell apart two links a user experiences
|
||||
very differently: one whose queue fills and empties the instant traffic
|
||||
stops, and one that stays full for seconds afterwards. The second ruins
|
||||
a call after the download has finished; the first does not.
|
||||
|
||||
Pure, and fed the sample stream it already has: `(t, rtt, loaded)`
|
||||
tuples, where the third element is the load tag added in 0.1.11. No
|
||||
extra traffic, and nothing to schedule — the user's own usage supplies
|
||||
the burst.
|
||||
|
||||
Returns `{"ms": None}` when there is nothing to say, which is most of
|
||||
the time: no burst in the window, or the link never came back inside
|
||||
`DRAIN_MAX_S`, or the baseline is unknown.
|
||||
"""
|
||||
out = {"ms": None, "settled": None}
|
||||
if not samples or not baseline_ms or baseline_ms <= 0:
|
||||
return out
|
||||
# The most recent load -> idle transition, which is the only one whose
|
||||
# recovery is still visible in this window.
|
||||
last_loaded = None
|
||||
for i, sm in enumerate(samples):
|
||||
if len(sm) > 2 and sm[2]:
|
||||
last_loaded = i
|
||||
if last_loaded is None or last_loaded == len(samples) - 1:
|
||||
return out # no burst, or still under load
|
||||
ended_t = samples[last_loaded][0]
|
||||
target = baseline_ms * DRAIN_TOLERANCE
|
||||
for sm in samples[last_loaded + 1:]:
|
||||
if sm[1] is None:
|
||||
continue # a lost probe says nothing either way
|
||||
if sm[1] <= target:
|
||||
span = sm[0] - ended_t
|
||||
if span > DRAIN_MAX_S:
|
||||
return out
|
||||
out["ms"] = round(max(0.0, span) * 1000.0, 0)
|
||||
out["settled"] = True
|
||||
return out
|
||||
# Still above the baseline at the end of the window: report the floor it
|
||||
# has already exceeded rather than a number implying it recovered.
|
||||
span = samples[-1][0] - ended_t
|
||||
if 0 < span <= DRAIN_MAX_S:
|
||||
out["ms"] = round(span * 1000.0, 0)
|
||||
out["settled"] = False
|
||||
return out
|
||||
|
||||
|
||||
# Queueing delay, in milliseconds, that separates a link carrying traffic
|
||||
# comfortably from one holding packets up. Deliberately the same shape as
|
||||
# the bufferbloat grades and the Latency tab's copy, and deliberately about
|
||||
# the ABSOLUTE delay rather than its share of the round trip: a socket to
|
||||
# another continent is mostly distance, and a proportion would call that
|
||||
# congested.
|
||||
PRESSURE_BUSY_MS = 10.0
|
||||
PRESSURE_CONGESTED_MS = 30.0
|
||||
|
||||
|
||||
def pressure(socket_queue_ms=None, loaded_ms=None, idle_ms=None) -> dict:
|
||||
"""What the connection is doing RIGHT NOW, as opposed to lately.
|
||||
|
||||
The index cannot answer this and is not meant to. It is a weakest-link
|
||||
score over three components, one of which — Speed — moves at
|
||||
content-check cadence, so when it is the weakest the index barely
|
||||
responds to anything else. Measured live: a saturating test drove
|
||||
Responsiveness down 14.6 points while the index moved from 75.0 to
|
||||
75.0, because Speed sat permanently lowest at 73.1. Both numbers were
|
||||
correct; neither answered "is it bad right now".
|
||||
|
||||
So this is a separate, fast channel rather than a change to the index.
|
||||
It reports queueing delay, which is the thing a user actually feels
|
||||
during a burst, and it prefers the figure taken from their own TCP
|
||||
connections (`sockets.queue_p50`, the kernel's own timing) over our
|
||||
probes' loaded-minus-idle difference, because real traffic to real
|
||||
destinations beats an inference from two sample populations.
|
||||
|
||||
Returns `state: None` when neither source can say, which is honest and
|
||||
common on an idle machine with nothing to measure.
|
||||
"""
|
||||
src, q = None, None
|
||||
if socket_queue_ms is not None and socket_queue_ms >= 0:
|
||||
src, q = "sockets", float(socket_queue_ms)
|
||||
elif (loaded_ms is not None and idle_ms is not None
|
||||
and loaded_ms >= idle_ms):
|
||||
# Only when the difference points the right way; queueing cannot be
|
||||
# negative, and a negative difference means the split is unreliable
|
||||
# rather than that load helped.
|
||||
src, q = "probes", float(loaded_ms - idle_ms)
|
||||
if q is None:
|
||||
return {"state": None, "queue_ms": None, "source": None}
|
||||
if q >= PRESSURE_CONGESTED_MS:
|
||||
state = "congested"
|
||||
elif q >= PRESSURE_BUSY_MS:
|
||||
state = "busy"
|
||||
else:
|
||||
state = "clear"
|
||||
return {"state": state, "queue_ms": round(q, 1), "source": src}
|
||||
@@ -0,0 +1,561 @@
|
||||
"""Speed measurement, two kinds.
|
||||
|
||||
Content speed: a short ranged download and a small upload, on a schedule, small enough to
|
||||
be honest about the connection without being a burden on it. This is what
|
||||
feeds the Speed score, following Orb's split — score the everyday number,
|
||||
keep the fireworks manual.
|
||||
|
||||
Peak speed: saturates the line, only ever on demand. Prefers the official
|
||||
Ookla CLI when installed (server choice, shareable result), falls back to
|
||||
Cloudflare's endpoints via curl, then fast.com via the same API Omarchy's
|
||||
built-in speed test uses. Both fallbacks need nothing installed beyond curl.
|
||||
|
||||
Loaded latency is sampled during the peak download by the daemon's existing
|
||||
probes, not here — the test just records the window it ran in.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
CLOUDFLARE_DOWN = "https://speed.cloudflare.com/__down?bytes={n}"
|
||||
CLOUDFLARE_UP = "https://speed.cloudflare.com/__up"
|
||||
# The token fast.com's own web client uses; Omarchy's built-in speed test
|
||||
# ships the same one.
|
||||
FAST_API = ("https://api.fast.com/netflix/speedtest/v2"
|
||||
"?https=true&token=YXNkZmFzZGxmbnNkYWZoYXNkZmhrYWxm&urlCount=3")
|
||||
|
||||
|
||||
def vet_target(url: str):
|
||||
"""(url, --resolve argument) for a target we will fetch, else None.
|
||||
|
||||
Only for URLs WE DID NOT CHOOSE. fast.com nominates its own download
|
||||
hosts, so that JSON decides what this daemon connects to, and it has
|
||||
to be treated as hostile input rather than as a list of Netflix
|
||||
servers. Three things must hold:
|
||||
|
||||
1. the scheme is https, so a nominated target cannot downgrade the
|
||||
transfer to plaintext or hand curl a `file://` path;
|
||||
2. EVERY address the host resolves to is public, so a speed test can
|
||||
never be aimed at a router's admin page, a service on loopback,
|
||||
or a link-local metadata address;
|
||||
3. the address that passed (2) is the one curl actually connects to.
|
||||
|
||||
The third is the point most of this class gets wrong: resolving here
|
||||
and letting curl resolve again is a check-then-use race, and a DNS
|
||||
answer that returns a public address to us and a private one to curl
|
||||
wins it. Pinning the vetted addresses with --resolve closes that
|
||||
window, the same way every other read in this daemon is enforced on
|
||||
the thing actually used rather than on a name looked up earlier.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
return None
|
||||
port = parsed.port or 443
|
||||
try:
|
||||
infos = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
return None
|
||||
addrs = []
|
||||
for info in infos:
|
||||
try:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
except ValueError:
|
||||
return None
|
||||
if ip.version == 6 and ip.ipv4_mapped is not None:
|
||||
ip = ip.ipv4_mapped
|
||||
# Spelled out rather than leaning on is_global alone, whose range
|
||||
# table has been corrected across Python versions we may run on.
|
||||
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
||||
or ip.is_multicast or ip.is_reserved or ip.is_unspecified):
|
||||
return None
|
||||
addrs.append(str(ip))
|
||||
if not addrs:
|
||||
return None
|
||||
return url, "%s:%d:%s" % (parsed.hostname, port, ",".join(addrs))
|
||||
|
||||
|
||||
def _curl(args, timeout) -> Optional[subprocess.CompletedProcess]:
|
||||
if not shutil.which("curl"):
|
||||
return None
|
||||
try:
|
||||
# --proto =https refuses anything but TLS even if a target or a
|
||||
# server tries something else; we never pass -L, so there is no
|
||||
# redirect for it to follow either.
|
||||
return subprocess.run(["curl", "-fsS", "--proto", "=https",
|
||||
"--max-time", str(int(timeout))] + args,
|
||||
capture_output=True, text=True, timeout=timeout + 5,
|
||||
check=False)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return None
|
||||
|
||||
|
||||
# A transfer shorter than this carried too few bytes for its own duration to
|
||||
# be worth dividing by: the timing error, not the line, would set the answer.
|
||||
MIN_TIMED_WINDOW_S = 0.05
|
||||
|
||||
|
||||
def _rate_over_payload(size: float, t_total: float, t_payload_start: float):
|
||||
"""Mbps over the part of the request that actually carried bytes.
|
||||
|
||||
curl's own `speed_download` divides the bytes by the WHOLE request —
|
||||
DNS, the TCP connect, the TLS handshake and the wait for the first byte
|
||||
included. None of that carried payload, and none of it shrinks when the
|
||||
line gets faster, so it is a fixed tax on a measurement whose useful part
|
||||
keeps getting shorter: a 3 MB stream is ~240 ms of payload on a 400 Mbps
|
||||
line and ~107 ms on a gigabit one, against the same ~70-90 ms of setup.
|
||||
That is not noise. It is a bias that grows with the quantity being
|
||||
measured, which made the check read a 900 Mbps line as roughly 220 and
|
||||
put a ceiling near 480 on a scale whose top two anchors are 500 and 750.
|
||||
|
||||
Dividing by a window instead of by the total is only honest while the
|
||||
window is long enough to divide by, so a sample too short to time is
|
||||
withheld rather than published — the same rule the rest of the daemon
|
||||
uses for a figure it cannot stand behind.
|
||||
"""
|
||||
window = t_total - t_payload_start
|
||||
if window < MIN_TIMED_WINDOW_S or size <= 0:
|
||||
return None
|
||||
return size * 8 / 1e6 / window
|
||||
|
||||
|
||||
def _curl_timed_download(url: str, timeout: float, resolve: str = None):
|
||||
"""(mbps, bytes), timed over the payload rather than the whole request."""
|
||||
pin = ["--resolve", resolve] if resolve else []
|
||||
r = _curl(pin + ["-o", "/dev/null",
|
||||
"-w", "%{size_download} %{time_total} %{time_starttransfer}",
|
||||
url],
|
||||
timeout)
|
||||
if not r or r.returncode != 0:
|
||||
return None, 0
|
||||
try:
|
||||
size, t_total, t_start = (float(x) for x in r.stdout.split())
|
||||
except ValueError:
|
||||
return None, 0
|
||||
return _rate_over_payload(size, t_total, t_start), int(size)
|
||||
|
||||
|
||||
def _upload_argv(url: str, timeout: float):
|
||||
"""The one upload invocation, shared by the single and parallel forms.
|
||||
|
||||
The body is piped in — pointing curl at /dev/zero directly would have it
|
||||
read the file to its end, which /dev/zero does not have."""
|
||||
return ["curl", "-fsS", "--proto", "=https", "--max-time", str(int(timeout)),
|
||||
"-o", "/dev/null", "-X", "POST", "--data-binary", "@-",
|
||||
"-H", "Content-Type: application/octet-stream",
|
||||
# Not time_starttransfer: on a POST that is the first byte of the
|
||||
# RESPONSE, and against speed.cloudflare.com it arrives right after
|
||||
# the handshake (the 100-continue), not after the body. The TLS
|
||||
# handshake completing is when this request starts putting bytes
|
||||
# on the wire.
|
||||
"-w", "%{size_upload} %{time_total} %{time_appconnect}", url]
|
||||
|
||||
|
||||
def _parallel_upload(url: str, per_stream: int, streams: int, timeout: float):
|
||||
"""Sum of concurrent upload stream rates.
|
||||
|
||||
The download learned in 0.1.x that one TCP stream cannot fill a fast line —
|
||||
a single-stream check read this 450 Mbps connection as 54 — and grew
|
||||
`_parallel_download` for it. The upload never did, in either the hourly
|
||||
check or the peak, so both were reading one stream's ceiling and calling
|
||||
it the line. Measured here: the same 2 MB carried by four streams instead
|
||||
of one read 36% higher, and 4 MB over four streams read more than twice
|
||||
what the shipping 2 MB over one did.
|
||||
|
||||
Every stream is handed the same immutable buffer, so the memory cost is
|
||||
one stream's worth of zeros rather than N.
|
||||
"""
|
||||
if not shutil.which("curl"):
|
||||
return None, 0
|
||||
body = b"\0" * per_stream
|
||||
procs = []
|
||||
for _ in range(streams):
|
||||
try:
|
||||
spawned = time.monotonic()
|
||||
procs.append((subprocess.Popen(
|
||||
_upload_argv(url, timeout), stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL), spawned))
|
||||
except OSError:
|
||||
pass
|
||||
# A pipe holds far less than a stream's body, so writing them in turn
|
||||
# would serialise the very thing being parallelised: each child gets a
|
||||
# thread that feeds it and collects its result.
|
||||
outs = [None] * len(procs)
|
||||
|
||||
def feed(i, proc):
|
||||
try:
|
||||
outs[i] = proc.communicate(input=body, timeout=timeout + 10)[0]
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
workers = [threading.Thread(target=feed, args=(i, proc), daemon=True)
|
||||
for i, (proc, _) in enumerate(procs)]
|
||||
for w in workers:
|
||||
w.start()
|
||||
for w in workers:
|
||||
w.join(timeout + 15)
|
||||
|
||||
windows, total_bytes = [], 0
|
||||
for i, (proc, spawned) in enumerate(procs):
|
||||
if proc.returncode != 0 or not outs[i]:
|
||||
continue
|
||||
try:
|
||||
size, t_total, t_app = (float(x) for x in outs[i].decode().split())
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
continue
|
||||
total_bytes += int(size)
|
||||
windows.append((size, spawned + t_app, spawned + t_total))
|
||||
return _aggregate_rate(windows), total_bytes
|
||||
|
||||
|
||||
def _curl_timed_upload(url: str, n_bytes: int, timeout: float):
|
||||
"""One upload stream. Kept for the peak's estimate pass, which only needs
|
||||
a rough rate to size the real one."""
|
||||
if not shutil.which("curl"):
|
||||
return None, 0
|
||||
cmd = ["curl", "-fsS", "--proto", "=https", "--max-time", str(int(timeout)),
|
||||
"-o", "/dev/null", "-X", "POST", "--data-binary", "@-",
|
||||
"-H", "Content-Type: application/octet-stream",
|
||||
# Not time_starttransfer: on a POST that lands part way through
|
||||
# the body, not after it. The TLS handshake completing is when
|
||||
# this request starts putting bytes on the wire.
|
||||
"-w", "%{size_upload} %{time_total} %{time_appconnect}", url]
|
||||
try:
|
||||
r = subprocess.run(cmd, input=b"\0" * n_bytes, capture_output=True,
|
||||
timeout=timeout + 5, check=False)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return None, 0
|
||||
if r.returncode != 0:
|
||||
return None, 0
|
||||
try:
|
||||
size, t_total, t_app = (float(x) for x in r.stdout.decode().split())
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None, 0
|
||||
return _rate_over_payload(size, t_total, t_app), int(size)
|
||||
|
||||
|
||||
def _aggregate_rate(windows):
|
||||
"""Mbps carried by a set of parallel streams.
|
||||
|
||||
NOT the sum of their individual rates. Streams do not start or finish
|
||||
together — TLS handshakes complete tens to hundreds of milliseconds apart
|
||||
— so a stream that outlives the others has the line to itself and measures
|
||||
all of it. Adding that to what its siblings measured while sharing counts
|
||||
the same link two, three, four times. Observed on this ~450 Mbps line:
|
||||
summing gave 647 / 629 / 538 Mbps for transfers that actually carried
|
||||
323 / 276 / 269.
|
||||
|
||||
The honest figure is what crossed the wire divided by the time the wire
|
||||
spent carrying it: total bytes over the union of the streams' payload
|
||||
windows. The union rather than first-start-to-last-finish, so a gap
|
||||
between streams is not billed as throughput.
|
||||
|
||||
`windows` is (bytes, absolute start, absolute end) per stream.
|
||||
"""
|
||||
windows = [w for w in windows if w[0] > 0 and w[2] > w[1]]
|
||||
if not windows:
|
||||
return None
|
||||
total_bytes = sum(w[0] for w in windows)
|
||||
spans = sorted((w[1], w[2]) for w in windows)
|
||||
union, cur_s, cur_e = 0.0, spans[0][0], spans[0][1]
|
||||
for s, e in spans[1:]:
|
||||
if s > cur_e:
|
||||
union += cur_e - cur_s
|
||||
cur_s, cur_e = s, e
|
||||
else:
|
||||
cur_e = max(cur_e, e)
|
||||
union += cur_e - cur_s
|
||||
if union < MIN_TIMED_WINDOW_S:
|
||||
return None
|
||||
return total_bytes * 8 / 1e6 / union
|
||||
|
||||
|
||||
def _parallel_download(url: str, streams: int, timeout: float):
|
||||
"""Sum of concurrent stream rates.
|
||||
|
||||
One TCP stream at ~10 ms of latency tops out far below a fast line's
|
||||
capacity — a single-stream check read this 450 Mbps connection as 54.
|
||||
Real page loads and video players open several connections, so several
|
||||
streams is the honest simulation, and their sum is the number.
|
||||
"""
|
||||
if not shutil.which("curl"):
|
||||
return None, 0
|
||||
procs = []
|
||||
for _ in range(streams):
|
||||
try:
|
||||
# curl times everything from its own start, and the children are
|
||||
# spawned a few milliseconds apart, so their clocks have to be
|
||||
# put on a common origin before their windows can be compared.
|
||||
spawned = time.monotonic()
|
||||
procs.append((subprocess.Popen(
|
||||
["curl", "-fsS", "--proto", "=https",
|
||||
"--max-time", str(int(timeout)), "-o", "/dev/null",
|
||||
"-w", "%{size_download} %{time_total} %{time_starttransfer}",
|
||||
url],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True),
|
||||
spawned))
|
||||
except OSError:
|
||||
pass
|
||||
windows, total_bytes = [], 0
|
||||
for p, spawned in procs:
|
||||
try:
|
||||
out, _ = p.communicate(timeout=timeout + 10)
|
||||
except subprocess.TimeoutExpired:
|
||||
p.kill()
|
||||
p.wait()
|
||||
continue
|
||||
if p.returncode != 0:
|
||||
continue
|
||||
try:
|
||||
size, t_total, t_start = (float(x) for x in out.split())
|
||||
except ValueError:
|
||||
continue
|
||||
total_bytes += int(size)
|
||||
windows.append((size, spawned + t_start, spawned + t_total))
|
||||
return _aggregate_rate(windows), total_bytes
|
||||
|
||||
|
||||
# Each stream aims for about this much time actually carrying bytes: long
|
||||
# enough that TCP's ramp-up is a small share of what is timed, short enough
|
||||
# that the check stays something nobody notices.
|
||||
CONTENT_TARGET_S = 0.5
|
||||
# A stream never goes below this, so a hint that came in low cannot shrink
|
||||
# the next transfer into a degenerate one.
|
||||
CONTENT_STREAM_FLOOR = 500_000
|
||||
# And never above this, which is what bounds the hourly data budget. Only a
|
||||
# fast line reaches either cap; everything slower asks for less and gets it.
|
||||
CONTENT_DOWN_STREAM_CAP = 3_000_000
|
||||
CONTENT_UP_STREAM_CAP = 2_000_000
|
||||
|
||||
|
||||
def content_stream_bytes(hint_mbps, streams: int, cap: int) -> int:
|
||||
"""Bytes for one stream: about CONTENT_TARGET_S of payload at the rate
|
||||
this line last showed, bounded both ways.
|
||||
|
||||
A fixed size cannot serve both ends of the range it has to. Twelve MB is
|
||||
a quarter of a second on a fast line and nine seconds of a saturated link
|
||||
on a 10 Mbps one — the users least able to spare it were paying the most
|
||||
for it, hourly. Sizing by time inverts that: the cap is reached only by
|
||||
lines that can afford it, and a slow line asks for a fraction.
|
||||
|
||||
With no hint — the first check on a network — the cap is what it sends,
|
||||
because there is nothing yet to size against and one honest measurement
|
||||
is what produces the hint for every check after it.
|
||||
"""
|
||||
if not hint_mbps or hint_mbps <= 0:
|
||||
return cap
|
||||
per_stream_mbps = float(hint_mbps) / max(1, streams)
|
||||
want = int(per_stream_mbps / 8 * CONTENT_TARGET_S * 1e6)
|
||||
return max(CONTENT_STREAM_FLOOR, min(cap, want))
|
||||
|
||||
|
||||
def content_test(down_hint_mbps=None, up_hint_mbps=None,
|
||||
streams: int = 4) -> dict:
|
||||
"""The scheduled check.
|
||||
|
||||
Both directions are carried by `streams` parallel connections, each sized
|
||||
for a target duration rather than by dividing a fixed budget. Dividing a
|
||||
budget was how the download bug worked from one side and the upload's from
|
||||
the other: more streams meant shorter streams, and a stream too short to
|
||||
time is withheld.
|
||||
|
||||
Costs up to ~20 MB on a line fast enough to reach both caps, and a
|
||||
fraction of that below — about 2 MB on a 25 Mbps line, where the old fixed
|
||||
16 MB took nine seconds of the link every hour.
|
||||
"""
|
||||
started = time.time()
|
||||
per_stream = content_stream_bytes(down_hint_mbps, streams,
|
||||
CONTENT_DOWN_STREAM_CAP)
|
||||
up_per_stream = content_stream_bytes(up_hint_mbps, streams,
|
||||
CONTENT_UP_STREAM_CAP)
|
||||
down_mbps, down_n = _parallel_download(
|
||||
CLOUDFLARE_DOWN.format(n=per_stream), streams, timeout=30)
|
||||
if down_mbps is None and per_stream < CONTENT_DOWN_STREAM_CAP:
|
||||
# Sized from history, and the line turned out to be faster than that
|
||||
# history says — so fast that the streams finished inside the window
|
||||
# too short to time, and were withheld. Nothing is stored for a
|
||||
# withheld check, so the hint would never learn better and every
|
||||
# check after this one would ask for the same too-short transfer and
|
||||
# report nothing, forever. One pass at the cap re-anchors it.
|
||||
retry_mbps, retry_n = _parallel_download(
|
||||
CLOUDFLARE_DOWN.format(n=CONTENT_DOWN_STREAM_CAP), streams,
|
||||
timeout=30)
|
||||
down_mbps, down_n = retry_mbps, down_n + retry_n
|
||||
|
||||
up_mbps, up_n = _parallel_upload(CLOUDFLARE_UP, up_per_stream, streams,
|
||||
timeout=30)
|
||||
if up_mbps is None and up_per_stream < CONTENT_UP_STREAM_CAP:
|
||||
retry_mbps, retry_n = _parallel_upload(
|
||||
CLOUDFLARE_UP, CONTENT_UP_STREAM_CAP, streams, timeout=30)
|
||||
up_mbps, up_n = retry_mbps, up_n + retry_n
|
||||
return {
|
||||
"kind": "content",
|
||||
"engine": "cloudflare",
|
||||
"ok": down_mbps is not None,
|
||||
"down_mbps": round(down_mbps, 1) if down_mbps else None,
|
||||
"up_mbps": round(up_mbps, 1) if up_mbps else None,
|
||||
"bytes": down_n + up_n,
|
||||
"started": started,
|
||||
"ended": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def _peak_ookla() -> Optional[dict]:
|
||||
"""The official Speedtest CLI, when the user has installed it."""
|
||||
if not shutil.which("speedtest"):
|
||||
return None
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["speedtest", "--format=json", "--accept-license", "--accept-gdpr"],
|
||||
capture_output=True, text=True, timeout=120, check=False)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return None
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
j = json.loads(r.stdout)
|
||||
return {
|
||||
"engine": "ookla",
|
||||
"ok": True,
|
||||
"down_mbps": round(j["download"]["bandwidth"] * 8 / 1e6, 1),
|
||||
"up_mbps": round(j["upload"]["bandwidth"] * 8 / 1e6, 1),
|
||||
"ping_idle": round(j["ping"]["latency"], 1),
|
||||
"jitter": round(j["ping"].get("jitter", 0), 1),
|
||||
"bytes": j["download"].get("bytes", 0) + j["upload"].get("bytes", 0),
|
||||
"server": f'{j["server"].get("name", "")} · {j["server"].get("location", "")}',
|
||||
"url": j.get("result", {}).get("url", ""),
|
||||
}
|
||||
except (ValueError, KeyError, TypeError, AttributeError):
|
||||
# Someone else's JSON: a missing key, a string where a number was
|
||||
# expected, a list where an object was. Any of those is a failed
|
||||
# engine, not a dead worker thread.
|
||||
return None
|
||||
|
||||
|
||||
PEAK_TARGET_S = 10 # aim each sustained pass at about this long
|
||||
PEAK_STREAMS = 4
|
||||
# __down 403s any single request of 100 MB or more; each parallel stream
|
||||
# stays under that and the streams together still carry a fast line.
|
||||
CLOUDFLARE_DOWN_MAX = 99_999_999
|
||||
PEAK_DOWN_FLOOR = 10_000_000
|
||||
PEAK_UP_FLOOR = 5_000_000
|
||||
PEAK_UP_CAP = 100_000_000 # also bounds the in-memory upload body
|
||||
|
||||
|
||||
def _sized_pass(mbps: float, floor: int, cap: int) -> int:
|
||||
"""Bytes that should take about PEAK_TARGET_S at the measured rate."""
|
||||
return max(floor, min(cap, int(mbps / 8 * PEAK_TARGET_S * 1e6)))
|
||||
|
||||
|
||||
def _pass_seconds(mbps: float, n_bytes: int) -> float:
|
||||
return n_bytes * 8 / (mbps * 1e6)
|
||||
|
||||
|
||||
def _peak_cloudflare() -> Optional[dict]:
|
||||
"""An estimate pass sizes a sustained pass.
|
||||
|
||||
Fixed sizes made the whole test finish inside TCP ramp-up on a fast
|
||||
line (2-3 s end to end), which both under-reads the line and leaves
|
||||
the loaded-latency window with a handful of probe samples. The
|
||||
estimate pass measures the rate; the sustained pass is sized to hold
|
||||
that rate for ~PEAK_TARGET_S, split over parallel streams because a
|
||||
single stream can neither exceed the per-request byte cap nor fill a
|
||||
fast line by itself. A slow line's estimate pass already runs that
|
||||
long and doubles as the sustained pass.
|
||||
"""
|
||||
total = 0
|
||||
best_down, size = _curl_timed_download(CLOUDFLARE_DOWN.format(n=25_000_000),
|
||||
timeout=40)
|
||||
total += size
|
||||
if best_down and _pass_seconds(best_down, size) < PEAK_TARGET_S * 0.6:
|
||||
n = _sized_pass(best_down / PEAK_STREAMS, PEAK_DOWN_FLOOR,
|
||||
CLOUDFLARE_DOWN_MAX)
|
||||
mbps, size = _parallel_download(CLOUDFLARE_DOWN.format(n=n),
|
||||
PEAK_STREAMS, timeout=40)
|
||||
total += size
|
||||
if mbps:
|
||||
best_down = max(best_down, mbps)
|
||||
best_up = 0.0
|
||||
up_est, size = _curl_timed_upload(CLOUDFLARE_UP, 10_000_000, timeout=40)
|
||||
total += size
|
||||
if up_est:
|
||||
best_up = up_est
|
||||
if _pass_seconds(up_est, size) < PEAK_TARGET_S * 0.6:
|
||||
# Per stream, as the download pass already sizes itself: the same
|
||||
# total goes up, split four ways, so this costs no more data than
|
||||
# the single stream it replaces and stops reading one stream's
|
||||
# ceiling as the line.
|
||||
n = _sized_pass(up_est / PEAK_STREAMS, PEAK_UP_FLOOR // PEAK_STREAMS,
|
||||
PEAK_UP_CAP // PEAK_STREAMS)
|
||||
mbps, size = _parallel_upload(CLOUDFLARE_UP, n, PEAK_STREAMS,
|
||||
timeout=40)
|
||||
total += size
|
||||
if mbps:
|
||||
best_up = max(best_up, mbps)
|
||||
if not best_down:
|
||||
return None
|
||||
return {
|
||||
"engine": "cloudflare",
|
||||
"ok": True,
|
||||
"down_mbps": round(best_down, 1),
|
||||
"up_mbps": round(best_up, 1) if best_up else None,
|
||||
"bytes": total,
|
||||
"server": "speed.cloudflare.com",
|
||||
}
|
||||
|
||||
|
||||
def _peak_fast() -> Optional[dict]:
|
||||
"""Download-only, via the Netflix OCA endpoints fast.com hands out.
|
||||
|
||||
The API picks the hosts, so each one is vetted before it is fetched
|
||||
(see vet_target) and a target that does not pass is skipped rather
|
||||
than failing the test — a bad entry in someone else's JSON should
|
||||
cost us one candidate, not the measurement.
|
||||
"""
|
||||
r = _curl([FAST_API], timeout=15)
|
||||
if not r or r.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
targets = [t["url"] for t in json.loads(r.stdout).get("targets", []) if t.get("url")]
|
||||
except (ValueError, KeyError, TypeError, AttributeError):
|
||||
return None
|
||||
vetted = [v for v in (vet_target(u) for u in targets if isinstance(u, str)) if v]
|
||||
best = 0.0
|
||||
total = 0
|
||||
for url, resolve in vetted[:3]:
|
||||
mbps, size = _curl_timed_download(url, timeout=30, resolve=resolve)
|
||||
total += size
|
||||
if mbps:
|
||||
best = max(best, mbps)
|
||||
if not best:
|
||||
return None
|
||||
return {"engine": "fast.com", "ok": True, "down_mbps": round(best, 1),
|
||||
"up_mbps": None, "bytes": total, "server": "Netflix OCA"}
|
||||
|
||||
|
||||
def peak_test(engine: str = "Auto") -> dict:
|
||||
"""On-demand, engine per the user's setting."""
|
||||
started = time.time()
|
||||
order = {
|
||||
"Auto": (_peak_ookla, _peak_cloudflare, _peak_fast),
|
||||
"Ookla": (_peak_ookla,),
|
||||
"Cloudflare": (_peak_cloudflare,),
|
||||
"fast.com": (_peak_fast,),
|
||||
}.get(engine, (_peak_ookla, _peak_cloudflare, _peak_fast))
|
||||
for fn in order:
|
||||
result = fn()
|
||||
if result:
|
||||
result.update({"kind": "peak", "started": started, "ended": time.time()})
|
||||
return result
|
||||
return {"kind": "peak", "engine": engine, "ok": False,
|
||||
"started": started, "ended": time.time()}
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Reading and writing the JSON state files, safely.
|
||||
|
||||
live.json is rewritten twice a second and is all the bar widget ever looks
|
||||
at; recent.json is a pre-downsampled 30-minute window so the panel's default
|
||||
graphs paint without a query; apps.json is per-application traffic. All are
|
||||
written to a temp file and renamed, so a reader never sees a half-written
|
||||
file. The QML side does not open any of them itself (0.1.9): `nexthop
|
||||
stream` reads them through `read_text_bounded` — no symlink following, a
|
||||
regular file or nothing, a size cap on the read itself — and hands the shell
|
||||
one re-serialised line per record.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import APPS, LIVE, RECENT
|
||||
|
||||
|
||||
def write_atomic(path: Path, payload: dict, durable: bool = False):
|
||||
"""Write a JSON file so a reader sees the old one or the new one.
|
||||
|
||||
The temp file plus rename is what gives readers that guarantee, and it
|
||||
costs nothing. The fsync is a different promise — that the bytes
|
||||
survive a power cut — and none of the snapshots written here needs
|
||||
it: each is replaced within seconds of the daemon starting. On btrfs
|
||||
that fsync was 25× the payload at the block layer (62.5 KiB per 2.5 KB
|
||||
write, measured), twice a second. `durable` keeps it for a caller
|
||||
that genuinely wants it.
|
||||
"""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(payload, f, separators=(",", ":"))
|
||||
f.flush()
|
||||
if durable:
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def read_text_bounded(path: Path, max_bytes: int):
|
||||
"""Read a state file, enforcing every property on the fd actually read.
|
||||
|
||||
`O_NOFOLLOW` refuses a symlinked path outright, `O_NONBLOCK` means a
|
||||
FIFO left at the path returns instead of stalling the caller, `fstat`
|
||||
on the descriptor proves it is a regular file, and the cap bounds the
|
||||
read itself rather than trusting a size sampled beforehand. Returns
|
||||
(text, stamp) or None; `stamp` is (mtime_ns, size), enough for a
|
||||
caller to skip re-reading an unchanged file.
|
||||
|
||||
This is the only way state reaches a reader — the QML side consumes it
|
||||
through `nexthop stream` rather than opening these paths itself, so an
|
||||
oversized or non-regular file can never allocate or block inside the
|
||||
long-lived shell process.
|
||||
"""
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK)
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
return None
|
||||
chunks, total = [], 0
|
||||
while total <= max_bytes:
|
||||
try:
|
||||
chunk = os.read(fd, min(65536, max_bytes + 1 - total))
|
||||
except BlockingIOError:
|
||||
break
|
||||
except OSError:
|
||||
return None
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
return None
|
||||
stamp = (st.st_mtime_ns, st.st_size)
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
return b"".join(chunks).decode("utf-8"), stamp
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def read_json(path: Path, default=None, max_bytes: int = 4 * 1024 * 1024):
|
||||
"""Bounded read of a state file, parsed. The bound lives on the read,
|
||||
not on a prior stat, for the same reason as the daemon's config read."""
|
||||
got = read_text_bounded(path, max_bytes)
|
||||
if got is None:
|
||||
return default
|
||||
try:
|
||||
return json.loads(got[0])
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def retire_legacy_snapshots(old_dir: Path, new_dir: Path, now: float = None):
|
||||
"""The snapshots moved to the runtime dir in 0.2.22; tidy the old place.
|
||||
|
||||
`nexthop stream` is a long-lived process that resolved its paths when it
|
||||
started, so a reader from before the move keeps watching the state dir
|
||||
for as long as it lives — through the daemon handover, until the shell
|
||||
reloads the QML. Deleting live.json there would leave that reader
|
||||
holding the last number it saw, as if it were current. It is rewritten
|
||||
once instead, as a tombstone: state "no-daemon", no index, and no pid,
|
||||
so the old bar shows "no data" and the old version watch finds nothing
|
||||
to retire. recent.json and apps.json are simply removed.
|
||||
"""
|
||||
old_dir, new_dir = Path(old_dir), Path(new_dir)
|
||||
if old_dir == new_dir:
|
||||
return
|
||||
now = time.time() if now is None else now
|
||||
for name in (RECENT, APPS):
|
||||
try:
|
||||
(old_dir / name).unlink()
|
||||
except OSError:
|
||||
pass
|
||||
live = old_dir / LIVE
|
||||
got = read_text_bounded(live, 256 * 1024)
|
||||
if got is None:
|
||||
return
|
||||
try:
|
||||
payload = json.loads(got[0])
|
||||
except ValueError:
|
||||
payload = None
|
||||
if not isinstance(payload, dict):
|
||||
try:
|
||||
live.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
for key in ("pid", "pid_start", "daemon_version"):
|
||||
payload.pop(key, None)
|
||||
payload.update({"t": round(now, 3), "state": "no-daemon", "index": None,
|
||||
"band": None, "down_since": None})
|
||||
write_atomic(live, payload, durable=True)
|
||||
@@ -0,0 +1,402 @@
|
||||
"""Persistence: per-minute rows, hourly rollups, tests and events.
|
||||
|
||||
sqlite from the standard library, in WAL mode so the CLI can read a window
|
||||
of history while the daemon is mid-write. Raw half-second samples never
|
||||
reach the disk — they are folded into a minute row and discarded, which is
|
||||
what keeps a month of continuous monitoring under about 12 MB.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .probes import nearest_rank
|
||||
|
||||
SAMPLE_COLUMNS = [
|
||||
"local_p50", "local_p95", "local_jitter", "local_loss",
|
||||
"wan_p50", "wan_p95", "wan_jitter", "wan_loss",
|
||||
# 0.2.20: the two order statistics the scored fold and its critics
|
||||
# actually turn on. Lag leans on p75; Orb headlines a high-water max;
|
||||
# LibreQoS takes a phase percentile. Comparing those against our own
|
||||
# history was only possible as an upper bound because neither was ever
|
||||
# written down — p50 and p95 alone cannot reconstruct either. Recorded
|
||||
# now, scored never: the decision needs real days behind it, the same
|
||||
# rule loaded latency was held to in 0.1.11.
|
||||
"local_p75", "local_max", "wan_p75", "wan_max",
|
||||
"lag", "rx_bps", "tx_bps", "signal_dbm",
|
||||
"resp", "rel", "spd", "idx",
|
||||
# Latency split by what the link was doing at the time. The gap between
|
||||
# them is bufferbloat, and it only accumulates into something worth
|
||||
# scoring if it is recorded minute by minute first.
|
||||
"lag_idle", "lag_loaded",
|
||||
# 0.2.0: what ICMP alone would have scored, beside the instrument-
|
||||
# scored lag — the basis switch stays auditable per minute.
|
||||
"lag_icmp",
|
||||
]
|
||||
|
||||
# Minute-only, and deliberately not in SAMPLE_COLUMNS: `rollup_hours` averages
|
||||
# everything in that list, and a mean of drains destroys the one thing the
|
||||
# drain is being stored for. Its value is quantised by probe cadence, so what
|
||||
# has to survive is the DISTRIBUTION — sixty of them averaged is a number with
|
||||
# none of that in it.
|
||||
#
|
||||
# 0.2.37. Until now the drain was published to live.json and stored nowhere,
|
||||
# so a figure on screen could never be checked afterwards; the distribution
|
||||
# that showed it was quantised had to come from the other implementation
|
||||
# because this one had no history to look at.
|
||||
#
|
||||
# Three numbers rather than one. `drain_settled` says whether the link
|
||||
# recovered or the window merely ended, so a censored floor is not read as a
|
||||
# measurement. `drain_min_ms` is the tightest bound across the seated
|
||||
# instruments beside the loosest, which is what is published today — carrying
|
||||
# both is what lets the choice between them be settled from history instead of
|
||||
# argued. `drain_src` names the instrument the published value came from,
|
||||
# because each instrument's value is floored at its own cadence and knowing
|
||||
# which one won is the difference between an auditable figure and a guess.
|
||||
MINUTE_ONLY_REAL = ["drain_ms", "drain_min_ms", "drain_settled"]
|
||||
MINUTE_ONLY_TEXT = ["drain_src"]
|
||||
|
||||
_COLS_SQL = ", ".join(f"{c} REAL" for c in SAMPLE_COLUMNS)
|
||||
_MINUTE_EXTRA_SQL = ", ".join(
|
||||
[f"{c} REAL" for c in MINUTE_ONLY_REAL] + [f"{c} TEXT" for c in MINUTE_ONLY_TEXT])
|
||||
|
||||
SCHEMA = f"""
|
||||
CREATE TABLE IF NOT EXISTS minute (
|
||||
ts INTEGER PRIMARY KEY, {_COLS_SQL}, {_MINUTE_EXTRA_SQL},
|
||||
iface TEXT, network TEXT, probes TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS hour (
|
||||
ts INTEGER PRIMARY KEY, {_COLS_SQL}, iface TEXT, network TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tests (
|
||||
ts INTEGER PRIMARY KEY, kind TEXT, engine TEXT,
|
||||
down_mbps REAL, up_mbps REAL, ping_idle REAL, ping_loaded REAL,
|
||||
jitter REAL, bytes INTEGER, server TEXT, ok INTEGER, detail TEXT,
|
||||
network TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL, ended_ts INTEGER, kind TEXT, severity TEXT,
|
||||
leg TEXT, detail TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS events_ts ON events(ts);
|
||||
CREATE INDEX IF NOT EXISTS tests_kind_ts ON tests(kind, ts);
|
||||
"""
|
||||
|
||||
|
||||
def _locked(method):
|
||||
"""Serialise access to the one connection — see Store."""
|
||||
@functools.wraps(method)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
with self._lock:
|
||||
return method(self, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
class Store:
|
||||
"""One connection, one lock.
|
||||
|
||||
Three threads reach this object: the daemon loop (minute rows, events,
|
||||
a read for Reliability on every tick), the content-test worker and the
|
||||
peak-test worker (one row each when they finish). The connection is
|
||||
opened with `check_same_thread=False`, which only tells the sqlite3
|
||||
module to allow that — it does not make concurrent use of one
|
||||
connection safe, and a forced overlap reproduces "bad parameter or
|
||||
other API misuse" and a lost row. Every transaction here is a few
|
||||
milliseconds, so a mutex is the whole fix; a writer thread with a
|
||||
queue was considered and is more machinery than three callers need.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path, read_only: bool = False):
|
||||
self.path = Path(path)
|
||||
self._lock = threading.RLock()
|
||||
if read_only:
|
||||
uri = f"file:{self.path}?mode=ro"
|
||||
self.db = sqlite3.connect(uri, uri=True, timeout=5.0)
|
||||
else:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.db = sqlite3.connect(self.path, timeout=5.0,
|
||||
check_same_thread=False)
|
||||
self.db.executescript(SCHEMA)
|
||||
self._migrate()
|
||||
self.db.execute("PRAGMA journal_mode=WAL")
|
||||
self.db.execute("PRAGMA synchronous=NORMAL")
|
||||
self.db.commit()
|
||||
self.db.row_factory = sqlite3.Row
|
||||
|
||||
def _migrate(self):
|
||||
"""Additive migrations for databases created by older versions."""
|
||||
for table, column in (("tests", "network"),
|
||||
("minute", "lag_idle"), ("minute", "lag_loaded"),
|
||||
("hour", "lag_idle"), ("hour", "lag_loaded"),
|
||||
("minute", "lag_icmp"), ("hour", "lag_icmp"),
|
||||
("minute", "probes"),
|
||||
("minute", "local_p75"), ("hour", "local_p75"),
|
||||
("minute", "local_max"), ("hour", "local_max"),
|
||||
("minute", "wan_p75"), ("hour", "wan_p75"),
|
||||
("minute", "wan_max"), ("hour", "wan_max"),
|
||||
("minute", "drain_ms"),
|
||||
("minute", "drain_min_ms"),
|
||||
("minute", "drain_settled"),
|
||||
("minute", "drain_src")):
|
||||
try:
|
||||
self.db.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN {column} "
|
||||
f"{'TEXT' if column in ('network', 'probes', 'drain_src') else 'REAL'}")
|
||||
except sqlite3.OperationalError:
|
||||
pass # column already there
|
||||
|
||||
@_locked
|
||||
def close(self):
|
||||
try:
|
||||
self.db.close()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------- writes
|
||||
|
||||
@_locked
|
||||
def put_minute(self, ts: int, values: dict, iface: str = "",
|
||||
network: str = "", probes: str = ""):
|
||||
extra = MINUTE_ONLY_REAL + MINUTE_ONLY_TEXT
|
||||
cols = ["ts"] + SAMPLE_COLUMNS + extra + ["iface", "network", "probes"]
|
||||
row = ([int(ts)]
|
||||
+ [values.get(c) for c in SAMPLE_COLUMNS]
|
||||
+ [values.get(c) for c in extra]
|
||||
+ [iface, network, probes])
|
||||
placeholders = ", ".join("?" * len(cols))
|
||||
self.db.execute(
|
||||
f"INSERT OR REPLACE INTO minute ({', '.join(cols)}) VALUES ({placeholders})",
|
||||
row,
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
@_locked
|
||||
def put_test(self, ts: int, kind: str, engine: str, **kw):
|
||||
self.db.execute(
|
||||
"""INSERT OR REPLACE INTO tests
|
||||
(ts, kind, engine, down_mbps, up_mbps, ping_idle, ping_loaded,
|
||||
jitter, bytes, server, ok, detail, network)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(int(ts), kind, engine, kw.get("down_mbps"), kw.get("up_mbps"),
|
||||
kw.get("ping_idle"), kw.get("ping_loaded"), kw.get("jitter"),
|
||||
kw.get("bytes"), kw.get("server"), 1 if kw.get("ok", True) else 0,
|
||||
kw.get("detail", ""), kw.get("network", "")),
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
@_locked
|
||||
def open_event(self, ts: int, kind: str, severity: str, leg: str, detail: str) -> int:
|
||||
cur = self.db.execute(
|
||||
"INSERT INTO events (ts, kind, severity, leg, detail) VALUES (?,?,?,?,?)",
|
||||
(int(ts), kind, severity, leg, detail),
|
||||
)
|
||||
self.db.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
@_locked
|
||||
def close_event(self, event_id: int, ended_ts: int, detail: str = None):
|
||||
if detail is None:
|
||||
self.db.execute("UPDATE events SET ended_ts=? WHERE id=?",
|
||||
(int(ended_ts), event_id))
|
||||
else:
|
||||
self.db.execute("UPDATE events SET ended_ts=?, detail=? WHERE id=?",
|
||||
(int(ended_ts), detail, event_id))
|
||||
self.db.commit()
|
||||
|
||||
# ------------------------------------------------------------- maintenance
|
||||
|
||||
@_locked
|
||||
def rollup_hours(self, now: float = None):
|
||||
"""Fold complete minutes into hour rows.
|
||||
|
||||
Averages the averages, which is fair because every minute row covers
|
||||
the same span. Percentiles do not survive that — an hourly p95 built
|
||||
from sixty per-minute p95s is a mean of p95s, and it is labelled as
|
||||
such wherever it is displayed. The same caveat binds harder to the
|
||||
new max columns: an hourly `local_max` is a mean of sixty maxima,
|
||||
which is not the hour's worst sample and must never be shown as one.
|
||||
Use the minute rows for anything that reasons about the tail.
|
||||
|
||||
An hour that spanned two networks is labelled with neither: MAX()
|
||||
would pick whichever name sorts last and file the other network's
|
||||
minutes under it. Blank is "mixed", which no consumer can mistake
|
||||
for a network.
|
||||
"""
|
||||
now = now or time.time()
|
||||
current_hour = int(now // 3600) * 3600
|
||||
avg = ", ".join(f"AVG({c}) AS {c}" for c in SAMPLE_COLUMNS)
|
||||
self.db.execute(
|
||||
f"""INSERT OR REPLACE INTO hour
|
||||
(ts, {', '.join(SAMPLE_COLUMNS)}, iface, network)
|
||||
SELECT (ts / 3600) * 3600 AS bucket, {avg},
|
||||
CASE WHEN COUNT(DISTINCT iface) > 1 THEN ''
|
||||
ELSE MAX(iface) END,
|
||||
CASE WHEN COUNT(DISTINCT network) > 1 THEN ''
|
||||
ELSE MAX(network) END
|
||||
FROM minute WHERE ts < ? GROUP BY bucket""",
|
||||
(current_hour,),
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
@_locked
|
||||
def prune(self, minute_days: int = 7, hour_days: int = 400, now: float = None):
|
||||
now = now or time.time()
|
||||
self.db.execute("DELETE FROM minute WHERE ts < ?",
|
||||
(int(now - minute_days * 86400),))
|
||||
self.db.execute("DELETE FROM hour WHERE ts < ?",
|
||||
(int(now - hour_days * 86400),))
|
||||
# Events were never pruned before 0.2.21 — about sixty rows a day,
|
||||
# unbounded. They keep the hourly history's horizon.
|
||||
self.db.execute("DELETE FROM events WHERE ts < ?",
|
||||
(int(now - hour_days * 86400),))
|
||||
self.db.commit()
|
||||
|
||||
@_locked
|
||||
def close_orphans(self, now: float = None) -> int:
|
||||
"""Close events a previous daemon left open. Returns how many.
|
||||
|
||||
Only the daemon that opened an event can close it, so one that
|
||||
died mid-outage — or was retired by the version handover with a
|
||||
rate-drop open — leaves `ended_ts` NULL for good. Two readers
|
||||
treat NULL as "still happening": `outage_stats` would charge such
|
||||
an outage against every Reliability window forever, and `events`
|
||||
would list it as ongoing. When it actually ended is unknowable,
|
||||
so it is closed at the shortest span the store accepts rather
|
||||
than at a guessed later time: undercharging by the lost tail is
|
||||
the safe direction, and inventing a duration is not.
|
||||
|
||||
Called once, after the lock is held — a second daemon that loses
|
||||
the flock must not close the running one's events on its way out.
|
||||
"""
|
||||
cur = self.db.execute(
|
||||
"UPDATE events SET ended_ts = ts + 1 WHERE ended_ts IS NULL")
|
||||
self.db.commit()
|
||||
return cur.rowcount
|
||||
|
||||
# ---------------------------------------------------------------- reads
|
||||
|
||||
@_locked
|
||||
def series(self, seconds: float, now: float = None,
|
||||
resolution: str = "auto") -> list:
|
||||
"""History over a window, at whichever resolution suits it.
|
||||
|
||||
Auto: under six hours reads per-minute rows; anything longer reads
|
||||
hourly ones, so a seven-day graph is 168 points rather than 10,080.
|
||||
Callers that genuinely want the fine rows (the 24 h experience
|
||||
ribbon) ask for "minute" explicitly.
|
||||
"""
|
||||
now = now or time.time()
|
||||
if resolution in ("minute", "hour"):
|
||||
table = resolution
|
||||
else:
|
||||
table = "minute" if seconds <= 6 * 3600 else "hour"
|
||||
rows = self.db.execute(
|
||||
f"SELECT * FROM {table} WHERE ts >= ? ORDER BY ts",
|
||||
(int(now - seconds),),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows], table
|
||||
|
||||
@_locked
|
||||
def tests(self, limit: int = 20, kind: str = None) -> list:
|
||||
if kind:
|
||||
rows = self.db.execute(
|
||||
"SELECT * FROM tests WHERE kind=? ORDER BY ts DESC LIMIT ?",
|
||||
(kind, limit)).fetchall()
|
||||
else:
|
||||
rows = self.db.execute(
|
||||
"SELECT * FROM tests ORDER BY ts DESC LIMIT ?", (limit,)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@_locked
|
||||
def events(self, seconds: float = 7 * 86400, limit: int = 100,
|
||||
now: float = None) -> list:
|
||||
"""Events overlapping the window, newest first.
|
||||
|
||||
Filtering on start time alone dropped an outage that began before
|
||||
the window and ended inside it — the one the user opens the list
|
||||
to see. Same overlap rule `outage_stats` has always used.
|
||||
"""
|
||||
now = now or time.time()
|
||||
start = int(now - seconds)
|
||||
rows = self.db.execute(
|
||||
"""SELECT * FROM events
|
||||
WHERE ts >= ? OR ended_ts IS NULL OR ended_ts >= ?
|
||||
ORDER BY ts DESC LIMIT ?""",
|
||||
(start, start, limit)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@_locked
|
||||
def baseline_speed(self, days: int = 30, network: str = "",
|
||||
min_samples: int = 5, now: float = None,
|
||||
fallback: bool = True):
|
||||
"""This connection's own normal: the p90 of recent content downloads.
|
||||
|
||||
p90 rather than max so one lucky quiet-hour run does not set a bar
|
||||
the line can never reach again. Scoped to the current network when
|
||||
it has enough samples — the office's normal is not the home's —
|
||||
falling back to all networks, and to None until there is enough
|
||||
history to mean anything.
|
||||
"""
|
||||
now = now or time.time()
|
||||
since = int(now - days * 86400)
|
||||
|
||||
def p90(rows):
|
||||
vals = sorted(r["down_mbps"] for r in rows
|
||||
if r["down_mbps"] is not None)
|
||||
if len(vals) < min_samples:
|
||||
return None
|
||||
return nearest_rank(vals, 0.9)
|
||||
|
||||
if network:
|
||||
rows = self.db.execute(
|
||||
"""SELECT down_mbps FROM tests
|
||||
WHERE kind='content' AND ok=1 AND ts >= ? AND network = ?""",
|
||||
(since, network)).fetchall()
|
||||
result = p90(rows)
|
||||
if result is not None:
|
||||
return result
|
||||
# The caller decides whether a cross-network baseline is meaningful.
|
||||
# For the degradation penalty it is not: "is it normal here" cannot
|
||||
# be answered with another network's normal.
|
||||
if not fallback:
|
||||
return None
|
||||
rows = self.db.execute(
|
||||
"""SELECT down_mbps FROM tests
|
||||
WHERE kind='content' AND ok=1 AND ts >= ?""",
|
||||
(since,)).fetchall()
|
||||
return p90(rows)
|
||||
|
||||
@_locked
|
||||
def outage_stats(self, seconds: float, now: float = None):
|
||||
"""(fraction fully down, count of disruptions, fraction disrupted).
|
||||
|
||||
Disruptions carry their duration as well as their count because
|
||||
reliability charges both kinds of interruption in the same currency —
|
||||
time. Counting alone made three brief blips outweigh an hour offline.
|
||||
"""
|
||||
now = now or time.time()
|
||||
start = now - seconds
|
||||
rows = self.db.execute(
|
||||
"""SELECT ts, ended_ts, kind FROM events
|
||||
WHERE kind IN ('outage', 'disruption') AND (ended_ts IS NULL OR ended_ts >= ?)""",
|
||||
(int(start),)).fetchall()
|
||||
down = 0.0
|
||||
disrupted = 0.0
|
||||
disruptions = 0
|
||||
for r in rows:
|
||||
begin = max(r["ts"], start)
|
||||
end = r["ended_ts"] if r["ended_ts"] else now
|
||||
end = min(end, now)
|
||||
if end <= begin:
|
||||
continue
|
||||
if r["kind"] == "outage":
|
||||
down += end - begin
|
||||
else:
|
||||
disruptions += 1
|
||||
disrupted += end - begin
|
||||
span = seconds if seconds else 0.0
|
||||
return ((down / span if span else 0.0), disruptions,
|
||||
(disrupted / span if span else 0.0))
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Is a newer version published? Notify, never install.
|
||||
|
||||
Omarchy checks itself for updates (`omarchy-update-available` looks at its own
|
||||
checkout and its package) but nothing checks plugins, so a user can sit on an
|
||||
old Nexthop indefinitely without ever being told. This closes that gap the
|
||||
smallest way it can be closed.
|
||||
|
||||
**This module never updates anything.** It answers one question — is the
|
||||
installed checkout behind its origin — and the answer becomes a quiet glyph in
|
||||
the panel naming the command the user can run. Updating stays where Omarchy
|
||||
put it: `omarchy plugin update`, which shows the diff and asks. A plugin that
|
||||
fetched and ran new code would be self-modifying code inside a system that
|
||||
deliberately gates updates behind human review, and it would reopen a security
|
||||
review that took four rounds to clear.
|
||||
|
||||
How it stays cheap and read-only:
|
||||
|
||||
* `git ls-remote` asks the remote for its HEAD without writing a single byte
|
||||
into the user's checkout — no fetch, no new objects, no refs touched. It
|
||||
takes well under a second and needs no credentials.
|
||||
* Whether we are *behind* rather than merely *different* is then decided from
|
||||
objects we already have, so a developer checkout that is ahead of origin is
|
||||
never nagged.
|
||||
* Egress is to the repository the user installed from and nowhere else. It
|
||||
carries nothing about them or their network. It is still egress, so it is
|
||||
disclosed and `updateCheck` turns it off.
|
||||
|
||||
The one piece of untrusted input here is the commit id the remote hands back,
|
||||
and it goes on to be an argument to another `git` call — so it is validated
|
||||
against the exact 40-hex shape before it reaches a subprocess, the same
|
||||
doctrine `vet_target()` applies to URLs we did not choose.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
# A git object id and nothing else. This is the guard that matters: the value
|
||||
# arrives from the network and is then passed as an argument to git.
|
||||
RE_SHA = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
# A release is a rare event, and the check exists to catch the user who would
|
||||
# otherwise never look. Daily is generous.
|
||||
CHECK_INTERVAL_S = 24 * 3600
|
||||
# Not at startup: the daemon restarts with the shell, and a check on every
|
||||
# restart would be noise for no benefit. Nothing is lost by waiting.
|
||||
FIRST_CHECK_DELAY_S = 300
|
||||
# The remote may be slow, unreachable, or a captive portal that answers
|
||||
# everything. None of those may stall the daemon.
|
||||
GIT_TIMEOUT_S = 20
|
||||
# `ls-remote` prints one short line per ref; this is far past HEAD alone.
|
||||
MAX_LS_REMOTE_BYTES = 64 * 1024
|
||||
|
||||
|
||||
def verdict(local: str, remote: str, have_remote: bool,
|
||||
head_before_remote: bool, remote_before_head: bool) -> str:
|
||||
"""Where the checkout stands relative to origin. Pure, so it is testable.
|
||||
|
||||
* `current` — the same commit.
|
||||
* `behind` — origin is strictly ahead of us: an update.
|
||||
* `ahead` — we are strictly ahead of origin: a dev checkout.
|
||||
* `diverged` — neither contains the other.
|
||||
* `unknown` — we could not tell, and say so rather than guessing.
|
||||
|
||||
Both ancestry directions are needed, and the reason is the likeliest
|
||||
case of all: `omarchy plugin update` fetches before it shows its diff, so
|
||||
a user who looked and said "not now" already *holds* origin's commit
|
||||
while still being behind it. Deciding "behind" from "we have never seen
|
||||
that object" alone would show that user nothing.
|
||||
"""
|
||||
if not local or not remote:
|
||||
return "unknown"
|
||||
if local == remote:
|
||||
return "current"
|
||||
if not have_remote:
|
||||
# We do not hold origin's commit at all, so it is newer than anything
|
||||
# we know about.
|
||||
return "behind"
|
||||
if head_before_remote:
|
||||
return "behind"
|
||||
if remote_before_head:
|
||||
return "ahead"
|
||||
return "diverged"
|
||||
|
||||
|
||||
class UpdateWatch:
|
||||
"""Asks origin, on a slow cadence, whether this checkout is behind.
|
||||
|
||||
The result lives in memory only. A daemon restart forgets it and waits
|
||||
`FIRST_CHECK_DELAY_S` before asking again, which is why no fifth state
|
||||
file was added for this.
|
||||
"""
|
||||
|
||||
def __init__(self, repo: Path = None, enabled: bool = True, spawn=None):
|
||||
# Derived from this file, not from the working directory: the daemon
|
||||
# can be started from anywhere.
|
||||
self.repo = Path(repo) if repo else Path(__file__).resolve().parent.parent
|
||||
self.enabled = enabled
|
||||
self.state = "unknown"
|
||||
self.checked_ts = None
|
||||
self._next = None
|
||||
# How a check is run. Off the loop by default: `ls-remote` may sit
|
||||
# at its 20 s timeout on exactly the flaky network where the
|
||||
# outage watch matters, and the loop must not wait for it. Tests
|
||||
# pass a synchronous spawn so the verdict lands within the tick.
|
||||
self._spawn = spawn or self._in_thread
|
||||
self._lock = threading.Lock()
|
||||
self._inflight = False
|
||||
self._pending = None # a verdict awaiting the next tick
|
||||
|
||||
@staticmethod
|
||||
def _in_thread(fn):
|
||||
threading.Thread(target=fn, name="update-check", daemon=True).start()
|
||||
|
||||
def _git(self, *args, capture: bool = True):
|
||||
"""One git call. Fixed argv, no shell, bounded, always timed out."""
|
||||
env = dict(os.environ)
|
||||
# A credential prompt on a private or moved remote would otherwise
|
||||
# block until the timeout every single time.
|
||||
env["GIT_TERMINAL_PROMPT"] = "0"
|
||||
env.pop("GIT_ASKPASS", None)
|
||||
env.pop("SSH_ASKPASS", None)
|
||||
# `git -C <dir>` does not require <dir> to BE a repository: git
|
||||
# walks up until it finds one. A copy-install with no .git of its
|
||||
# own, inside a home that is itself a checkout (dotfiles), would
|
||||
# otherwise be compared against — and contact the remote of — the
|
||||
# wrong repository every day. The ceiling stops the walk at our
|
||||
# parent, so a missing .git answers "unknown", never someone else.
|
||||
env["GIT_CEILING_DIRECTORIES"] = str(self.repo.parent)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(self.repo),
|
||||
# No credential helper and no askpass program may run for
|
||||
# this: a private or moved remote gets "unknown", not a
|
||||
# keyring prompt once a day. GIT_TERMINAL_PROMPT above
|
||||
# covers only git's own tty prompt.
|
||||
"-c", "credential.helper=", "-c", "core.askPass=",
|
||||
*args],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE if capture else subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=GIT_TIMEOUT_S, env=env,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None, ""
|
||||
out = ""
|
||||
if capture and proc.stdout:
|
||||
out = proc.stdout[:MAX_LS_REMOTE_BYTES].decode(
|
||||
"utf-8", "replace").strip()
|
||||
return proc.returncode, out
|
||||
|
||||
def _local_head(self):
|
||||
code, out = self._git("rev-parse", "HEAD")
|
||||
return out if code == 0 and RE_SHA.match(out) else None
|
||||
|
||||
def _remote_head(self):
|
||||
"""Origin's HEAD, or None. Writes nothing into the checkout."""
|
||||
code, out = self._git("ls-remote", "origin", "HEAD")
|
||||
if code != 0 or not out:
|
||||
return None
|
||||
sha = out.split()[0] if out.split() else ""
|
||||
# The guard: this value came off the network and is about to become a
|
||||
# git argument. Anything but a bare object id is refused outright.
|
||||
return sha if RE_SHA.match(sha) else None
|
||||
|
||||
def check(self) -> str:
|
||||
"""Run one check now and return the verdict. Read-only throughout."""
|
||||
if not shutil.which("git"):
|
||||
return "unknown"
|
||||
local = self._local_head()
|
||||
if not local:
|
||||
return "unknown" # not a git checkout, or no commits
|
||||
remote = self._remote_head()
|
||||
if not remote:
|
||||
return "unknown" # offline, no origin, or a junk answer
|
||||
if local == remote:
|
||||
return "current"
|
||||
# Do we already hold origin's commit? Only objects we have are
|
||||
# consulted from here on, so no fetch is ever needed.
|
||||
code, _ = self._git("cat-file", "-e", remote + "^{commit}",
|
||||
capture=False)
|
||||
have = code == 0
|
||||
before, after = False, False
|
||||
if have:
|
||||
# Exit 1 means "not an ancestor", which is an answer, not an error.
|
||||
code, _ = self._git("merge-base", "--is-ancestor", local, remote,
|
||||
capture=False)
|
||||
before = code == 0
|
||||
code, _ = self._git("merge-base", "--is-ancestor", remote, local,
|
||||
capture=False)
|
||||
after = code == 0
|
||||
return verdict(local, remote, have, before, after)
|
||||
|
||||
def _run_check(self):
|
||||
state = self.check()
|
||||
with self._lock:
|
||||
self._pending = state
|
||||
self._inflight = False
|
||||
|
||||
def _collect(self, now: float):
|
||||
with self._lock:
|
||||
state, self._pending = self._pending, None
|
||||
if state is not None:
|
||||
self.state = state
|
||||
self.checked_ts = round(now)
|
||||
|
||||
def tick(self, now: float):
|
||||
"""Called from the daemon loop; starts a check when one is due and
|
||||
adopts the verdict of one that has finished. Never blocks."""
|
||||
if not self.enabled:
|
||||
# Turning the setting off clears any standing notice, so the
|
||||
# glyph disappears rather than lingering with a stale answer.
|
||||
# A check already running is left to finish and its answer
|
||||
# dropped here, unread.
|
||||
self.state, self.checked_ts, self._next = "unknown", None, None
|
||||
with self._lock:
|
||||
self._pending = None
|
||||
return
|
||||
self._collect(now)
|
||||
if self._next is None:
|
||||
self._next = now + FIRST_CHECK_DELAY_S
|
||||
return
|
||||
if now < self._next:
|
||||
return
|
||||
self._next = now + CHECK_INTERVAL_S
|
||||
with self._lock:
|
||||
if self._inflight:
|
||||
return
|
||||
self._inflight = True
|
||||
self._spawn(self._run_check)
|
||||
# A synchronous spawn has already finished; adopt it now rather
|
||||
# than a tick later.
|
||||
self._collect(now)
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
"""What the panel reads. None while nothing has been established."""
|
||||
if not self.enabled or self.state == "unknown":
|
||||
return None
|
||||
return {
|
||||
"state": self.state,
|
||||
"available": self.state == "behind",
|
||||
"checked_ts": self.checked_ts,
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// The recent history drawn into the path connectors on the Overview.
|
||||
//
|
||||
// The two lines between the nodes were flat 2 px bars coloured by the current
|
||||
// value. They already occupied this space, so showing three minutes of each
|
||||
// leg there costs no new row — the constraint that decides most of this
|
||||
// panel's layout.
|
||||
//
|
||||
// What this file owns is slot derivation and the paint. It owns no arithmetic
|
||||
// about the legs: the ISP leg is subtracted in the daemon (`score.wan_point_ms`)
|
||||
// and arrives per point, because the rule that matters there is the one that
|
||||
// REFUSES to answer when a gateway reads slower than the internet behind it,
|
||||
// and a second copy of a refusal is the copy that forgets. Colours come in as
|
||||
// a function so `PathChain.legColor` stays the only place thresholds live.
|
||||
//
|
||||
// No `.pragma library`: it would drop the QML context and `Qt.rgba` with it.
|
||||
|
||||
// 36 slots of 5 s is three minutes. recent.json holds 360 of them, but the
|
||||
// Latency tab already draws the whole half hour; a second long window here
|
||||
// would be the same chart twice. Three minutes is what changes while you
|
||||
// watch, which is the point of putting it here.
|
||||
var SLOTS = 36;
|
||||
|
||||
// Both legs share one zero-based scale at least this tall, so a wobble on a
|
||||
// 2 ms local leg cannot be drawn larger than a slower WAN. The cost is that a
|
||||
// fast local leg is a flat line near the floor. That is honest: it IS flat.
|
||||
var SCALE_FLOOR_MS = 50;
|
||||
|
||||
// The ring is the widest thing drawn, so it — not the line — sets the
|
||||
// margins. Everything below is derived from it: a plot inset by less than
|
||||
// the ring's outer edge cuts the ring, and the newest point is exactly
|
||||
// where the ring goes, which is exactly the right edge. That is how the
|
||||
// first cut shipped: the line was laid out edge to edge, correctly for a
|
||||
// line, and the ring inherited an inset of zero.
|
||||
// [Plamen, 2026-09-12: "its a bit cut off the pulsing dot?"]
|
||||
var DOT_R = 2;
|
||||
var RING_R = 4.5;
|
||||
var RING_STROKE = 1.2;
|
||||
|
||||
// The arc's outer edge, which is what must stay inside the canvas.
|
||||
var PAD = RING_R + RING_STROKE / 2 + 0.4;
|
||||
|
||||
// Clearance between the lowest a line may sit and the down bar, so a
|
||||
// value at zero is not read as an outage marker.
|
||||
var DOWN_GAP = 2;
|
||||
|
||||
/**
|
||||
* One leg's last `n` slots, newest last.
|
||||
*
|
||||
* Three outcomes, and keeping them apart is the whole job:
|
||||
* {v: ms} measured
|
||||
* {down: true} probes went out and nothing came back
|
||||
* null nothing to say — no probe was sent, or the figure is withheld
|
||||
*
|
||||
* `loss === null` is the daemon's way of saying it sampled nothing in that
|
||||
* bucket, which is a gap rather than an outage. A bucket that sampled and got
|
||||
* no reply carries a loss figure with no `total`, and that is down.
|
||||
*/
|
||||
function slots(points, key, n) {
|
||||
var out = [];
|
||||
var pts = points || [];
|
||||
var from = Math.max(0, pts.length - (n || SLOTS));
|
||||
for (var i = from; i < pts.length; i++) {
|
||||
var p = pts[i];
|
||||
if (!p || p.loss === null || p.loss === undefined) { out.push(null); continue; }
|
||||
if (key === "local") {
|
||||
// The router itself did not answer.
|
||||
if (p.local === null || p.local === undefined) { out.push({ down: true }); continue; }
|
||||
out.push({ v: p.local });
|
||||
continue;
|
||||
}
|
||||
// Nothing beyond the router answered at all.
|
||||
if (p.total === null || p.total === undefined) { out.push({ down: true }); continue; }
|
||||
// It answered, but the subtraction was withheld — unknown, not zero,
|
||||
// and certainly not an outage.
|
||||
if (p.wan === null || p.wan === undefined) { out.push(null); continue; }
|
||||
out.push({ v: p.wan });
|
||||
}
|
||||
while (out.length < (n || SLOTS)) out.unshift(null);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The tallest measured value across every leg, floored. */
|
||||
function sharedMax(seriesList, floor) {
|
||||
var m = floor === undefined ? SCALE_FLOOR_MS : floor;
|
||||
for (var i = 0; i < seriesList.length; i++) {
|
||||
var s = seriesList[i] || [];
|
||||
for (var j = 0; j < s.length; j++) {
|
||||
if (s[j] && !s[j].down && s[j].v > m) m = s[j].v;
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** The newest slot that carries anything at all, or -1. */
|
||||
function newestIndex(series) {
|
||||
for (var i = series.length - 1; i >= 0; i--) if (series[i]) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint one leg.
|
||||
*
|
||||
* `opts`: { max, phase, live, motion, colorFor, downColor, dimColor }
|
||||
* `phase` runs 0..1 and drives the ring; `colorFor(ms, down)` is the panel's
|
||||
* own `legColor`, passed in rather than reimplemented.
|
||||
*/
|
||||
function draw(ctx, w, h, series, opts) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!series || series.length === 0 || w <= 0 || h <= 0) return;
|
||||
|
||||
var max = opts.max || SCALE_FLOOR_MS;
|
||||
|
||||
// One margin on all four sides, so a ring fits wherever a mark can
|
||||
// land: at the top of the scale, on the down bar, or at either end.
|
||||
var downY = h - PAD;
|
||||
var plotTop = PAD;
|
||||
var plotH = Math.max(1, downY - DOWN_GAP - plotTop);
|
||||
var left = PAD, right = w - PAD;
|
||||
var step = series.length > 1 ? (right - left) / (series.length - 1) : 0;
|
||||
|
||||
function yOf(v) { return plotTop + plotH - (Math.min(v, max) / max) * plotH; }
|
||||
|
||||
// The measured line, cut at every gap so nothing is ever drawn across one.
|
||||
var run = [];
|
||||
function flushLine() {
|
||||
if (run.length > 1) {
|
||||
ctx.beginPath();
|
||||
for (var i = 0; i < run.length; i++) {
|
||||
if (i === 0) ctx.moveTo(run[i].x, run[i].y);
|
||||
else ctx.lineTo(run[i].x, run[i].y);
|
||||
}
|
||||
ctx.strokeStyle = run[run.length - 1].c;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
} else if (run.length === 1) {
|
||||
ctx.fillStyle = run[0].c;
|
||||
ctx.fillRect(run[0].x - 0.75, run[0].y - 0.75, 1.5, 1.5);
|
||||
}
|
||||
run = [];
|
||||
}
|
||||
|
||||
// A run of down slots is ONE outage, so it is one bar along the bottom
|
||||
// rather than a row of ticks: its length is the duration.
|
||||
var downRun = null;
|
||||
function flushDown() {
|
||||
if (!downRun) return;
|
||||
ctx.fillStyle = opts.downColor;
|
||||
ctx.fillRect(downRun.x0 - 1, downY - 1.5,
|
||||
Math.max(2, downRun.x1 - downRun.x0 + 2), 3);
|
||||
downRun = null;
|
||||
}
|
||||
|
||||
for (var i = 0; i < series.length; i++) {
|
||||
var p = series[i], x = left + i * step;
|
||||
if (!p) { flushLine(); flushDown(); continue; }
|
||||
if (p.down) {
|
||||
flushLine();
|
||||
if (downRun) downRun.x1 = x; else downRun = { x0: x, x1: x };
|
||||
continue;
|
||||
}
|
||||
flushDown();
|
||||
run.push({ x: x, y: yOf(p.v), c: opts.colorFor(p.v, false) });
|
||||
}
|
||||
flushLine();
|
||||
flushDown();
|
||||
|
||||
// The ring goes on the LAST SLOT DRAWN, whatever kind it is.
|
||||
//
|
||||
// A down sample is a measurement: a probe went out and nothing answered,
|
||||
// which is a reading arriving. So it pulses, in red, like any other newest
|
||||
// mark. Only a gap gets no ring, because nothing arrived to claim. The
|
||||
// reference implementation never pulses a down marker, and it strands the
|
||||
// ring back at the last measured point while the line's real end sits
|
||||
// somewhere else, which reads as a rendering fault.
|
||||
// [D, Plamen, 2026-09-12]
|
||||
var last = series.length - 1;
|
||||
if (!series[last]) return;
|
||||
var lx = left + last * step;
|
||||
var ly = series[last].down ? downY : yOf(series[last].v);
|
||||
var col = series[last].down ? opts.downColor
|
||||
: opts.colorFor(series[last].v, false);
|
||||
|
||||
if (opts.live) {
|
||||
ctx.beginPath();
|
||||
if (opts.motion) {
|
||||
ctx.arc(lx, ly, DOT_R + opts.phase * (RING_R - DOT_R), 0, Math.PI * 2);
|
||||
ctx.globalAlpha = 0.5 * (1 - opts.phase);
|
||||
} else {
|
||||
// The claim is still made, without motion.
|
||||
ctx.arc(lx, ly, RING_R, 0, Math.PI * 2);
|
||||
ctx.globalAlpha = 0.4;
|
||||
}
|
||||
ctx.strokeStyle = col;
|
||||
ctx.lineWidth = RING_STROKE;
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.arc(lx, ly, DOT_R, 0, Math.PI * 2);
|
||||
ctx.fillStyle = col;
|
||||
ctx.fill();
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user