aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rwxr-xr-xremote/sbb-remote44
-rw-r--r--sb_builder/__init__.py2
-rw-r--r--sb_builder/dashboard.py146
-rw-r--r--tests/test_gui.py35
-rw-r--r--tests/test_remote.py27
5 files changed, 237 insertions, 17 deletions
diff --git a/remote/sbb-remote b/remote/sbb-remote
index b26fa9f..d82120a 100755
--- a/remote/sbb-remote
+++ b/remote/sbb-remote
@@ -38,7 +38,7 @@ import tempfile
import time
import tomllib
-VERSION = "0.2.0"
+VERSION = "0.3.0"
DEFAULTS = {
"state_dir": "/var/lib/sb-builder",
"repo_dir": "/var/lib/sbopkg/SBo-danix",
@@ -358,12 +358,50 @@ def _uptime():
return float(f.read().split()[0])
+def _cpu_times(path="/proc/stat"):
+ """(busy, total) jiffies over all CPUs; idle and iowait are not busy."""
+ with open(path) as f:
+ fields = [int(x) for x in f.readline().split()[1:]]
+ total = sum(fields[:8]) # guest time is already in user/nice
+ return total - fields[3] - fields[4], total
+
+
+def _cpu():
+ """Busy percent over a short sample."""
+ b1, t1 = _cpu_times()
+ time.sleep(0.25)
+ b2, t2 = _cpu_times()
+ return round(100 * (b2 - b1) / max(t2 - t1, 1), 1)
+
+
+def _meminfo(path="/proc/meminfo"):
+ with open(path) as f:
+ kb = {k: int(v.split()[0]) for k, v in (line.split(":", 1) for line in f)}
+ return {"total": kb["MemTotal"] * 1024, "available": kb["MemAvailable"] * 1024}
+
+
+def _slackpkg(lastupdate="/var/lib/slackpkg/LASTUPDATE", pkgdir="/var/log/packages"):
+ """Last 'slackpkg update' (its LASTUPDATE stamp) and last package change."""
+ def update():
+ with open(lastupdate) as f:
+ return int(f.read().split()[0])
+
+ def upgrade():
+ # ponytail: newest package entry; slackrepo INSTALL=n, so this is slackpkg's
+ with os.scandir(pkgdir) as it:
+ return int(max(e.stat().st_mtime for e in it))
+ return {"update": _try(update), "upgrade": _try(upgrade)}
+
+
def cmd_stats(cfg):
- dirs = {"repo": cfg["repo_dir"], "state": cfg["state_dir"]}
+ root = _try(lambda: shutil.disk_usage("/"))
return {
"load": _try(os.getloadavg),
"uptime": _try(_uptime),
- "disk_free": {k: _try(lambda p=p: shutil.disk_usage(p).free) for k, p in dirs.items()},
+ "cpu": _try(_cpu),
+ "mem": _try(_meminfo),
+ "disk": {"total": root and root.total, "free": root and root.free},
+ "slackpkg": _slackpkg(),
"packages": _try(lambda: _packages(cfg)),
"sync": {o: _try(lambda o=o: int(_git(cfg, "log", "-1", "--format=%ct", "--", o + "/")))
for o in cfg["overlays"]},
diff --git a/sb_builder/__init__.py b/sb_builder/__init__.py
index 7c58f9a..563a7ce 100644
--- a/sb_builder/__init__.py
+++ b/sb_builder/__init__.py
@@ -16,4 +16,4 @@
"""sb-builder: Qt6 GUI driving the SlackBuild tooling on a build VM."""
-VERSION = "0.2.0" # must match remote/sbb-remote VERSION
+VERSION = "0.3.0" # must match remote/sbb-remote VERSION
diff --git a/sb_builder/dashboard.py b/sb_builder/dashboard.py
index 85c19f4..474af66 100644
--- a/sb_builder/dashboard.py
+++ b/sb_builder/dashboard.py
@@ -14,12 +14,13 @@
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-"""Dashboard tab: KPI tiles, slackrepo results bar, recent jobs, from stats and jobs."""
+"""Dashboard tab: KPI tiles, VM meters, slackrepo results bar, recent jobs."""
+import collections
import html
import time
-from PyQt6.QtCore import QEvent, QRectF, Qt, pyqtSignal
+from PyQt6.QtCore import QEvent, QPointF, QRectF, Qt, pyqtSignal
from PyQt6.QtGui import QColor, QFont, QPainter, QPalette
from PyQt6.QtWidgets import (QFrame, QGridLayout, QHBoxLayout, QLabel, QPushButton,
QToolTip, QVBoxLayout, QWidget)
@@ -30,6 +31,16 @@ from .fmt import ICON, NA, fmt_age, fmt_bytes, fmt_duration, fmt_time, job_title
STATUS = {"ok": "#0ca30c", "aborted": "#fab219", "bad": "#ec835a", "failed": "#d03b3b",
"killed": "#fab219", "lost": "#ec835a"}
GAP = 2 # px of surface between bar segments
+SPARK_POINTS = 60 # ~30 min at the default 30 s dashboard refresh
+
+
+def meter_color(pct, accent):
+ """Fill color by severity: accent, then warning from 80%, critical from 90%."""
+ if pct >= 90:
+ return QColor(STATUS["failed"])
+ if pct >= 80:
+ return QColor(STATUS["aborted"])
+ return accent
def num(n):
@@ -128,12 +139,111 @@ class StackedBar(QWidget):
return super().event(e)
+class MeterBar(QWidget):
+ """Horizontal meter; the track is a faint step of the fill's own color."""
+
+ def __init__(self):
+ super().__init__()
+ self.pct = None
+ self.setFixedHeight(10)
+
+ def paintEvent(self, _event):
+ p = QPainter(self)
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
+ p.setPen(Qt.PenStyle.NoPen)
+ fill = meter_color(self.pct or 0, self.palette().color(QPalette.ColorRole.Highlight))
+ track = QColor(fill)
+ track.setAlphaF(0.22)
+ p.setBrush(track)
+ p.drawRoundedRect(QRectF(self.rect()), 4, 4)
+ if self.pct:
+ p.setBrush(fill)
+ p.drawRoundedRect(QRectF(0, 0, self.width() * min(self.pct, 100) / 100, self.height()), 4, 4)
+
+
+class Sparkline(QWidget):
+ """Recent values as a thin line, newest point marked; this session's polls only."""
+
+ def __init__(self):
+ super().__init__()
+ # ponytail: in-memory, filled only while the dashboard is polled (visible)
+ self.points = collections.deque(maxlen=SPARK_POINTS)
+ self.setFixedHeight(30)
+
+ def add(self, value):
+ self.points.append(value)
+ self.update()
+
+ def paintEvent(self, _event):
+ if len(self.points) < 2:
+ return
+ p = QPainter(self)
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
+ w, h = self.width() - 4, self.height() - 4
+ step = w / (SPARK_POINTS - 1)
+ x0 = 2 + w - step * (len(self.points) - 1) # newest at the right edge
+ pts = [QPointF(x0 + i * step, 2 + h - h * min(v, 100) / 100)
+ for i, v in enumerate(self.points)]
+ pen = p.pen()
+ pen.setColor(self.palette().color(QPalette.ColorRole.PlaceholderText))
+ pen.setWidthF(2)
+ p.setPen(pen)
+ p.drawPolyline(pts)
+ p.setPen(Qt.PenStyle.NoPen)
+ p.setBrush(self.palette().color(QPalette.ColorRole.Highlight))
+ p.drawEllipse(pts[-1], 3, 3)
+
+
+class Meter(QWidget):
+ """Label and percentage, meter bar, muted detail, optional sparkline."""
+
+ def __init__(self, label, spark=False):
+ super().__init__()
+ self.value = QLabel(NA)
+ f = self.value.font()
+ f.setWeight(QFont.Weight.DemiBold)
+ self.value.setFont(f)
+ self.bar = MeterBar()
+ self.detail = muted(QLabel())
+ self.spark = Sparkline() if spark else None
+ head = QHBoxLayout()
+ head.addWidget(QLabel(label))
+ head.addStretch()
+ head.addWidget(self.value)
+ col = QVBoxLayout()
+ col.setSpacing(4)
+ col.addLayout(head)
+ col.addWidget(self.bar)
+ col.addWidget(self.detail)
+ if self.spark:
+ col.addWidget(self.spark)
+ col.addStretch()
+ col.setContentsMargins(0, 0, 0, 0)
+ self.setLayout(col)
+
+ def set(self, pct, detail):
+ self.bar.pct = pct
+ self.bar.update()
+ self.value.setText(NA if pct is None else f"{pct:.0f}%")
+ self.detail.setText(detail)
+ if self.spark and pct is not None:
+ self.spark.add(pct)
+
+
class Dashboard(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setStyleSheet("QFrame#card { background: palette(base);"
" border: 1px solid palette(mid); border-radius: 10px; }")
- self.tiles = {name: Tile(name) for name in ("Packages", "Built", "Last pull", "VM load")}
+ self.tiles = {name: Tile(name) for name in ("Packages", "Built", "Last pull", "slackpkg")}
+
+ self.vm = Card("VM")
+ self.cpu, self.ram, self.disk = Meter("CPU", spark=True), Meter("RAM", spark=True), Meter("Disk /")
+ meters = QHBoxLayout()
+ meters.setSpacing(28)
+ for m in (self.cpu, self.ram, self.disk):
+ meters.addWidget(m, 1)
+ self.vm.body.addLayout(meters)
self.results = Card("slackrepo results")
self.bar = StackedBar()
@@ -165,17 +275,18 @@ class Dashboard(QWidget):
grid.setSpacing(12)
for i, tile in enumerate(self.tiles.values()):
grid.addWidget(tile, 0, i)
- grid.addWidget(self.results, 1, 0, 1, 3)
+ grid.addWidget(self.vm, 1, 0, 1, 3)
grid.addWidget(self.orphans, 1, 3)
- grid.addWidget(self.jobs, 2, 0, 1, 4)
- grid.setRowStretch(2, 1)
+ grid.addWidget(self.results, 2, 0, 1, 4)
+ grid.addWidget(self.jobs, 3, 0, 1, 4)
+ grid.setRowStretch(3, 1)
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 12, 16, 16)
layout.addLayout(top)
layout.addLayout(grid)
def cards(self):
- return [*self.tiles.values(), self.results, self.orphans, self.jobs]
+ return [*self.tiles.values(), self.vm, self.results, self.orphans, self.jobs]
def show_stats(self, s):
p, sr, sync = s.get("packages"), s.get("slackrepo"), s.get("sync") or {}
@@ -194,9 +305,24 @@ class Dashboard(QWidget):
t["Last pull"].set(fmt_age(min(known)) if known else NA,
" · ".join(f"{o} {fmt_duration(None if ts is None else time.time() - ts)}"
for o, ts in sync.items()))
- load, disk = s.get("load"), s.get("disk_free") or {}
- t["VM load"].set(NA if load is None else f"{load[0]:.2f}",
- f"up {fmt_duration(s.get('uptime'))} · {fmt_bytes(disk.get('repo'))} free")
+ pkg = s.get("slackpkg") or {}
+ t["slackpkg"].set(fmt_age(pkg.get("upgrade")) if pkg.get("upgrade") else NA,
+ "last upgrade · lists updated " + fmt_duration(
+ None if pkg.get("update") is None else time.time() - pkg["update"]))
+ load, mem, disk = s.get("load"), s.get("mem"), s.get("disk") or {}
+ self.cpu.set(s.get("cpu"), f"load {NA if load is None else f'{load[0]:.2f}'}"
+ f" · up {fmt_duration(s.get('uptime'))}")
+ if mem:
+ used = mem["total"] - mem["available"]
+ self.ram.set(100 * used / mem["total"],
+ f"{fmt_bytes(used)} used of {fmt_bytes(mem['total'])}")
+ else:
+ self.ram.set(None, "")
+ if disk.get("total"):
+ self.disk.set(100 * (disk["total"] - disk["free"]) / disk["total"],
+ f"{fmt_bytes(disk['free'])} free of {fmt_bytes(disk['total'])}")
+ else:
+ self.disk.set(None, "")
self._show_results(sr)
def _show_results(self, sr):
diff --git a/tests/test_gui.py b/tests/test_gui.py
index b99aebf..a597c01 100644
--- a/tests/test_gui.py
+++ b/tests/test_gui.py
@@ -30,6 +30,7 @@ ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
sys.path.insert(0, ROOT)
from PyQt6.QtCore import QCoreApplication, QEvent, QObject, QProcess, pyqtSignal # noqa: E402
+from PyQt6.QtGui import QColor # noqa: E402
from PyQt6.QtWidgets import QApplication # noqa: E402
from sb_builder import VERSION, config # noqa: E402
@@ -120,8 +121,10 @@ class FakeNotifier(QObject):
self.sent.append((title, body, jid))
-STATS = {"load": [0.5, 0.4, 0.3], "uptime": 3600.0,
- "disk_free": {"repo": 10 * 2**30, "state": None},
+STATS = {"load": [0.5, 0.4, 0.3], "uptime": 3600.0, "cpu": 85.0,
+ "mem": {"total": 16 * 2**30, "available": 12 * 2**30},
+ "disk": {"total": 100 * 2**30, "free": 10 * 2**30},
+ "slackpkg": {"update": 1791760212, "upgrade": None},
"packages": {"total": 10, "personal": 2, "pentesting": 1, "shadowed": 1},
"sync": {"personal": None, "pentesting": 1792351223}, "slackrepo": None}
JOB = {"id": "20260101-000000-sbshadow", "kind": "sbshadow", "args": ["--no"], "argv": [],
@@ -249,6 +252,25 @@ class FilterTest(unittest.TestCase):
self.assertEqual(self.names("cat:misc", statuses={"ok"}), [])
+class MeterTest(unittest.TestCase):
+ def test_color_thresholds(self):
+ from sb_builder.dashboard import STATUS, meter_color
+ accent = QColor("#123456")
+ self.assertEqual(meter_color(79.9, accent), accent)
+ self.assertEqual(meter_color(80, accent), QColor(STATUS["aborted"]))
+ self.assertEqual(meter_color(95, accent), QColor(STATUS["failed"]))
+
+ def test_sparkline_cap_and_nulls(self):
+ from sb_builder.dashboard import Meter
+ m = Meter("CPU", spark=True)
+ for i in range(100):
+ m.set(float(i), "")
+ self.assertEqual(len(m.spark.points), m.spark.points.maxlen)
+ self.assertEqual(m.spark.points[-1], 99.0)
+ m.set(None, "")
+ self.assertEqual(m.value.text(), "n/a")
+
+
class SmokeTest(unittest.TestCase):
def window(self, **answers):
remote = FakeRemote({"version": ({"version": VERSION, "overlays": ["personal"]}, None),
@@ -269,7 +291,14 @@ class SmokeTest(unittest.TestCase):
self.assertEqual(tiles["Packages"].value.text(), "10")
self.assertEqual(tiles["Packages"].detail.text(), "2\u00a0personal · 1\u00a0pentesting · 1\u00a0shadowed")
self.assertEqual(tiles["Built"].value.text(), "n/a")
- self.assertIn("10.0 GiB free", tiles["VM load"].detail.text())
+ d = w.dashboard
+ self.assertEqual((d.cpu.value.text(), d.ram.value.text(), d.disk.value.text()),
+ ("85%", "25%", "90%"))
+ self.assertIn("load 0.50", d.cpu.detail.text())
+ self.assertEqual(d.disk.detail.text(), "10.0 GiB free of 100.0 GiB")
+ self.assertEqual(tiles["slackpkg"].value.text(), "n/a")
+ self.assertIn("lists updated", tiles["slackpkg"].detail.text())
+ self.assertEqual(len(d.cpu.spark.points), 1)
self.assertEqual(w.dashboard.legend.text(), "n/a")
jobs = w.open_jobs()
self.assertIs(w.open_jobs(), jobs)
diff --git a/tests/test_remote.py b/tests/test_remote.py
index c2b9857..2f273fb 100644
--- a/tests/test_remote.py
+++ b/tests/test_remote.py
@@ -391,11 +391,38 @@ class StatsTest(Base):
def test_missing_sources_are_null(self):
s = sbb.cmd_stats(self.cfg)
self.assertIsNotNone(s["load"])
+ self.assertEqual(set(s["disk"]), {"total", "free"})
+ self.assertIn("slackpkg", s)
self.assertIsNone(s["packages"])
self.assertIsNone(s["slackrepo"])
self.assertEqual(s["sync"], {"personal": None, "pentesting": None})
json.dumps(s)
+ def test_vm_figures(self):
+ meminfo = os.path.join(self.tmp, "meminfo")
+ with open(meminfo, "w") as f:
+ f.write("MemTotal: 16000 kB\nMemFree: 9000 kB\nMemAvailable: 12000 kB\n")
+ self.assertEqual(sbb._meminfo(meminfo), {"total": 16000 * 1024, "available": 12000 * 1024})
+ with mock.patch.object(sbb, "_cpu_times", side_effect=[(100, 1000), (150, 1100)]), \
+ mock.patch.object(sbb.time, "sleep"):
+ self.assertEqual(sbb._cpu(), 50.0)
+ stat = os.path.join(self.tmp, "stat")
+ with open(stat, "w") as f:
+ f.write("cpu 10 0 10 70 10 0 0 0 0 0\ncpu0 1 2 3 4\n")
+ self.assertEqual(sbb._cpu_times(stat), (20, 100)) # idle + iowait count as not busy
+
+ def test_slackpkg_times(self):
+ last = os.path.join(self.tmp, "LASTUPDATE")
+ with open(last, "w") as f:
+ f.write("1791760212\n")
+ pkgs = os.path.join(self.tmp, "packages")
+ os.mkdir(pkgs)
+ for name, t in (("a-1-x86_64-1", 1000), ("b-2-x86_64-1", 3000)):
+ open(os.path.join(pkgs, name), "w").close()
+ os.utime(os.path.join(pkgs, name), (t, t))
+ self.assertEqual(sbb._slackpkg(last, pkgs), {"update": 1791760212, "upgrade": 3000})
+ self.assertEqual(sbb._slackpkg("/nonexistent", "/nonexistent"), {"update": None, "upgrade": None})
+
def test_counts(self):
r = self.cfg["repo_dir"]
for p in ("cat/foo", "cat/bar", "personal/foo", "personal/baz", "pentesting/nmap"):