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:
asepharyana
2026-09-23 15:19:12 +07:00
commit 1cdb82a76f
300 changed files with 78143 additions and 0 deletions
+231
View File
@@ -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
}
}
}
}
+21
View File
@@ -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
}
}
+195
View File
@@ -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()
}
}
}
}
+558
View File
@@ -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() }
}
}
}
+241
View File
@@ -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.
![Nexthop panel](preview.png)
## 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 |
|---|---|
| ![Latency tab](docs/latency.png) | ![Speed tab](docs/speed.png) |
| Wi-Fi | Applications |
|---|---|
| ![Wi-Fi tab](docs/wifi.png) | ![Apps tab](docs/apps.png) |
The event log, naming who did what — including the router that kicks:
![Events tab](docs/events.png)
## 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
}
}
+191
View File
@@ -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
}
}
+484
View File
@@ -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()
}
}
+620
View File
@@ -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 &middot; 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 &mdash; bounded, no-follow, regular-file-only, so the shell never opens a state path itself &nbsp;&middot;&nbsp; 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 &middot; ~/.LOCAL/STATE/NEXTHOP</div>
<div class="g3">
<div class="box">
<div class="bt">live.json</div>
<div class="bs">rewritten 2&times; / 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 &mdash; 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 &nbsp;&middot;&nbsp; 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 &middot; 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 &rarr; Responsiveness. Downtime &rarr; Reliability. Content speed &rarr; Speed. Weakest-link, 0&ndash;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 &rarr; cloudflare &rarr; 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 &mdash; 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 &mdash; 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 &mdash; 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 &middot; 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 &darr;</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 &middot; 94 excellent<br>local 1.2 ms &middot; wan 8.4 ms &middot; 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 &middot; 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 &rarr; 49.37.x.x. Same ISP, same AS.</div>
<div class="evd">&mdash;</div>
</div>
<div class="ev">
<div class="evt">Fri 12:04</div>
<div class="dot" style="background: #bb9af7;"></div>
<div class="evb">Roamed 3&times; between &hellip;25:fe &harr; &hellip;25:fa &harr; &hellip;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 &mdash; 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 &middot; 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 &rarr; 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 &rarr; 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 &middot; 0.11% &middot; 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&minus;</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}} &middot; {{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 &nbsp;&middot;&nbsp; typical <span style="color: #c0caf5; font-size: 11px;">{{d.lagTypical}} ms</span> &nbsp;&middot;&nbsp; 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 &middot; 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 &middot; {{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 &middot; 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 &middot; 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 &middot; FEEDS YOUR SCORE</div>
<div style="font-size: 10px; color: #565f89;">hourly &middot; ~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 &middot; 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 &middot; 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 &mdash; up to ~600 MB on a fast one, far less on a slow one &mdash; 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&minus;</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 &middot; OOKLA &middot; 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 &middot; 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 &middot; 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 &middot; 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;">&minus;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;">&minus;90 unusable</div>
<div style="font-size: 9px; color: #414868;">&minus;67 marginal</div>
<div style="font-size: 9px; color: #414868;">&minus;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 &amp; LOCAL LAG &middot; 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 &middot; 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 &middot; 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 &rarr; 44</div>
<div style="font-size: 10px; color: #565f89;">&minus;61 &rarr; &minus;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

+31
View File
@@ -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,
}
+204
View File
@@ -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