diff options
| -rwxr-xr-x | remote/sbb-remote | 44 | ||||
| -rw-r--r-- | sb_builder/__init__.py | 2 | ||||
| -rw-r--r-- | sb_builder/dashboard.py | 146 | ||||
| -rw-r--r-- | tests/test_gui.py | 35 | ||||
| -rw-r--r-- | tests/test_remote.py | 27 |
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"): |
