- 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
581 lines
18 KiB
QML
581 lines
18 KiB
QML
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
|
||
}
|
||
}
|
||
}
|
||
}
|