From: Michael Tremer Date: Sun, 30 Apr 2023 16:05:19 +0000 (+0000) Subject: builders: Refactor detail page and add live statistics X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=e04bc130d37705400b3b1e47f30d9ec9eba30f73;p=pbs.git builders: Refactor detail page and add live statistics Signed-off-by: Michael Tremer --- diff --git a/Makefile.am b/Makefile.am index fcb7ea11..23593c5c 100644 --- a/Makefile.am +++ b/Makefile.am @@ -172,6 +172,11 @@ dist_templates_builders_DATA = \ templates_buildersdir = $(templatesdir)/builders +dist_templates_builders_modules_DATA = \ + src/templates/builders/modules/stats.html + +templates_builders_modulesdir = $(templates_buildersdir)/modules + dist_templates_builds_DATA = \ src/templates/builds/delete.html \ src/templates/builds/index.html \ @@ -324,6 +329,7 @@ CLEANFILES += \ src/static/css/site.css static_js_DATA = \ + src/static/js/builders-stats.min.js \ src/static/js/jquery.min.js \ src/static/js/job-log-stream.min.js \ src/static/js/pbs.min.js @@ -331,6 +337,7 @@ static_js_DATA = \ static_jsdir = $(staticdir)/js EXTRA_DIST += \ + src/static/js/builders-stats.js \ src/static/js/job-log-stream.js \ src/static/js/pbs.js diff --git a/src/buildservice/builders.py b/src/buildservice/builders.py index 1da65103..2c33f4de 100644 --- a/src/buildservice/builders.py +++ b/src/buildservice/builders.py @@ -35,6 +35,10 @@ class Builders(base.Object): return iter(builders) + def init(self): + # Initialize stats + self.stats = BuildersStats(self.backend) + def create(self, name, user=None, log=True): """ Creates a new builder. @@ -199,6 +203,41 @@ class Builders(base.Object): return { row.arch : row.t for row in res } +class BuildersStats(base.Object): + builders = {} + + def join(self, builder, connection): + try: + self.builders[builder].append(connection) + except KeyError: + self.builders[builder] = [connection] + + def leave(self, connection): + # Find and remove the connection + for builder in self.builders.copy(): + try: + self.builders[builder].remove(connection) + except IndexError: + continue + + # Remove the builder if it has no connections left + if not self.builders[builder]: + del self.builders[builder] + + async def submit_stats(self, builder, stats): + """ + Called when a builder sends new stats + """ + try: + connections = self.builders[builder] + except KeyError: + return + + # Send the stats to all connections + await asyncio.gather( + *(c.submit_stats(stats) for c in connections), + ) + class Builder(base.DataObject): table = "builders" @@ -240,7 +279,15 @@ class Builder(base.DataObject): return True - def log_stats(self, cpu_model=None, cpu_count=None, cpu_arch=None, pakfire_version=None, + # Stats + + def _get_stats(self, query, *args, **kwargs): + res = self.db.get(query, *args, **kwargs) + + if res: + return BuilderStats(self.backend, self, res) + + async def log_stats(self, cpu_model=None, cpu_count=None, cpu_arch=None, pakfire_version=None, os_name=None, cpu_user=None, cpu_nice=None, cpu_system=None, cpu_idle=None, cpu_iowait=None, cpu_irq=None, cpu_softirq=None, cpu_steal=None, cpu_guest=None, cpu_guest_nice=None, loadavg1=None, loadavg5=None, loadavg15=None, mem_total=None, @@ -272,7 +319,7 @@ class Builder(base.DataObject): ) # Log Stats - self.db.execute(""" + stats = self._get_stats(""" INSERT INTO builder_stats ( @@ -306,7 +353,8 @@ class Builder(base.DataObject): VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s - )""", + ) + RETURNING *""", self.id, cpu_user, cpu_nice, @@ -335,12 +383,15 @@ class Builder(base.DataObject): swap_free, ) + # Send out the stats + await self.backend.builders.stats.submit_stats(self, stats) + @lazy_property def stats(self): """ Returns the latest stats data (if any) """ - return self.db.get(""" + return self._get_stats(""" SELECT * FROM @@ -713,3 +764,30 @@ class Builder(base.DataObject): "pkg" : job.pkg.download_url, }, }) + + +class BuilderStats(base.Object): + def init(self, builder, data): + self.builder = builder + self.data = data + + @property + def cpu_usage(self): + """ + Returns the CPU usage in percent + """ + return 100 - self.data.cpu_idle + + @property + def mem_usage(self): + """ + Returns the amount of used memory in percent + """ + return (self.data.mem_total - self.data.mem_available) / self.data.mem_total * 100 + + @property + def swap_usage(self): + """ + Returns the amount of used swap in percent + """ + return self.data.swap_used / self.data.swap_total * 100 diff --git a/src/static/js/builders-stats.js b/src/static/js/builders-stats.js new file mode 100644 index 00000000..81d5efca --- /dev/null +++ b/src/static/js/builders-stats.js @@ -0,0 +1,52 @@ +$(".builders-stats").each(function() { + // Fetch the name of the builder + const name = $(this).data("name"); + + const cpu_usage = $(this).find("#cpu-usage"); + const mem_usage = $(this).find("#mem-usage"); + const swap_usage = $(this).find("#swap-usage"); + + // Make the URL + const url = "wss://" + window.location.host + "/builders/" + name + "/stats"; + + // Try to connect to the stream + const stream = new WebSocket(url); + + // Updates the progressbar and sets a colour + updateProgressBar = function(e, percentage) { + // Set the value + e.val(percentage); + + // Remove all classes + e.removeClass("is-dark is-light is-danger is-warning is-success"); + + if (percentage >= 90) { + e.addClass("is-danger"); + + } else if (percentage >= 75) { + e.addClass("is-warning"); + + } else { + e.addClass("is-success"); + } + } + + stream.addEventListener("message", (event) => { + // Parse message as JSON + const data = JSON.parse(event.data); + + console.debug("Message from server: ", data); + + // Set CPU usage + if (cpu_usage) + updateProgressBar(cpu_usage, data.cpu_usage); + + // Set memory usage + if (mem_usage) + updateProgressBar(mem_usage, data.mem_usage); + + // Set swap usage + if (swap_usage) + updateProgressBar(swap_usage, data.swap_usage); + }); +}); diff --git a/src/templates/builders/detail.html b/src/templates/builders/detail.html index 3cdfbe45..d1d88bbd 100644 --- a/src/templates/builders/detail.html +++ b/src/templates/builders/detail.html @@ -14,100 +14,92 @@ -

