guix-cuirass/src/cuirass/templates.scm

1999 lines
81 KiB
Scheme

;;; templates.scm -- HTTP API
;;; Copyright © 2018 Tatiana Sholokhova <tanja201396@gmail.com>
;;; Copyright © 2018, 2019, 2020 Ludovic Courtès <ludo@gnu.org>
;;; Copyright © 2019, 2020 Ricardo Wurmus <rekado@elephly.net>
;;; Copyright © 2020 Mathieu Othacehe <othacehe@gnu.org>
;;;
;;; This file is part of Cuirass.
;;;
;;; Cuirass is free software: you can redistribute it and/or modify
;;; it under the terms of the GNU General Public License as published by
;;; the Free Software Foundation, either version 3 of the License, or
;;; (at your option) any later version.
;;;
;;; Cuirass is distributed in the hope that it will be useful,
;;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;;; GNU General Public License for more details.
;;;
;;; You should have received a copy of the GNU General Public License
;;; along with Cuirass. If not, see <http://www.gnu.org/licenses/>.
(define-module (cuirass templates)
#:use-module (ice-9 format)
#:use-module (ice-9 match)
#:use-module (srfi srfi-1)
#:use-module (srfi srfi-2)
#:use-module (srfi srfi-19)
#:use-module (srfi srfi-26)
#:use-module (json)
#:use-module (web uri)
#:use-module (guix channels)
#:use-module (guix derivations)
#:use-module (guix packages)
#:use-module (guix progress)
#:use-module (guix store)
#:use-module ((guix utils) #:select (string-replace-substring
version>?))
#:use-module (cuirass config)
#:use-module ((cuirass database) #:select (build-status
build-weather
evaluation-status))
#:use-module (cuirass remote)
#:use-module (cuirass specification)
#:export (html-page
specifications-table
specification-edit
evaluation-info-table
build-eval-table
build-search-results-table
build-details
evaluation-build-table
running-builds-table
global-metrics-content
workers-status
machine-status
evaluation-dashboard))
(define (navigation-items navigation)
(match navigation
(() '())
((item . rest)
(cons `(li (@ (class "nav-item"))
(a (@ (class "nav-link" ,(if (null? rest) " active" ""))
(href ,(assq-ref item #:link)))
,(assq-ref item #:name)))
(navigation-items rest)))))
(define (search-form query)
`(form (@ (id "search")
(class "form-inline")
(action "/search"))
(div
(@ (class "input-group"))
(input (@ (type "text")
(class "form-control")
(id "query")
(name "query")
,(if query
`(value ,query)
'(placeholder "search for builds"))))
(span (@ (class "input-group-append"))
(button
(@ (type "submit")
(class "btn btn-primary"))
"Search")))
(div
(@ (id "search-hints"))
(p "You can limit the search results with the following keywords:")
(ul
(li (code "spec")
", a " (em "specification") " such as " (code "guix-master"))
(li (code "system")
", a build for the given " (em "target system")
" such as " (code "x86_64-linux"))
(li (code "status")
", to limit the results to builds with the given status. "
"This should be one of "
(code "success") ", "
(code "failed") ", "
(code "failed-dependency") ", "
(code "failed-other") ", or "
(code "canceled") "."))
(p "For example, the following query will list successful builds of
the " (code "guix-master") " specification for the " (code "i686-linux") "
system whose names start with " (code "guile-") ":" (br)
(code "spec:guix-master system:i686-linux status:success guile-")))))
(define* (html-page title body navigation
#:optional query
#:key (margin? #t))
"Return HTML page with given TITLE and BODY."
`(html (@ (xmlns "http://www.w3.org/1999/xhtml")
(xml:lang "en")
(lang "en"))
(head
(meta (@ (charset "utf-8")))
(meta (@ (name "viewport")
(content ,(string-join '("width=device-width"
"initial-scale=1"
"shrink-to-fit=no")
", "))))
(link (@ (rel "stylesheet")
(href "/static/css/bootstrap.css")))
(link (@ (rel "stylesheet")
(href "/static/css/datatables.min.css")))
(link (@ (rel "stylesheet")
(href "/static/css/open-iconic-bootstrap.css")))
(link (@ (rel "stylesheet")
(href "/static/css/choices.min.css")))
(link (@ (rel "stylesheet")
(href "/static/css/cuirass.css")))
(link (@ (rel "icon") (type "image/png")
(href "/static/images/icon.png")))
(script (@ (src "/static/js/jquery-3.6.0.min.js")))
(script (@ (src "/static/js/datatables.min.js")))
(script (@ (src "/static/js/d3.v6.min.js")))
(script "
$(document).ready(function() {
var default_opts = {
paging: false,
searching: false,
info: false,
order: [],
};
var spec_table = $('#spec-table');
if (spec_table.find('th').length > 1) {
spec_table.DataTable({
...default_opts,
/* Do not sort the 'Action' column. */
columnDefs: [
{ orderable: false, targets: 5 },
{ orderable: false, targets: 6 }
],
});
}
var eval_table = $('#eval-table');
if (eval_table.find('th').length > 1) {
eval_table.DataTable({
...default_opts,
columnDefs: [
{ orderable: false, targets: 0 },
{ orderable: false, targets: 1 },
{ orderable: false, targets: 8 }
],
});
}
});")
(title ,title))
(body
(nav (@ (class "navbar navbar-expand-lg navbar-light bg-light"))
(a (@ (class "navbar-brand pt-0")
(href "/"))
(img (@ (src "/static/images/guix.png")
(alt "logo")
(height "25")
(style "margin-top: -12px"))))
(div (@ (class "collapse navbar-collapse"))
(ul (@ (class "navbar-nav mr-auto"))
(li (@ (class "nav-item dropdown"))
(a (@ (class "nav-link dropdown-toggle")
(data-toggle "dropdown")
(href "#")
(role "button")
(aria-haspopup "true")
(aria-expanded "false"))
"Status")
(div (@ (class "dropdown-menu")
(aria-labelledby "navbarDropdow"))
(a (@ (class "dropdown-item")
(href "/metrics"))
"Global metrics")
(a (@ (class "dropdown-item")
(href "/workers"))
"Workers status")
(a (@ (class "dropdown-item")
(href "/status"))
"Running builds")))
(li (@ (class "nav-item"))
(a (@ (class "nav-link" ,(if (null? navigation)
" active" ""))
(href "/"))
Home))
,@(navigation-items navigation)))
,(search-form query))
(div (@ (id "content")
(class ,(if margin?
"container content"
"content-fixed-margin")))
,body)
(footer
(@ (class "footer text-center"))
(p (@ (class "mt-3"))
(a (@ (href "http://guix.gnu.org/cuirass/"))
,(string-append "Cuirass " %package-version))
" — Copyright © 2016 - 2021 by the GNU Guix community.")))))
(define (status-class status)
(cond
((= (build-status submitted) status)
"oi oi-clock text-warning")
((= (build-status scheduled) status)
"oi oi-clock text-warning")
((= (build-status started) status)
"oi oi-reload text-warning")
((= (build-status succeeded) status)
"oi oi-check text-success")
((= (build-status failed) status)
"oi oi-x text-danger")
((= (build-status failed-dependency) status)
"oi oi-warning text-danger")
((= (build-status failed-other) status)
"oi oi-warning text-danger")
((= (build-status canceled) status)
"oi oi-question-mark text-warning")
(else
"oi oi-warning text-danger")))
(define (status-title status)
(cond
((= (build-status submitted) status)
"Submitted")
((= (build-status scheduled) status)
"Scheduled")
((= (build-status started) status)
"Started")
((= (build-status succeeded) status)
"Succeeded")
((= (build-status failed) status)
"Failed")
((= (build-status failed-dependency) status)
"Failed (dependency)")
((= (build-status failed-other) status)
"Failed (other)")
((= (build-status canceled) status)
"Canceled")
(else
"Invalid status")))
(define (specifications-table specs evaluations summaries)
(define (spec->latest-eval name)
(find (lambda (s)
(string=? (assq-ref s #:specification) name))
evaluations))
(define (eval-summary eval)
(find (lambda (s)
(eq? (assq-ref s #:evaluation)
(assq-ref eval #:evaluation)))
summaries))
(define (summary->percentage summary)
(let ((total (assq-ref summary #:total))
(succeeded (assq-ref summary #:succeeded)))
(nearest-exact-integer (* 100 (/ succeeded total)))))
"Return HTML for the SPECS table."
`((p (@ (class "lead")) "Specifications"
(button (@ (class "btn btn-outline-primary float-right job-toggle")
(type "button"))
(span (@ (class "oi oi-contrast d-inline-block")
(title "Toggle jobs"))))
(a (@ (href "/events/rss/"))
(button (@ (class "btn btn-outline-warning mr-1 float-right")
(type "button"))
(span (@ (class "oi oi-rss text-warning align-right")
(title "RSS")
(aria-hidden "true"))
"")))
(a (@ (class "btn btn-outline-primary mr-1 float-right")
(href "/specification/add/")
(role "button"))
(span (@(class "oi oi-plus text-primary align-right")
(title "Add")
(aria-hidden "true"))
"")))
(script "
$(document).ready(function() {
$('.job-toggle').click(function() {
$('.job-per').toggle();
$('.job-val').toggle();
})});")
(table
(@ (id "spec-table")
(class "table table-sm table-hover"))
,@(if (null? specs)
`((th (@ (scope "col")) "No elements here."))
`((thead (tr (th (@ (scope "col")) Name)
(th (@ (scope "col")) Build)
(th (@ (scope "col")) Channels)
(th (@ (scope "col")) Priority)
(th (@ (scope "col")) Systems)
(th (@ (scope "col")) Jobs)
(th (@ (scope "col")) Action)))
(tbody
,@(map
(lambda (spec)
`(tr
(td (a (@ (href "/jobset/"
,(specification-name spec)))
,(specification-name spec)))
(td ,(match (specification-build spec)
((? symbol? build)
(symbol->string build))
((build _ ...)
(symbol->string build))))
(td ,(string-join
(map (lambda (channel)
(format #f "~a (on ~a)"
(channel-name channel)
(channel-branch channel)))
(specification-channels spec)) ", "))
(td ,(number->string
(specification-priority spec)))
(td
,(let* ((systems (specification-systems spec))
(systems*
(string-join
(sort systems string<?)
", "))
(tooltip?
(> (length systems) 1)))
`(span
(@ ,@(if tooltip?
`((data-toggle "tooltip")
(title ,systems*))
'()))
,(if tooltip?
(string-append (car systems) ", ...")
systems))))
(td
(@
(style "vertical-align: middle"))
,@(let* ((summary
(eval-summary
(spec->latest-eval
(specification-name spec))))
(percentage
(summary->percentage summary))
(style
(format #f "width: ~a%" percentage)))
(if summary
`((div
(@ (class "progress job-per")
(title "Percentage succeeded"))
(div (@ (class "progress-bar")
(role "progressbar")
(style ,style)
(aria-valuemin "0")
(aria-valuemax "100"))
(strong
(span
(@ (class "text-dark"))
,percentage
"%"))))
" "
(div
(@ (class "job-val"))
(div
(@ (class "badge badge-success")
(title "Succeeded"))
,(assq-ref summary #:succeeded))
(div
(@ (class "badge badge-danger")
(title "Failed"))
,(assq-ref summary #:failed))
(div
(@ (class "badge badge-secondary")
(title "Scheduled"))
,(assq-ref summary #:scheduled))))
'())))
(td
,@(let ((eval (and=> (spec->latest-eval
(specification-name spec))
(cut assq-ref <> #:evaluation))))
(if eval
`((a (@ (href "/eval/" ,eval
"/dashboard"))
(div
(@ (class "oi oi-monitor d-inline-block ml-2")
(title "Dashboard")
(aria-hidden "true"))
"")))
'()))
(div
(@ (class "dropdown d-inline-block ml-2"))
(a (@ (class "oi oi-menu dropdown-toggle no-dropdown-arrow")
(href "#")
(data-toggle "dropdown")
(role "button")
(aria-haspopup "true")
(aria-expanded "false"))
" ")
(div (@ (class "dropdown-menu"))
(a (@ (class "dropdown-item")
(href "/specification/edit/"
,(specification-name spec)))
" Edit")
(a (@ (class "dropdown-item")
(href "/admin/specifications/delete/"
,(specification-name spec)))
" Delete"))))))
specs)))))))
(define* (specification-edit #:optional spec)
"Return HTML to add a new specification if no argument is passed, or to edit
the existing SPEC otherwise."
(define (channels->html channels)
(let ((html
(fold
(lambda (channel html)
(let ((first-row? (null? html))
(name (channel-name channel))
(url (channel-url channel))
(branch (channel-branch channel)))
(cons
`(div (@ (class ,(if first-row?
"form-group row channel"
"form-group row channel-new")))
(label (@ (for "name")
(class "col-sm-2 col-form-label"))
,(if first-row? "Channels" ""))
(div (@ (class "col-sm-2"))
(input
(@ (type "text")
(class "form-control channel-name")
(name "channel-name")
(placeholder "name")
(value ,name)
(required))))
(div (@ (class "col-sm-4"))
(input
(@ (type "text")
(class "form-control channel-url")
(name "channel-url")
(placeholder "url")
(value ,url)
(required))))
(div (@ (class "col-sm-2"))
(input
(@ (type "text")
(class "form-control channel-branch")
(name "channel-branch")
(placeholder "branch")
(value ,branch)
(required))))
,@(if first-row?
'((a (@ (class "btn btn-success add-channel")
(href "#")
(role "button"))
"Add"))
'((a (@ (class "btn btn-danger remove-channel")
(href "#")
(role "button"))
"Remove"))))
html)))
'()
channels)))
(match (reverse html)
((first . rest)
(list first `(div (@ (class "channels")) ,@rest))))))
(let ((name (and spec (specification-name spec)))
(build (and spec (match (specification-build spec)
((? symbol? build) build)
((build _ ...) build))))
(channels (and spec (specification-channels spec)))
(period (and spec (specification-period spec)))
(priority (and spec (specification-priority spec)))
(systems (and spec (specification-systems spec))))
`(span
(p (@ (class "lead"))
,(if spec
(format #f "Edit ~a specification" name)
"Create a new specification"))
(script (@ (src "/static/js/choices.min.js")))
(script "
$(document).ready(function() {
$('.remove-channel').click(function() {
$(this).parent().remove();
});
$('.add-channel').click(function() {
var clone = $('.channel').clone();
clone.attr('class', 'form-group row channel-new');
clone.find('.col-form-label').text('');
var new_button = clone.find('.add-channel');
new_button.attr('class', 'btn btn-danger remove-channel');
new_button.text('Remove');
new_button.click(function() {
$(this).parent().remove();
});
clone.appendTo('.channels');
});
var cbs = $('.system');
cbs.change(function(){
if(cbs.is(':checked')) {
cbs.removeAttr('required');
} else {
cbs.attr('required', 'required');
}
});
var checked_cbs = $('.system:checkbox:checked').length;
if (checked_cbs == 0) {
cbs.attr('required', 'required');
}
(function () {
'use strict'
var forms = document.querySelectorAll('.needs-validation')
Array.prototype.slice.call(forms)
.forEach(function (form) {
form.addEventListener('submit', function (event) {
if (!form.checkValidity()) {
event.preventDefault()
event.stopPropagation()
}
form.classList.add('was-validated')
}, false)
})
})();
const select_choices = new Choices($('.build-param-select')[0], {
removeItemButton: true,
duplicateItemsAllowed: false,
});
const input_choices = new Choices($('.build-param-input')[0], {
removeItemButton: true,
duplicateItemsAllowed: false,
});
$('.build-param-select').on('showDropdown', function() {
var names = $('.channel-name').map(function() {
var name = $(this).val();
return { 'value': name, 'label': name};
}).toArray();
select_choices.setChoices(names, 'value', 'label', true);
});
var param_select = $('.build-select');
var param_select_cb = function(){
var val = param_select.val();
if (['packages', 'manifests'].indexOf(val) >= 0) {
input_choices.clearStore();
$('.param-input-row').show();
} else {
$('.param-input-row').hide();
}
if (['channels'].indexOf(val) >= 0) {
$('.param-select-row').show();
} else {
$('.param-select-row').hide();
}
};
param_select_cb();
param_select.change(param_select_cb);
const default_param = $('.default-build-param');
if (default_param.length) {
var items = default_param.text().split(',').map(function(name) {
return { 'value': name, 'label': name, selected: true};
});
if ($('.param-select-row').is(':visible')) {
select_choices.setChoices(items, 'value', 'label', true);
} else if ($('.param-input-row').is(':visible')) {
input_choices.setValue(items);
}}
});")
(form (@ (id "add-specification")
(class "needs-validation")
,@(if spec
'((action "/admin/specification/edit"))
'((action "/admin/specification/add")))
(method "POST"))
(div (@ (class "form-group row"))
(label (@ (for "name")
(class "col-sm-2 col-form-label"))
"Name")
(div (@ (class "col-sm-4"))
(input (@ (type "text")
(class "form-control")
(id "name")
(name "name")
(value ,(or name ""))
,@(if spec
'((readonly))
'())
(required)))))
(div (@ (class "form-group row"))
(label (@ (for "build")
(class "col-sm-2 col-form-label"))
"Build")
(div (@ (class "col-sm-4"))
(select
(@ (class "form-control build-select")
(id "build")
(name "build"))
,@(map (lambda (type)
`(option (@ ,@(if (eq? type build)
'((selected))
'()))
,(symbol->string type)))
%build-types))))
,(if spec
(match (specification-build spec)
((build . rest)
`((span (@ (class "default-build-param")
(hidden "true"))
,(string-join
(map (lambda (param)
(if (symbol? param)
(symbol->string param)
param))
rest)
","))))
(else ""))
'())
(div (@ (class "form-group row param-select-row"))
(label (@(class "col-sm-2 col-form-label"))
"Parameter")
(div (@ (class "col-sm-4"))
(select (@ (type "text")
(class "form-control build-param-select")
(name "param-select")
(multiple)))))
(div (@ (class "form-group row param-input-row"))
(label (@(class "col-sm-2 col-form-label"))
"Parameter")
(div (@ (class "col-sm-4"))
(input (@ (type "text")
(name "param-input")
(class "form-control build-param-input")))))
,@(channels->html
(if spec channels (list %default-guix-channel)))
(div (@ (class "form-group row"))
(label (@ (for "period")
(class "col-sm-2 col-form-label"))
"Period")
(div (@ (class "col-sm-4"))
(input
(@ (type "number")
(class "form-control")
(id "period")
(name "period")
(min 0)
(value ,(or period 0))))))
(div (@ (class "form-group row"))
(label (@ (for "priority")
(class "col-sm-2 col-form-label"))
"Priority")
(div (@ (class "col-sm-4"))
(input
(@ (type "number")
(class "form-control")
(id "priority")
(name "priority")
(min 0)
(max 9)
(value ,(or priority 9))))))
(div (@ (class "form-group row"))
(label (@ (for "systems")
(class "col-sm-2 col-form-label"))
"Systems")
,@(map (lambda (system)
`(div (@ (class "form-check form-check-inline"))
(input (@ (class "form-check-input system")
(type "checkbox")
(id ,system)
(name ,system)
,@(if (and systems
(member system systems))
'((checked))
'())))
(label (@ (class "form-check-label")
(for ,system))
,system)))
%cuirass-supported-systems))
(div (@ (class "form-group row"))
(div (@ (class "col-sm-4"))
(button
(@ (type "submit")
(class "btn btn-primary"))
" Submit")))))))
(define (build-details build products history)
"Return HTML showing details for the BUILD."
(define status (assq-ref build #:status))
(define weather (assq-ref build #:weather))
(define blocking-outputs
(or (and-let* (((= (build-status failed-dependency) status))
(drv (false-if-exception
(read-derivation-from-file
(assq-ref build #:derivation)))))
(append-map (lambda (drv)
(match (derivation->output-paths drv)
(((_ . items) ...)
items)))
(filter (compose derivation-log-file
derivation-file-name)
(with-store store
(derivation-build-plan
store (list (derivation-input drv))
#:substitutable-info (const #f))))))
'()))
(define completed?
(or (= (build-status succeeded) status)
(= (build-status failed) status)))
(define evaluation
(assq-ref build #:eval-id))
(define (history-table-row build)
(define status
(assq-ref build #:status))
`(tr
(td (span (@ (class ,(status-class status))
(title ,(status-title status))
(aria-hidden "true"))
""))
(th (@ (scope "row"))
(a (@ (href "/build/" ,(assq-ref build #:id) "/details"))
,(assq-ref build #:id)))
(td ,(assq-ref build #:nix-name))
(td ,(time->string (assq-ref build #:stoptime)))))
`((p (@ (class "lead")) "Build details"
(div (@ (class "dropdown float-right"))
(a (@ (class "btn btn-warning dropdown-toggle")
(href "#")
(data-toggle "dropdown")
(role "button")
(aria-haspopup "true")
(aria-expanded "false"))
"Action")
(div (@ (class "dropdown-menu"))
(a (@ (class "dropdown-item")
(href "/admin/build/"
,(assq-ref build #:id) "/restart"))
" Restart"))))
(table
(@ (class "table table-sm table-hover"))
(tbody
(tr (th "Build ID")
(td ,(assq-ref build #:id)))
(tr (th "Evaluation")
(td (a (@ (href ,(string-append "/eval/"
(number->string evaluation))))
,(number->string evaluation))))
(tr (th "Status")
(td (span (@ (class ,(status-class status))
(title ,(status-title status)))
,(string-append " " (status-title status)))
,@(map (lambda (output)
`((br)
(a (@ (href ,(string-append "/log/" (basename output))))
,output)))
blocking-outputs)))
(tr (th "System")
(td ,(assq-ref build #:system)))
(tr (th "Name")
(td ,(assq-ref build #:nix-name)))
(tr (th "Duration")
(td ,(let ((timestamp (time-second (current-time time-utc)))
(start (assq-ref build #:starttime))
(stop (assq-ref build #:stoptime)))
(cond
((and (> start 0) (> stop 0))
(string-append (number->string (- stop start))
" seconds"))
((> start 0)
(string-append (number->string (- timestamp start))
" seconds"))
(else "—")))))
(tr (th "Finished")
(td ,(if completed?
(time->string (assq-ref build #:stoptime))
"—")))
(tr (th "Weather")
(td (span (@ (class ,(weather-class weather))
(title ,(weather-title weather))
(aria-hidden "true"))
"")))
(tr (th "Log file")
(td (a (@ (href "/build/" ,(assq-ref build #:id) "/log/raw"))
"raw")))
(tr (th "Derivation")
(td (pre ,(assq-ref build #:derivation))))
(tr (th "Outputs")
(td ,(map (match-lambda ((out (#:path . path))
`(pre ,path)))
(assq-ref build #:outputs))))
,@(if (null? products)
'()
(let ((product-items
(map
(lambda (product)
(let* ((id (assq-ref product #:id))
(size (assq-ref product #:file-size))
(type (assq-ref product #:type))
(path (assq-ref product #:path))
(href (format #f "/download/~a" id)))
`(a (@ (href ,href))
(li (@ (class "list-group-item"))
(div
(@ (class "container"))
(div
(@ (class "row"))
(div
(@ (class "col-md-auto"))
(span
(@ (class "oi oi-data-transfer-download")
(title "Download")
(aria-hidden "true"))))
(div (@ (class "col-md-auto"))
,path)
(div (@ (class "col-md-auto"))
"(" ,type ")")
(div (@ (class "col-md-auto"))
,(byte-count->string size))))))))
products)))
`((tr (th "Build outputs")
(td
(ul (@ (class "list-group d-flex flex-row"))
,product-items))))))))
,@(if (null? history)
'()
`((h6 "Build history")
(table
(@ (class "table table-sm table-hover table-striped"))
(thead
(tr
(th (@ (scope "col") (class "border-0")) ())
(th (@ (scope "col") (class "border-0")) "ID")
(th (@ (scope "col") (class "border-0")) "Name")
(th (@ (scope "col") (class "border-0")) "Completion time")))
(tbody ,@(map history-table-row history)))))))
(define (pagination first-link prev-link next-link last-link)
"Return html page navigation buttons with LINKS."
`(div (@ (class row))
(nav
(@ (class "mx-auto") (aria-label "Page navigation"))
(ul (@ (class "pagination"))
(li (@ (class "page-item"
,(if (string-null? prev-link) " disabled")))
(a (@ (class "page-link")
(href ,first-link))
"<< First"))
(li (@ (class "page-item"
,(if (string-null? prev-link) " disabled")))
(a (@ (class "page-link")
(href ,prev-link))
"< Previous"))
(li (@ (class "page-item"
,(if (string-null? next-link) " disabled")))
(a (@ (class "page-link")
(href ,next-link))
"Next >"))
(li (@ (class "page-item"
,(if (string-null? next-link) " disabled")))
(a (@ (class "page-link")
(href ,last-link))
"Last >>"))))))
(define (input-changes checkouts)
(let ((changes
(string-join
(map (lambda (checkout)
(let ((input (assq-ref checkout #:channel))
(commit (assq-ref checkout #:commit)))
(format #f "~a → ~a" input (substring commit 0 7))))
checkouts)
", ")))
(if (string=? changes "") '(em "None") changes)))
(define (evaluation-badges evaluation absolute)
(let ((status (assq-ref evaluation #:status)))
(if (= status (evaluation-status started))
'((em "In progress…"))
(cond
((= status (evaluation-status failed))
`((a (@ (href "/eval/" ,(assq-ref evaluation #:id) "/log/raw")
(class "oi oi-x text-danger")
(title "Failed")
(aria-hidden "true"))
"")))
((= status (evaluation-status aborted))
`((a (@ (href "/eval/" ,(assq-ref evaluation #:id) "/log/raw")
(class "oi oi-x text-warning")
(title "Aborted")
(aria-hidden "true"))
"")))
((= status (evaluation-status succeeded))
`((div
(@ (class "job-abs"))
(div (@ (class "badge badge-success")
(title "Succeeded"))
,(assq-ref absolute #:succeeded))
(div (@ (class "badge badge-danger")
(title "Failed"))
,(assq-ref absolute #:failed))
(div (@ (class "badge badge-secondary")
(title "Scheduled"))
,(assq-ref absolute #:scheduled)))
(div
(@ (class "job-rel"))
(a (@ (href "/eval/" ,(assq-ref evaluation #:id)
"?status=succeeded")
(class "badge badge-success")
(title "Succeeded"))
,(assq-ref evaluation #:succeeded))
(a (@ (href "/eval/" ,(assq-ref evaluation #:id)
"?status=failed")
(class "badge badge-danger")
(title "Failed"))
,(assq-ref evaluation #:failed))
(a (@ (href "/eval/" ,(assq-ref evaluation #:id)
"?status=pending")
(class "badge badge-secondary")
(title "Scheduled"))
,(assq-ref evaluation #:scheduled)))))))))
(define* (evaluation-info-table name evaluations id-min id-max
#:key absolute-summary)
"Return HTML for the EVALUATION table NAME. ID-MIN and ID-MAX are
global minimal and maximal id."
(define (eval-absolute-summary eval)
(find (lambda (e)
(= (assq-ref e #:evaluation) (assq-ref eval #:id)))
absolute-summary))
`((p (@ (class "lead")) "Evaluations of " ,name
(button (@ (class "btn btn-outline-primary float-right job-toggle")
(type "button"))
(span (@ (class "oi oi-contrast d-inline-block")
(title "Toggle jobs"))))
(a (@ (href "/events/rss/?specification=" ,name))
(button (@ (class "btn btn-outline-warning float-right mr-1")
(type "button"))
(span (@(class "oi oi-rss text-warning align-right")
(title "RSS")
(aria-hidden "true"))
""))))
(script "
$(document).ready(function() {
$('.job-toggle').click(function() {
$('.job-abs').toggle();
$('.job-rel').toggle();
})});")
(table
(@ (class "table table-sm table-hover table-striped"))
,@(if (null? evaluations)
`((th (@ (scope "col")) "No elements here."))
`((thead
(tr
(th (@ (scope "col")) "#")
(th (@ (scope "col")) "Channel changes")
(th (@ (scope "col")) Success)
(th (@ (scope "col")) Action)))
(tbody
,@(map
(lambda (row)
`(tr (th (@ (scope "row"))
(a (@ (href "/eval/" ,(assq-ref row #:id)))
,(assq-ref row #:id)))
(td ,(input-changes (assq-ref row #:checkouts)))
(td
,@(evaluation-badges row
(eval-absolute-summary row)))
(td
(a (@ (href "/eval/" ,(assq-ref row #:id)
"/dashboard"))
(div
(@ (class "oi oi-monitor d-inline-block")
(title "Dashboard")
(aria-hidden "true"))
""))
(div
(@ (class "dropdown d-inline-block ml-2"))
(a (@ (class "oi oi-menu dropdown-toggle no-dropdown-arrow")
(href "#")
(data-toggle "dropdown")
(role "button")
(aria-haspopup "true")
(aria-expanded "false"))
" ")
(div (@ (class "dropdown-menu"))
(a (@ (class "dropdown-item")
(href "/admin/evaluation/"
,(assq-ref row #:id)
"/cancel"))
" Cancel pending builds")
(a (@ (class "dropdown-item")
(href "/admin/evaluation/"
,(assq-ref row #:id)
"/restart"))
" Restart all builds")
(a (@ (class "dropdown-item")
(href "/admin/evaluation/"
,(assq-ref row #:id)
"/retry"))
" Retry the evaluation"))))))
evaluations)))))
,(if (null? evaluations)
(pagination "" "" "" "")
(let* ((eval-ids (map (cut assq-ref <> #:id) evaluations))
(page-id-min (last eval-ids))
(page-id-max (first eval-ids)))
(pagination
(format #f "?border-high=~d" (1+ id-max))
(if (= page-id-max id-max)
""
(format #f "?border-low=~d" page-id-max))
(if (= page-id-min id-min)
""
(format #f "?border-high=~d" page-id-min))
(format #f "?border-low=~d" (1- id-min)))))))
(define (time->string time)
"Return a string representing TIME in a concise, human-readable way."
(define now*
(current-time time-utc))
(define now
(time-second now*))
(define elapsed
(- now time))
(cond ((< elapsed 120)
"seconds ago")
((< elapsed 7200)
(let ((minutes (inexact->exact
(round (/ elapsed 60)))))
(format #f "~a minutes ago" minutes)))
((< elapsed (* 48 3600))
(let ((hours (inexact->exact
(round (/ elapsed 3600)))))
(format #f "~a hours ago" hours)))
(else
(let* ((time (make-time time-utc 0 time))
(date (time-utc->date time))
(year (date-year date))
(current (date-year (time-utc->date now*)))
(format (if (= year current)
"~e ~b ~H:~M ~z"
"~e ~b ~Y ~H:~M")))
(date->string date format)))))
(define (weather-class status)
(cond
((= (build-weather unknown) status)
"oi oi-media-record text-primary mt-1")
((= (build-weather new-success) status)
"oi oi-arrow-thick-top text-success mt-1")
((= (build-weather new-failure) status)
"oi oi-arrow-thick-bottom text-danger mt-1")
((= (build-weather still-succeeding) status)
"oi oi-media-record text-success mt-1")
((= (build-weather still-failing) status)
"oi oi-media-record text-danger mt-1")))
(define (weather-title status)
(cond
((= (build-weather unknown) status) "Unknown")
((= (build-weather new-success) status) "New success")
((= (build-weather new-failure) status) "New failure")
((= (build-weather still-succeeding) status) "Still succeeding")
((= (build-weather still-failing) status) "Still failing")))
(define (build-eval-table eval-id builds build-min build-max status)
"Return HTML for the BUILDS table evaluation with given STATUS. BUILD-MIN
and BUILD-MAX are global minimal and maximal (stoptime, rowid) pairs."
(define (table-header)
`(thead
(tr
(th (@ (scope "col") (class "border-0")) '())
(th (@ (scope "col") (class "border-0")) '())
(th (@ (scope "col") (class "border-0")) "ID")
(th (@ (scope "col") (class "border-0")) "Specification")
(th (@ (scope "col") (class "border-0")) "Completion time")
(th (@ (scope "col") (class "border-0")) "Job")
(th (@ (scope "col") (class "border-0")) "Name")
(th (@ (scope "col") (class "border-0")) "System")
(th (@ (scope "col") (class "border-0")) "Log"))))
(define (table-row build)
(define status
(assq-ref build #:buildstatus))
(define weather
(assq-ref build #:weather))
(define completed?
(or (= (build-status succeeded) status)
(= (build-status failed) status)))
`(tr
(td (span (@ (class ,(status-class status))
(title ,(status-title status))
(aria-hidden "true"))
""))
(td (span (@ (class ,(weather-class weather))
(title ,(weather-title weather))
(aria-hidden "true"))
""))
(th (@ (scope "row"))
(a (@ (href "/build/" ,(assq-ref build #:id) "/details"))
,(assq-ref build #:id)))
(td ,(assq-ref build #:jobset))
(td ,(if completed?
(time->string (assq-ref build #:stoptime))
"—"))
(td ,(assq-ref build #:job))
(td ,(assq-ref build #:nixname))
(td ,(assq-ref build #:system))
(td (a (@ (href "/build/" ,(assq-ref build #:id) "/log/raw"))
"raw"))))
(define (build-id build)
(match build
((stoptime id) id)))
(define (build-stoptime build)
(match build
((stoptime id) stoptime)))
`((table
(@ (id "eval-table")
(class "table table-sm table-hover table-striped"))
,@(if (null? builds)
`((th (@ (scope "col") (class "border-0")) "No elements here."))
`(,(table-header)
(tbody ,@(map table-row builds)))))
,(if (null? builds)
(pagination "" "" "" "")
(let* ((build-time-ids (map (lambda (row)
(list (assq-ref row #:stoptime)
(assq-ref row #:id)))
builds))
(page-build-min (last build-time-ids))
(page-build-max (first build-time-ids)))
(pagination
(format
#f "?border-high-time=~d&border-high-id=~d~@[&status=~a~]"
(build-stoptime build-max)
(1+ (build-id build-max))
status)
(if (equal? page-build-max build-max)
""
(format
#f "?border-low-time=~d&border-low-id=~d~@[&status=~a~]"
(build-stoptime page-build-max)
(build-id page-build-max)
status))
(if (equal? page-build-min build-min)
""
(format
#f "?border-high-time=~d&border-high-id=~d~@[&status=~a~]"
(build-stoptime page-build-min)
(build-id page-build-min)
status))
(format
#f "?border-low-time=~d&border-low-id=~d~@[&status=~a~]"
(build-stoptime build-min)
(1- (build-id build-min))
status))))))
;; FIXME: Copied from (guix scripts describe).
(define %vcs-web-views
;; Hard-coded list of host names and corresponding web view URL templates.
;; TODO: Allow '.guix-channel' files to specify a URL template.
(let ((labhub-url (lambda (repository-url commit)
(string-append
(if (string-suffix? ".git" repository-url)
(string-drop-right repository-url 4)
repository-url)
"/commit/" commit))))
`(("git.savannah.gnu.org"
,(lambda (repository-url commit)
(string-append (string-replace-substring repository-url
"/git/" "/cgit/")
"/log/?id=" commit)))
("notabug.org" ,labhub-url)
("framagit.org" ,labhub-url)
("gitlab.com" ,labhub-url)
("gitlab.inria.fr" ,labhub-url)
("github.com" ,labhub-url))))
(define (commit-hyperlink url commit)
"Return, if possibly, a hyperlink for COMMIT of the repository at URL."
(let* ((uri (string->uri url))
(host (uri-host uri)))
(match (assoc-ref %vcs-web-views host)
(#f commit)
((link) `(a (@ (href ,(link url commit))) ,commit)))))
(define (nearest-exact-integer x)
"Given a real number X, return the nearest exact integer, with ties going to
the nearest exact even integer."
(inexact->exact (round x)))
(define (seconds->string duration)
(if (< duration 60)
(format #f "~a second~:p" duration)
(format #f "~a minute~:p" (nearest-exact-integer
(/ duration 60)))))
(define* (evaluation-build-table evaluation
#:key
channels
(checkouts '())
status builds
builds-id-min builds-id-max)
"Return HTML for an evaluation page, containing a table of builds for that
evaluation."
(define id (assq-ref evaluation #:id))
(define total (assq-ref evaluation #:total))
(define succeeded (assq-ref evaluation #:succeeded))
(define timestamp (assq-ref evaluation #:timestamp))
(define evaltime (assq-ref evaluation #:evaltime))
(define failed (assq-ref evaluation #:failed))
(define scheduled (assq-ref evaluation #:scheduled))
(define spec (assq-ref evaluation #:spec))
(define duration (- evaltime timestamp))
`((script "
$(document).ready(function() {
var url = new URL(window.location.href);
var params = url.searchParams;
var paginate = params.get('paginate');
var href;
console.log(paginate);
if (!paginate || paginate == '1') {
params.set('paginate', 0);
$('#paginate').attr('href', url.toString());
} else if (paginate == '0') {
params.set('paginate', 1);
$('#paginate').attr('class', 'oi oi-collapse-up');
$('#paginate').attr('href', url.toString());
}
});
")
(p (@ (class "lead"))
,(format #f "Evaluation #~a" id))
,@(if (= timestamp 0)
'()
`((p ,(if (= evaltime 0)
(format #f "Evaluation started ~a."
(time->string timestamp))
(format #f "Evaluation completed ~a in ~a."
(time->string evaltime)
(seconds->string duration))))))
(table (@ (class "table table-sm table-hover"))
(thead
(tr (th (@ (class "border-0") (scope "col")) "Channel")
(th (@ (class "border-0") (scope "col")) "Commit")))
(tbody
,@(map (lambda (checkout)
(let* ((name (assq-ref checkout #:channel))
(channel (find (lambda (channel)
(eq? (channel-name channel)
name))
channels))
(url (channel-url channel))
(commit (assq-ref checkout #:commit)))
;; Some checkout entries may refer to removed
;; inputs.
(if channel
`(tr (td ,url)
(td (code ,(commit-hyperlink url commit))))
'())))
checkouts)))
(p (@ (class "lead"))
,(format #f "~@[~a~] ~:[B~;b~]uilds of evaluation #~a"
(and=> status string-capitalize)
status
id)
" "
(a (@ (class "oi oi-monitor mr-2")
(style "font-size:0.8em")
(href "/eval/" ,id "/dashboard")
(role "button")))
(a (@ (id "paginate")
(class "oi oi-collapse-down")
(style "font-size:0.7em")
(href "")
(role "button"))))
(ul (@ (class "nav nav-tabs"))
(li (@ (class "nav-item"))
(a (@ (class ,(string-append "nav-link "
(match status
(#f "active")
(_ ""))))
(href "?all="))
"All "
(span (@ (class "badge badge-light badge-pill"))
,total)))
(li (@ (class "nav-item"))
(a (@ (class ,(string-append "nav-link "
(match status
("pending" "active")
(_ ""))))
(href "?status=pending"))
(span (@ (class "oi oi-clock text-warning")
(title "Scheduled")
(aria-hidden "true"))
"")
" Scheduled "
(span (@ (class "badge badge-light badge-pill"))
,scheduled)))
(li (@ (class "nav-item"))
(a (@ (class ,(string-append "nav-link "
(match status
("succeeded" "active")
(_ ""))))
(href "?status=succeeded"))
(span (@ (class "oi oi-check text-success")
(title "Succeeded")
(aria-hidden "true"))
"")
" Succeeded "
(span (@ (class "badge badge-light badge-pill"))
,succeeded)))
(li (@ (class "nav-item"))
(a (@ (class ,(string-append "nav-link "
(match status
("failed" "active")
(_ ""))))
(href "?status=failed"))
(span (@ (class "oi oi-x text-danger")
(title "Failed")
(aria-hidden "true"))
"")
" Failed "
(span (@ (class "badge badge-light badge-pill"))
,failed))))
(div (@ (class "tab-content pt-3"))
(div (@ (class "tab-pane show active"))
,(build-eval-table
id
builds
builds-id-min
builds-id-max
status)))))
(define (build-search-results-table query builds build-min build-max)
"Return HTML for the BUILDS table evaluation matching QUERY. BUILD-MIN
and BUILD-MAX are global minimal and maximal row identifiers."
(define (table-header)
`(thead
(tr
(th (@ (scope "col")) '())
(th (@ (scope "col")) "ID")
(th (@ (scope "col")) "Specification")
(th (@ (scope "col")) "Completion time")
(th (@ (scope "col")) "Job")
(th (@ (scope "col")) "Name")
(th (@ (scope "col")) "System")
(th (@ (scope "col")) "Log"))))
(define (table-row build)
(define status
(assq-ref build #:buildstatus))
(define completed?
(or (= (build-status succeeded) status)
(= (build-status failed) status)))
`(tr
(td (span (@ (class ,(status-class status))
(title ,(status-title status))
(aria-hidden "true"))
""))
(th (@ (scope "row"))
(a (@ (href "/build/" ,(assq-ref build #:id) "/details"))
,(assq-ref build #:id)))
(td ,(assq-ref build #:jobset))
(td ,(if completed?
(time->string (assq-ref build #:stoptime))
"—"))
(td ,(assq-ref build #:job))
(td ,(assq-ref build #:nixname))
(td ,(assq-ref build #:system))
(td ,(if completed?
`(a (@ (href "/build/" ,(assq-ref build #:id) "/log/raw"))
"raw")
"—"))))
`((p (@ (class "lead"))
"Builds matching " (em ,query))
(table
(@ (class "table table-sm table-hover table-striped"))
,@(if (null? builds)
`((th (@ (scope "col")) "No elements here."))
`(,(table-header)
(tbody ,@(map table-row builds)))))
,(if (null? builds)
(pagination "" "" "" "")
(let* ((build-ids (map (lambda (row) (assq-ref row #:id)) builds))
(page-build-min (last build-ids))
(page-build-max (first build-ids)))
(pagination
(format
#f "?query=~a&border-high-id=~d"
query
(1+ (first build-max)))
(if (equal? page-build-max (first build-max))
""
(format
#f "?query=~a&border-low-id=~d"
query
page-build-max))
(if (equal? page-build-min (first build-min))
""
(format
#f "?query=~a&border-high-id=~d"
query
page-build-min))
(format
#f "?query=~a&border-low-id=~d"
query
(1- (first build-min))))))))
(define (running-builds-table builds)
"Return HTML for the running builds table."
(define (build-row build)
`(tr
(th (@ (scope "row"))
(a (@ (href "/build/" ,(assq-ref build #:id) "/details"))
,(assq-ref build #:id)))
(td ,(assq-ref build #:job-name))
(td ,(time->string
(assq-ref build #:starttime)))
(td ,(assq-ref build #:system))
(td (a (@ (href "/build/" ,(assq-ref build #:id) "/log/raw"))
"raw"))))
`((p (@ (class "lead")) "Running builds")
(table
(@ (class "table table-sm table-hover table-striped"))
,@(if (null? builds)
`((th (@ (scope "col")) "No elements here."))
`((thead (tr (th (@ (scope "col")) "ID")
(th (@ (scope "col")) "Job")
(th (@ (scope "col")) "Queued at")
(th (@ (scope "col")) "System")
(th (@ (scope "col")) "Log")))
(tbody
,(map build-row builds)))))))
(define* (make-line-chart id datasets
#:key
(interpolation? #t)
(legend? #f)
(time-x-axes? #f)
xaxes-labels
x-label
y-label
(x-unit "day")
title
labels
colors)
(let* ((normal-xAxes (vector `((type . "category")
(labels . ,xaxes-labels)
(display . #t)
(scaleLabel
. ((display . #t)
(labelString . ,x-label))))))
(time-xAxes (vector `((type . "time")
(time . ((unit . ,x-unit)))
(display . #t)
(distribution . "series")
(scaleLabel
. ((display . #t)
(labelString . ,x-label))))))
(scales `((xAxes . ,(if time-x-axes?
time-xAxes
normal-xAxes))
(yAxes
. ,(vector `((display . #t)
(scaleLabel
. ((display . #t)
(labelString . ,y-label))))))))
(chart `((type . "line")
(data . ((datasets
. ,(apply vector
(map (lambda (dataset label color)
`((fill . #f)
(label . ,label)
,@(if interpolation?
'()
'((lineTension . 0)))
(borderColor . ,color)
(data . ,dataset)))
datasets labels colors)))))
(options . ((responsive . #t)
(tooltips . ((enabled . #f)))
(legend . ((display . ,legend?)))
(title . ((display . #f)
(text . ,title)))
(scales . ,scales))))))
`((script ,(format #f "window.addEventListener(\"load\",
function(event) {\
window.~a = new Chart\
(document.getElementById('~a').getContext('2d'), ~a);\
});" id id (scm->json-string chart))))))
(define* (global-metrics-content #:key
avg-eval-durations
avg-eval-build-start-time
builds-per-day
builds-per-machine
eval-completion-speed
new-derivations-per-day
pending-builds
percentage-failed-eval)
(define (avg-eval-duration-row . eval-durations)
(let ((spec (match eval-durations
(((spec . _) . rest) spec))))
`(tr (td ,spec)
,@(map (lambda (duration)
`(td ,(number->string
(nearest-exact-integer duration))))
(map cdr eval-durations)))))
(define (percentage-failed-eval-row . percentages)
(let ((spec (match percentages
(((spec . _) . rest) spec))))
`(tr (td ,spec)
,@(map (lambda (duration)
`(td ,(number->string
(nearest-exact-integer duration))
"%"))
(map cdr percentages)))))
(define (builds-per-machine-rows builds)
(map (match-lambda
((field . value)
`(tr (td ,field)
(td ,(number->string
(nearest-exact-integer value))))))
builds))
(define (builds->json-scm builds)
(apply vector
(map (match-lambda
((field . value)
`((x . ,(* field 1000)) (y . ,value))))
builds)))
(define (evals->json-scm evals)
(apply vector
(map (match-lambda
((field . value)
`((x . ,(number->string field)) (y . ,value))))
evals)))
(define (evals->labels evals)
(apply vector
(map (match-lambda
((field . value) field))
evals)))
(let ((builds-chart "builds_per_day")
(build-start-chart "avg_eval_build_start_time")
(evaluation-speed-chart "eval_speed_chart")
(pending-builds-chart "pending_builds"))
`((p (@ (class "lead")) "Global metrics")
(h6 "Average evaluation duration per specification (seconds).")
(table
(@ (class "table table-sm table-hover table-striped"))
(thead (tr (th (@ (scope "col")) "Specification")
(th (@ (scope "col")) "10 last evaluations")
(th (@ (scope "col")) "100 last evaluations")
(th (@ (scope "col")) "All evaluations")))
(tbody
,(apply map avg-eval-duration-row avg-eval-durations)))
(br)
(h6 "Builds completion.")
(p "This shows the difference between newly added derivations and built
derivations per day.")
(canvas (@ (id ,builds-chart)))
(br)
(h6 "Evaluation average build start time.")
(p "This is the average time required for an evaluation to start its
builds.")
(br)
(canvas (@ (id ,build-start-chart)))
(br)
(h6 "Evaluation completion speed.")
(p "The evaluation completion speed is the sum of an evaluation
completed builds divided by the time required to build them.")
(br)
(canvas (@ (id ,evaluation-speed-chart)))
(br)
(h6 "Pending builds.")
(p "This is the sum of all the currently pending builds.")
(br)
(canvas (@ (id ,pending-builds-chart)))
(br)
(h6 "Builds per machine.")
(p "This is the builds count per machine during the last day.")
(table
(@ (class "table table-sm table-hover table-striped"))
(thead (tr (th (@ (scope "col")) "Machine")
(th (@ (scope "col")) "Builds (last 24 hours)")))
(tbody
,(builds-per-machine-rows builds-per-machine)))
(br)
(h6 "Percentage of failed evaluations.")
(table
(@ (class "table table-sm table-hover table-striped"))
(thead (tr (th (@ (scope "col")) "Specification")
(th (@ (scope "col")) "10 last evaluations")
(th (@ (scope "col")) "100 last evaluations")
(th (@ (scope "col")) "All evaluations")))
(tbody
,(apply map percentage-failed-eval-row percentage-failed-eval)))
;; Scripts.
(script (@ (src "/static/js/chart.js")))
,@(make-line-chart builds-chart
(list (builds->json-scm new-derivations-per-day)
(builds->json-scm builds-per-day))
#:interpolation? #f
#:time-x-axes? #t
#:x-label "Day"
#:y-label "Builds"
#:title "Builds per day"
#:legend? #t
#:labels '("New derivations"
"Builds completed")
#:colors (list "#f6dd27" "#3e95cd"))
,@(make-line-chart build-start-chart
(list (evals->json-scm avg-eval-build-start-time))
#:xaxes-labels (evals->labels
avg-eval-build-start-time)
#:x-label "Evaluations"
#:y-label "Time (s)"
#:title "Evaluation average build start time"
#:labels '("Build start time")
#:colors (list "#3e95cd"))
,@(make-line-chart evaluation-speed-chart
(list (evals->json-scm eval-completion-speed))
#:xaxes-labels (evals->labels
eval-completion-speed)
#:x-label "Evaluations"
#:y-label "Speed (builds/hour)"
#:title "Evaluation completion speed"
#:labels '("Completion speed")
#:colors (list "#3e95cd"))
,@(make-line-chart pending-builds-chart
(list (builds->json-scm pending-builds))
#:time-x-axes? #t
#:x-label "Day"
#:y-label "Builds"
#:title "Pending builds"
#:labels '("Pending builds")
#:colors (list "#3e95cd")))))
(define (workers-status workers builds)
(define (machine-row machine)
(let* ((workers (sort (filter-map
(lambda (worker)
(and (string=? (worker-machine worker)
machine)
(worker-name worker)))
workers)
string<?))
(builds
(map (lambda (worker)
(match (filter
(lambda (build)
(let ((build-worker
(assq-ref build #:worker)))
(and build-worker
(string=? build-worker worker))))
builds)
(() #f)
((build _ ...) build)))
workers)))
`(div (@ (class "col-sm-4 mt-3"))
(a (@(href "/machine/" ,machine))
(h6 ,machine))
,(map (lambda (build)
(let ((style (format #f
"width: ~a%"
(if build
(assq-ref build #:percentage)
0))))
`(div (@ (class "progress mt-1")
(style "height: 20px"))
(div (@ (class "progress-bar")
(role "progressbar")
(style ,style)
(aria-valuemin "0")
(aria-valuemax "100"))
,(if build
`(strong
(@ (class "justify-content-center
d-flex position-absolute w-100"))
(a (@ (class "text-dark text-truncate")
(style "max-width: 150px")
(href "/build/"
,(assq-ref build #:id)
"/details"))
,(assq-ref build #:job-name)))
'(em
(@ (class "justify-content-center
text-dark d-flex position-absolute w-100"))
"idle"))))))
builds))))
(let ((machines (reverse
(sort (delete-duplicates
(map worker-machine workers))
version>?))))
`((p (@ (class "lead")) "Workers status")
(div (@ (class "container"))
(div (@ (class "row"))
,@(map machine-row machines))))))
(define* (machine-status name workers builds info)
(define (history->json-scm history)
(apply vector
(map (match-lambda
((field . value)
`((x . ,(* field 1000)) (y . ,value))))
history)))
(define (ram-available->json-scm history)
(apply vector
(map (match-lambda
((field . value)
`((x . ,(* field 1000))
(y . ,(/ value (expt 2 30))))))
history)))
`((p (@ (class "lead")) "Machine " ,name)
,@(if (null? info)
'()
`((table
(@ (class "table table-sm table-hover table-striped"))
(tbody
(tr (th "Hostname")
(td ,(assq-ref info #:hostname)))
(tr (th "Info")
(td ,(assq-ref info #:info)))
(tr (th "Boot time")
(td ,(time->string
(assq-ref info #:boottime))))
(tr (th "Total RAM")
(td ,(assq-ref info #:ram)))
(tr (th "Total root disk space")
(td ,(assq-ref info #:root-space)))
(tr (th "Total store disk space")
(td ,(assq-ref info #:store-space)))))))
(h6 "Workers")
(table
(@ (class "table table-sm table-hover table-striped"))
,@(if (null? workers)
`((th (@ (scope "col")) "No elements here."))
`((thead
(tr
(th (@ (scope "col")) "Name")
(th (@ (scope "col")) "Systems")
(th (@ (scope "col")) "Building")
(th (@ (scope "col")) "Last seen")))
(tbody
,@(map
(lambda (worker build)
`(tr (td ,(worker-name worker))
(td ,(string-join (worker-systems worker)
", "))
(td ,(match build
(() "idle")
((build _ ...)
`(a (@ (class "text-truncate")
(style "max-width: 150px")
(href "/build/"
,(assq-ref build #:id)
"/details"))
,(assq-ref build #:job-name)))))
(td ,(time->string
(worker-last-seen worker)))))
workers builds)))))
,@(if (null? info)
'((div (@ (class "alert alert-danger"))
"Could not find machine information using Zabbix."))
`((h6 "CPU idle time")
,@(let ((cpu-idle (assq-ref info #:cpu-idle))
(cpu-idle-chart "cpu_idle_chart"))
`((script (@ (src "/static/js/chart.js")))
(br)
(canvas (@ (id ,cpu-idle-chart)))
,@(make-line-chart cpu-idle-chart
(list (history->json-scm cpu-idle))
#:time-x-axes? #t
#:x-label "Time"
#:y-label "Percentage"
#:x-unit "minute"
#:title "CPU idle time"
#:labels '("CPU idle time")
#:colors (list "#3e95cd"))))
(br)
(h6 "Available memory")
,@(let ((ram-available (assq-ref info #:ram-available))
(ram-available-chart "ram_available_chart"))
`((script (@ (src "/static/js/chart.js")))
(br)
(canvas (@ (id ,ram-available-chart)))
,@(make-line-chart ram-available-chart
(list
(ram-available->json-scm ram-available))
#:time-x-axes? #t
#:x-label "Time"
#:y-label "GiB"
#:x-unit "minute"
#:title
"Available memory"
#:labels
'("Available memory")
#:colors (list "#3e95cd"))))
(br)
(h6 "Free store disk space percentage")
,@(let ((store-free (assq-ref info #:store-free))
(store-free-chart "store_free_chart"))
`((script (@ (src "/static/js/chart.js")))
(br)
(canvas (@ (id ,store-free-chart)))
,@(make-line-chart store-free-chart
(list (history->json-scm store-free))
#:time-x-axes? #t
#:x-label "Time"
#:y-label "Percentage"
#:x-unit "minute"
#:title
"Free store disk space percentage"
#:labels
'("Free store disk space percentage")
#:colors (list "#3e95cd"))))))))
(define* (evaluation-dashboard evaluation systems
#:key current-system)
(let ((jobs
(format #f "/api/jobs?evaluation=~a&system=~a"
evaluation current-system)))
`((p (@ (class "lead"))
,(format #f "Dasboard evaluation #~a" evaluation))
(form (@ (id "get-dashboard")
(class "row g-3 mb-3")
(action "/eval/" ,evaluation "/dashboard")
(method "GET"))
(div (@ (class "col-auto"))
(select (@ (id "system")
(name "system")
(class "form-control"))
,@(map (lambda (system)
`(option
(@ (value ,system)
,@(if (string=? system current-system)
'((selected))
'()))
,system))
systems)))
(div (@ (class "col-auto"))
(button
(@ (id "load-btn")
(class "btn btn-primary")
(type "submit")
(disabled))
(span
(@ (class "spinner-border spinner-border-sm")))
(span
" Loading"))))
(script ,(format #f "
function enableLoadButton() {
$('#load-btn').removeAttr('disabled');
$('#load-btn').children().eq(0).hide();
$('#load-btn').children().eq(1).text('Go');
}
function disableLoadButton() {
$('#load-btn').attr('disabled', 'true');
$('#load-btn').children().eq(0).show();
$('#load-btn').children().eq(1).text(' Loading');
}
function radius(count) {
if (count < 100)
return 15;
else if (count < 1000)
return 10;
else
return 5;
}
function color(status) {
switch (status) {
case -3:
case -2:
return 'gray';
case -1:
return 'orange';
case 0:
return 'green';
case 1:
case 2:
case 3:
case 4:
return 'red';
}
}
function svgWidth() {
var width = d3.select('#content').style('width').slice(0, -2);
return Math.round(Number(width));
}
disableLoadButton();
d3.json('~a').then(function (data) {
var width = svgWidth();
var circle_radius = radius(data.length);
var margin_x = circle_radius;
var margin_y = circle_radius;
var margin_circle_x = 3;
var margin_circle_y = (2.5 * circle_radius);
var circle_count_x =
Math.floor((width - 2 * margin_x) /
((2 * circle_radius) + margin_circle_x));
var height = ((data.length / circle_count_x) *
margin_circle_y) +
circle_radius + 2 * margin_y;
var div = d3.select('body').append('div')
.attr('class', 'tooltip')
.style('opacity', 0);
var svg = d3.select('#content').append('svg')
.attr('width', width)
.attr('height', height);
var circles = svg.append('g')
.selectAll('circle')
.data(data)
.enter()
.append('a')
.attr('xlink:href', d => '/build/' + d.build + '/details')
.append('circle')
.attr('r', circle_radius)
.attr('cx', function(d, i) {
return margin_x +
(i % circle_count_x)
* (circle_radius * 2 + margin_circle_x);
})
.attr('cy', function (d, i) {
return margin_y + Math.floor(i / circle_count_x)
* margin_circle_y;
})
.style('fill', d => color(d.status))
.on('mouseover', function(event, d) {
var circle = d3.select(this)
.style('fill', 'steelblue');
div.style('opacity', .9);
div.html(d.name)
.style('left', (event.pageX + 30) + 'px')
.style('top', (event.pageY - 30) + 'px');
})
.on('mouseout', function(event, d) {
var circle = d3.select(this)
.style('fill', color(d.status));
div.style('opacity', 0);
div.html('')
.style('left', '0px')
.style('top', '0px');
})
enableLoadButton();
});" jobs)))))