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 \
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
static_jsdir = $(staticdir)/js
EXTRA_DIST += \
+ src/static/js/builders-stats.js \
src/static/js/job-log-stream.js \
src/static/js/pbs.js
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.
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"
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,
)
# Log Stats
- self.db.execute("""
+ stats = self._get_stats("""
INSERT INTO
builder_stats
(
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,
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
"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
--- /dev/null
+$(".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);
+ });
+});
</ul>
</nav>
- <h1 class="title is-1">
- {{ builder }}
-
- {% if builder.testmode %}
- <span class="tag is-warning">{{ _("Test Mode") }}</span>
+ <section class="section">
+ <h1 class="title is-1">{{ builder }}</h1>
+ {% if builder.cpu_model %}
+ <h4 class="subtitle is-4">
+ {{ builder.cpu_model or _("Unknown CPU Model") }}
+ {% if builder.cpu_count > 1 %}
+ × {{ builder.cpu_count }}
+ {% end %}
+
+ <span class="tag">
+ {{ builder.arch }}
+ </span>
+ </h4>
{% end %}
- </h1>
- {% if builder.has_perm(current_user) %}
<div class="block">
- <a class="button is-warning is-small" href="/builders/{{ builder.hostname }}/edit">
- {{ _("Edit Builder") }}
- </a>
- </div>
- {% end %}
-
- <div class="block">
- <div class="box">
- <div class="columns">
- <div class="column is-8">
- {# XXX THIS IS ALL BORING AND UGLY BUT NOT TOO IMPORTANT RIGHT NOW #}
-
- {% if builder.cpu_model %}
- <p>
- {{ builder.cpu_model or _("Unknown CPU Model") }}
- {% if builder.cpu_count > 1 %}
- × {{ builder.cpu_count }}
- {% end %}
- </p>
- {% end %}
-
- {% if builder.pakfire_version %}
- <p>
- {{ _("Pakfire %s") % builder.pakfire_version }}
- </p>
- {% end %}
-
- {% if builder.os_name %}
- <p>
- {{ builder.os_name }}
- </p>
- {% end %}
-
- <p>
- {{ _("Supported Architectures: %s") % locale.list(builder.supported_arches) }}
- </p>
-
- {% if builder.total_build_time %}
- <p>
- {{ _("Total Build Time: %s") % format_time(builder.total_build_time) }}
- </p>
- {% end %}
-
- {% if builder.stats %}
- <p>
- {{ _("%s Memory") % format_size(builder.stats.mem_total) }}
- </p>
+ <div class="box">
+ <div class="block">
+ <nav class="level">
+ {% if builder.total_build_time %}
+ <div class="level-item has-text-centered">
+ <div>
+ <p class="heading">{{ _("Total Build Time") }}</p>
+ <p class="title">
+ {{ format_time(builder.total_build_time) }}
+ </p>
+ </div>
+ </div>
+ {% end %}
- {% if builder.stats.swap_total %}
- <p>
- {{ _("%s Swap Space") % format_size(builder.stats.swap_total) }}
- </p>
+ {% if builder.pakfire_version %}
+ <div class="level-item has-text-centered">
+ <div>
+ <p class="heading">{{ _("Pakfire Version") }}</p>
+ <p class="title">
+ {{ builder.pakfire_version }}
+ </p>
+ </div>
+ </div>
{% end %}
- {% end %}
- {% if builder.description %}
- {% module Text(builder.description) %}
- {% end %}
+ {% if builder.os_name %}
+ <div class="level-item has-text-centered">
+ <div>
+ <p class="heading">{{ _("Operating System") }}</p>
+ <p class="title">
+ {{ builder.os_name }}
+ </p>
+ </div>
+ </div>
+ {% end %}
+ </nav>
</div>
- <div class="column is-4">
- <h1>
- {{ len(builder.jobs) }}
- <small>/ {{ builder.max_jobs }}</small>
- </h1>
- <h5>{{ _("Jobs") }}</h5>
+ {# Builder Stats #}
+ <div class="block">
+ {% module BuilderStats(builder) %}
</div>
+
+
+ {% if builder.description %}
+ {% module Text(builder.description) %}
+ {% end %}
</div>
</div>
- </div>
- <!-- XXX add some realtime graph -->
+ {% if builder.has_perm(current_user) %}
+ <div class="block">
+ <a class="button is-warning" href="/builders/{{ builder.hostname }}/edit">
+ {{ _("Edit") }}
+ </a>
+ </div>
+ {% end %}
+ </section>
{% if builder.jobs %}
- <div class="block">
+ <section class="section">
<h5 class="subtitle is-5">{{ _("Running Jobs") }}</h5>
{% module JobsList(builder.jobs) %}
- </div>
+ </section>
{% end %}
- <div class="block">
+ <section class="section">
<h5 class="title is-5">{{ _("Log") }}</h5>
{% module EventsList(builder=builder, show_builder=False, limit=10) %}
- </div>
+ </section>
{% end block %}
--- /dev/null
+<div class="builders-stats" data-name="{{ builder.name }}">
+ {% if builder.is_online() %}
+ <div class="columns is-vcentered">
+ <div class="column is-2">
+ {{ _("Processor") }}
+ </div>
+
+ <div class="column">
+ <progress class="progress is-dark" id="cpu-usage" max="100"></progress>
+ </div>
+ </div>
+
+ <div class="columns is-vcentered">
+ <div class="column is-2">
+ {{ _("Memory") }}
+ </div>
+
+ <div class="column">
+ <progress class="progress is-small is-dark" id="mem-usage" max="100"></progress>
+ </div>
+
+ <div class="column is-2">
+ {{ _("Swap Usage") }}
+ </div>
+
+ <div class="column">
+ <progress class="progress is-small is-dark" id="swap-usage" max="100"></progress>
+ </div>
+ </div>
+ {% end %}
+</div>
# Builds
"BuildsList" : builds.ListModule,
+ # Builders
+ "BuilderStats" : builders.StatsModule,
+
# Distros
"DistrosList" : distributions.ListModule,
(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
import tornado.web
from . import base
+from . import ui_modules
# Setup logging
log = logging.getLogger("pbs.web.builders")
# 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)
# 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):
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",
+ )