- {{ builder }} - - {% if builder.testmode %} - {{ _("Test Mode") }} +
+

{{ builder }}

+ {% if builder.cpu_model %} +

+ {{ builder.cpu_model or _("Unknown CPU Model") }} + {% if builder.cpu_count > 1 %} + × {{ builder.cpu_count }} + {% end %} + + + {{ builder.arch }} + +

{% end %} -

- {% if builder.has_perm(current_user) %} - {% end %} - -
-
-
-
- {# XXX THIS IS ALL BORING AND UGLY BUT NOT TOO IMPORTANT RIGHT NOW #} - - {% if builder.cpu_model %} -

- {{ builder.cpu_model or _("Unknown CPU Model") }} - {% if builder.cpu_count > 1 %} - × {{ builder.cpu_count }} - {% end %} -

- {% end %} - - {% if builder.pakfire_version %} -

- {{ _("Pakfire %s") % builder.pakfire_version }} -

- {% end %} - - {% if builder.os_name %} -

- {{ builder.os_name }} -

- {% end %} - -

- {{ _("Supported Architectures: %s") % locale.list(builder.supported_arches) }} -

- - {% if builder.total_build_time %} -

- {{ _("Total Build Time: %s") % format_time(builder.total_build_time) }} -

- {% end %} - - {% if builder.stats %} -

- {{ _("%s Memory") % format_size(builder.stats.mem_total) }} -

+
+
+
-
-

- {{ len(builder.jobs) }} - / {{ builder.max_jobs }} -

-
{{ _("Jobs") }}
+ {# Builder Stats #} +
+ {% module BuilderStats(builder) %}
+ + + {% if builder.description %} + {% module Text(builder.description) %} + {% end %}
-
- + {% if builder.has_perm(current_user) %} + + {% end %} + {% if builder.jobs %} -
+
{{ _("Running Jobs") }}
{% module JobsList(builder.jobs) %} -
+ {% end %} -
+
{{ _("Log") }}
{% module EventsList(builder=builder, show_builder=False, limit=10) %} -
+ {% end block %} diff --git a/src/templates/builders/modules/stats.html b/src/templates/builders/modules/stats.html new file mode 100644 index 00000000..689478aa --- /dev/null +++ b/src/templates/builders/modules/stats.html @@ -0,0 +1,31 @@ +
+ {% if builder.is_online() %} +
+
+ {{ _("Processor") }} +
+ +
+ +
+
+ +
+
+ {{ _("Memory") }} +
+ +
+ +
+ +
+ {{ _("Swap Usage") }} +
+ +
+ +
+
+ {% end %} +
diff --git a/src/web/__init__.py b/src/web/__init__.py index 795c24ea..efa04c76 100644 --- a/src/web/__init__.py +++ b/src/web/__init__.py @@ -46,6 +46,9 @@ class Application(tornado.web.Application): # Builds "BuildsList" : builds.ListModule, + # Builders + "BuilderStats" : builders.StatsModule, + # Distros "DistrosList" : distributions.ListModule, @@ -151,6 +154,7 @@ class Application(tornado.web.Application): (r"/builders/([A-Za-z0-9\-\.]+)/delete", builders.BuilderDeleteHandler), (r"/builders/([A-Za-z0-9\-\.]+)/edit", builders.BuilderEditHandler), (r"/builders/([A-Za-z0-9\-\.]+)", builders.BuilderDetailHandler), + (r"/builders/([A-Za-z0-9\-\.]+)/stats", builders.StatsHandler), (r"/api/v1/builders/control", builders.APIv1ControlHandler), # Distributions diff --git a/src/web/builders.py b/src/web/builders.py index 2d27e62a..11f6b55e 100644 --- a/src/web/builders.py +++ b/src/web/builders.py @@ -4,6 +4,7 @@ import logging import tornado.web from . import base +from . import ui_modules # Setup logging log = logging.getLogger("pbs.web.builders") @@ -24,7 +25,7 @@ class APIv1ControlHandler(base.APIMixin, tornado.websocket.WebSocketHandler): # Drop the connection to the builder self.current_user.disconnected() - def on_message(self, message): + async def on_message(self, message): # Decode message message = self._decode_json_message(message) @@ -34,18 +35,44 @@ class APIv1ControlHandler(base.APIMixin, tornado.websocket.WebSocketHandler): # Handle stats if type == "stats": - self._handle_stats(data) + await self._handle_stats(data) # Log an error and ignore any other messages else: log.error("Received message of type '%s' which we cannot handle here" % type) - def _handle_stats(self, data): + async def _handle_stats(self, data): """ Handles stats messages """ with self.db.transaction(): - self.builder.log_stats(**data) + await self.builder.log_stats(**data) + + +class StatsHandler(base.BackendMixin, tornado.websocket.WebSocketHandler): + # No authentication required + async def open(self, name): + builder = self.backend.builders.get_by_name(name) + if not builder: + raise tornado.web.HTTPError(404, "Could not find builder %s" % name) + + # Register to receive updates + self.backend.builders.stats.join(builder=builder, connection=self) + + # Initially send the stats that we currently have + if builder.stats: + await self.submit_stats(builder.stats) + + def on_close(self): + self.backend.builders.stats.leave(self) + + async def submit_stats(self, stats): + await self.write_message({ + "cpu_usage" : stats.cpu_usage, + "mem_usage" : stats.mem_usage, + "swap_usage" : stats.swap_usage, + }) + class BuilderListHandler(base.BaseHandler): @@ -131,3 +158,13 @@ class BuilderDeleteHandler(base.BaseHandler): return self.render("builders/delete.html", builder=builder) + + +class StatsModule(ui_modules.UIModule): + def render(self, builder): + return self.render_string("builders/modules/stats.html", builder=builder) + + def javascript_files(self): + return ( + "js/builders-stats.min.js", + )