Files
omarchy/plugins/io.github.x3me.nexthop/EventsTab.qml
T
asepharyana 1cdb82a76f 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
2026-09-23 15:19:12 +07:00

581 lines
18 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}
}