[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
01/01: hydra: Add Cuirass JavaScript front-end.
From: |
Ludovic Court�s |
Subject: |
01/01: hydra: Add Cuirass JavaScript front-end. |
Date: |
Mon, 29 Jan 2018 12:18:28 -0500 (EST) |
civodul pushed a commit to branch master
in repository maintenance.
commit cf6fc7ddfbf3644f813687dd5f3c0c244d75bfb0
Author: Danny Milosavljevic <address@hidden>
Date: Mon Jan 29 18:17:48 2018 +0100
hydra: Add Cuirass JavaScript front-end.
This will be available as <https://berlin.guixsd.org/status>.
* hydra/nginx/html/status/index.html: New file.
---
hydra/nginx/html/status/index.html | 667 +++++++++++++++++++++++++++++++++++++
1 file changed, 667 insertions(+)
diff --git a/hydra/nginx/html/status/index.html
b/hydra/nginx/html/status/index.html
new file mode 100644
index 0000000..d95c599
--- /dev/null
+++ b/hydra/nginx/html/status/index.html
@@ -0,0 +1,667 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Cuirass Status Frontend</title>
+<style media="screen">
+<!--
+table {
+ border-collapse: collapse;
+ width: 90%;
+}
+table thead tr {
+ border-top: none 0px;
+ border-left: none 0px;
+ border-right: none 0px;
+ border-bottom: solid 1px;
+}
+table thead tr td {
+ font-weight: bold;
+}
+th {
+ padding: 40px;
+}
+tr.packagegroup {
+ border: 1px solid;
+}
+td {
+ vertical-align: top;
+}
+tr.packagegroup td span.name {
+}
+tr.packagegroup td a.log {
+}
+
+tr.filter {
+ background-color: lightgray;
+}
+span {
+}
+-->
+</style>
+<script>
+/*
+ @licstart The following is the entire license notice for the
+ JavaScript code in this page.
+
+ Copyright (C) 2018 Danny Milosavljevic
+
+ The JavaScript code in this page is free software: you can
+ redistribute it and/or modify it under the terms of the GNU
+ General Public License (GNU GPL) as published by the Free Software
+ Foundation, either version 3 of the License, or (at your option)
+ any later version. The code is distributed WITHOUT ANY WARRANTY;
+ without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
+
+ As additional permission under GNU GPL version 3 section 7, you
+ may distribute non-source (e.g., minimized or compacted) forms of
+ that code without the copy of the GNU GPL normally required by
+ section 4, provided you include this license notice and a URL
+ through which recipients can access the Corresponding Source.
+
+ @licend The above is the entire license notice
+ for the JavaScript code in this page.
+*/
+</script>
+<script>
+<!--
+'use strict';
+
+function td(child) {
+ let r = document.createElement("td");
+ r.appendChild(child);
+ return r;
+}
+
+function setStatusText(text) {
+ let currentdate = new Date();
+ let s = currentdate.toString() + " " + text;
+ document.getElementById("status").textContent = s;
+}
+
+let retrydelay = 3000; // ms
+function tryAgain(thunk) {
+ /* TODO random backoff */
+ window.setTimeout(thunk, retrydelay);
+ //if (retrydelay < 30000)
+ // retrydelay = retrydelay * 2;
+}
+
+let URLPREFIX = "https://berlin.guixsd.org/";;
+//let URLPREFIX = "http://localhost:8080/";;
+let APIURLPREFIX = URLPREFIX + "api/";
+function getQueryValues(parametername) {
+ var result = [];
+ var tmp = [];
+ location.search.substr(1).split(/[&;]/).forEach(function (item) {
+ let parts = item.split("=", 2);
+ if (parts[0] === parametername)
+ result.push(decodeURIComponent(parts[1]));
+ });
+ return result;
+}
+
+let systems = []; // will be filled by jobsetsrequest.
+let project = (getQueryValues("project").concat(["core-updates"]))[0]; //
repo-name
+let jobset = (getQueryValues("jobset").concat(["core-updates"]))[0]; // branch
+let APIURLSUFFIX = "&project=" + encodeURIComponent(project) + "&jobset=" +
encodeURIComponent(jobset);
+
+/* Finds the newest build per system and hides all the others in BUILDSTD */
+function hideOlderbuilds(buildstd, buildtimestd) {
+ let builds = buildstd.getElementsByTagName("div");
+ let buildtimes = buildtimestd.getElementsByTagName("span");
+ let i = 0;
+ let maximaltimes = new Object();
+ let maximalbuildids = new Object();
+ for (i = 0; i < builds.length; ++i) {
+ let build = builds[i];
+ let buildid = build.name*1;
+ let buildtimeelement = buildtimes[i + 1]; // first is the
maximum.
+ let a = build.getElementsByTagName("a")[0];
+ let system = a.name;
+ let buildtime = buildtimeelement.textContent*1;
+ if (!maximaltimes[system] || buildtime > maximaltimes[system]) {
+ maximaltimes[system] = buildtime;
+ maximalbuildids[system] = 0;
+ } else if (maximaltimes[system] && buildtime ==
maximaltimes[system]) {
+ // disambiguate.
+ if (!maximalbuildids[system] || buildid >
maximalbuildids[system])
+ maximalbuildids[system] = buildid;
+ }
+ }
+ for (i = 0; i < builds.length; ++i) {
+ let build = builds[i];
+ let buildid = build.name*1;
+ let buildtimeelement = buildtimes[i + 1]; // first is the
maximum.
+ let a = build.getElementsByTagName("a")[0];
+ let system = a.name;
+ let buildtime = buildtimeelement.textContent*1;
+ if (buildtime < maximaltimes[system] || (buildtime ==
maximaltimes[system] && (maximalbuildids[system] && buildid !=
maximalbuildids[system]))) {
+ build.style.display = "none";
+ }
+ }
+}
+
+/** Given a TD which has build time HTML child elements, updates the first
element to the maximum of the others.
+ Precondition: First element has no name. */
+function updateLatestbuildtime(td) {
+ let latestbuildtimenode = td.childNodes[0];
+ let value = new Date(0);
+ // Find maximum
+ Array.prototype.forEach.call(td.childNodes, function(e) {
+ if (e.name) {
+ let xvalue = new Date(e.textContent*1);
+ if (xvalue > value)
+ value = xvalue;
+ }
+ });
+ latestbuildtimenode.textContent = value;
+}
+
+function getElementsByName(root, name) {
+ let childelements = root.getElementsByTagName("*"); // also returns
itself, sigh...
+ let result = [];
+ Array.prototype.forEach.call(childelements, function(e) {
+ if (root != e && e.name == name)
+ result.push(e);
+ });
+ return result;
+}
+
+// XXX: I think getElementsByTagName does depth recursion. That's not what I
want.
+
+/** Given a TABLE and PACKAGEGROUPID, removes the entry for build BUILDID from
it. */
+function removePackagegroupelement(table, packagegroupid, buildid) {
+ let rootbody = table.getElementsByTagName("tbody")[0];
+ let packagegroups = getElementsByName(rootbody, "packagegroup_" +
packagegroupid);
+ if (packagegroups.length > 0) {
+ let packagegroup = packagegroups[0];
+ // Note: buildsTd and buildtimesTd should have the same number
of payload data elements.
+ let buildsTd = getElementsByName(packagegroup, "builds")[0];
+ let buildtimesTd = getElementsByName(packagegroup,
"buildtimes")[0];
+ let logentries = getElementsByName(buildsTd, buildid);
+ let buildtimeentries = getElementsByName(buildtimesTd, buildid);
+ if (buildtimeentries.length > 0) {
+ let buildtimeentry = buildtimeentries[0];
+ buildtimesTd.removeChild(buildtimeentry);
+ updateLatestbuildtime(buildtimesTd);
+ }
+ if (logentries.length > 0) {
+ let logentry = logentries[0];
+ buildsTd.removeChild(logentry);
+ }
+ if (buildsTd.childNodes.length == 0) { /* empty packagegroup */
+ rootbody.removeChild(packagegroup);
+ }
+ }
+}
+
+/** Given a TD and NAME, replaces the element by VALUER().
+ If it didn't exist yet, adds it. */
+function updateMultientry(td, name, valuer) {
+ let entries = getElementsByName(td, name);
+ let span = valuer();
+ if (entries.length == 0) {
+ //if (td.childNodes.length != 0 && span.style.visibility !=
"none") {
+ // let br = document.createElement("br");
+ // td.appendChild(br);
+ //}
+ td.appendChild(span);
+ } else {
+ td.replaceChild(span, entries[0]);
+ }
+}
+
+function element(tagName, name) {
+ let r = document.createElement(tagName);
+ r.name = name;
+ r.className = name;
+ return r;
+}
+
+/** Given a TABLE and PACKAGEGROUPID, adds an entry for build BUILDID to it.
+LINK specifies whether to add a link to show the log. */
+function addPackagegroupelement(table, packagegroupid, buildid, system,
buildtime, loglink) {
+ let rootbody = table.getElementsByTagName("tbody")[0];
+ let packagegroups = getElementsByName(rootbody, "packagegroup_" +
packagegroupid);
+ if (packagegroups.length == 0) {
+ let tr = document.createElement("tr");
+ tr.name = "packagegroup_" + packagegroupid;
+ tr.className = "packagegroup";
+
+ let packagenamespan = element("span", "packagename");
+ packagenamespan.textContent = packagegroupid;
+ tr.appendChild(td(packagenamespan));
+
+ tr.appendChild(element("td", "builds", "builds"));
+
+ let buildtimes = element("td", "buildtimes", "buildtimes");
+
+ let latestbuildtime = document.createElement("span");
+ buildtimes.appendChild(latestbuildtime);
+
+ tr.appendChild(buildtimes);
+
+ rootbody.appendChild(tr);
+ packagegroups = [rootbody];
+ }
+ let packagegroup = packagegroups[0];
+ let buildstd = getElementsByName(packagegroup, "builds")[0];
+ updateMultientry(buildstd, buildid, function() {
+ let div = document.createElement("div");
+ div.name = buildid;
+ let a = element("a", system);
+ if (loglink) {
+ a.href = URLPREFIX + "build/" +
encodeURIComponent(buildid) + "/log/raw";
+ a.textContent = "Log " + system;
+ } else
+ a.textContent = system;
+ a.title = system + " (built at " + new Date((buildtime*1)*1000)
+ ")";
+ a.target = a.href;
+ div.appendChild(a);
+ return div;
+ });
+ let buildtimestd = getElementsByName(packagegroup, "buildtimes")[0];
+ updateMultientry(buildtimestd, buildid, function() {
+ let span = document.createElement("span");
+ span.name = buildid;
+ span.style.display = 'none';
+ span.textContent = (buildtime | 0)*1000; // ms
+ return span;
+ });
+ hideOlderbuilds(buildstd, buildtimestd);
+ updateLatestbuildtime(buildtimestd);
+}
+
+function cpackagegroupid(datanode) {
+ let s = datanode.job;
+ let i = s.lastIndexOf(".");
+ // Strip off ".i686-linux" etc.
+ return s.substring(0, i);
+}
+
+/** Update filtered tables */
+function refilter(table, filters) {
+ let rootbody = table.getElementsByTagName("tbody")[0];
+ let trs = rootbody.getElementsByTagName("tr");
+ Array.prototype.forEach.call(trs, function (tr) {
+ let visible = true;
+ let tds = tr.getElementsByTagName("td");
+ let i = 0;
+ for (i = 0; i < filters.length; ++i) {
+ let check = filters[i];
+ if (!check(tds[i]))
+ visible = false;
+ }
+ tr.style.display = visible ? "table-row" : "none";
+ });
+}
+
+let yesman = function(td) {
+ return true;
+};
+
+// Selected filters
+
+let latestbuildsfilters = [yesman, yesman, yesman];
+let queuedbuildsfilters = [yesman, yesman, yesman];
+
+/** Given a JSONRESPONSE, makes sure that the queued builds in there are
displayed.
+(Note: the Log link makes little sense since it doesn't work here) */
+function displayQueuedbuilds(jsonResponse) {
+ let latestbuilds = document.getElementById("latestbuilds");
+ let queuedbuildsElement = document.getElementById("queuedbuilds");
+ jsonResponse.forEach(function (datanode) {
+ console.log(datanode);
+ let packagegroupid = cpackagegroupid(datanode);
+ let buildid = datanode.id;
+ let system = datanode.system;
+ let buildtime = datanode.timestamp;
+ removePackagegroupelement(latestbuilds, packagegroupid,
buildid);
+ addPackagegroupelement(queuedbuildsElement, packagegroupid,
buildid, system, buildtime, /*link*/false);
+ });
+ refilter(queuedbuilds, queuedbuildsfilters);
+}
+
+/** Given a JSONRESPONSE, makes sure that the latest builds in there are
displayed. */
+function displayLatestbuilds(jsonResponse) {
+ let latestbuilds = document.getElementById("latestbuilds");
+ let queuedbuildsElement = document.getElementById("queuedbuilds");
+ jsonResponse.forEach(function (datanode) {
+ console.log(datanode);
+ let packagegroupid = cpackagegroupid(datanode);
+ let buildid = datanode.id;
+ let system = datanode.system;
+ let buildtime = datanode.stoptime || datanode.timestamp;
+ removePackagegroupelement(queuedbuildsElement, packagegroupid,
buildid);
+ addPackagegroupelement(latestbuilds, packagegroupid, buildid,
system, buildtime, /*link*/true);
+ });
+ refilter(latestbuilds, latestbuildsfilters);
+}
+
+/** Starts a JSON request on resource URL.
+ If completed successfully, continues with onload(responseJSON).
+ If completed with error, continues with onerror(status, statusText). */
+function startRequest(url, onload, onerror) {
+ let req = new XMLHttpRequest();
+ req.timeout = 10000; // ms
+ req.overrideMimeType("application/json");
+ req.onerror = function(e) {
+ onerror(req.status, req.statusText);
+ };
+ req.onload = function() {
+ if (req.status == 200) {
+ //let response = req.responseJSON;
+ let response = JSON.parse(req.responseText);
+ //console.log(response);
+ onload(response);
+ } else if (req.status == 502) { // Bad gateway.
+ setStatusText("Bad gateway. Trying again...");
+ tryAgain(function() {
+ startRequest(url, onload, onerror);
+ });
+ } else if (req.status == 504) { // Gateway timeout.
+ setStatusText("Gateway timeout. Trying again...");
+ tryAgain(function() {
+ startRequest(url, onload, onerror);
+ });
+ } else {
+ setStatusText(req.status);
+ }
+ };
+ req.ontimeout = function() {
+ setStatusText("Timeout. Trying again...");
+ tryAgain(function() {
+ startRequest(url, onload, onerror);
+ });
+ };
+ req.open("GET", url, /*async */true);
+ //req.responseType = "json";
+ req.send(null);
+ // Access-Control-Allow-Origin: *
+}
+
+// Unused for now
+function startBuildstatusrequest(buildid) {
+ startRequest(URLPREFIX + "build/" + encodeURIComponent(buildid) +
"?nr=1" + APIURLSUFFIX, function(response) {
+ // (same as in latestbuilds, queuedbuilds)
+ // response.id
+ // response.project
+ // response.jobset
+ // response.job
+ // response.timetamp [build creation]
+ // response.stoptime
+ // response.buildoutputs.out.path
+ // response.system
+ // response.nixname
+ // response.buildstatus [0: succeeded, 1: failed, 2: failed
dep, 3: failed outer; 4 cancelled]
+ alert(response);
+ }, function(status, statusText) {
+ });
+}
+
+function createOption(value) {
+ let r = document.createElement("option");
+ r.value = value;
+ r.innerHTML = value;
+ return r;
+}
+
+function isOptionPresent(root, value) {
+ let options = root.getElementsByTagName("option");
+ let i = 0;
+ for (i = 0; i < options.length; ++i) {
+ let option = options[i];
+ if (option.value == value)
+ return true;
+ }
+ return false;
+}
+
+function startBuildlogrequest(buildid) {
+ let w = window.open(URLPREFIX + "build/" + encodeURIComponent(buildid)
+ "/log/raw?nr=1" + APIURLSUFFIX, "buildlog_" + buildid);
+ w.onerror = function() {
+ tryAgain(function() {
+ w.reload();
+ });
+ };
+}
+
+// TODO API params: system (premature optimization)
+// TODO status: pending or done or failed(!).
+
+/** Gets a list of the latest successful builds. */
+function startLatestbuildsrequest() {
+ return startRequest(APIURLPREFIX + "latestbuilds?nr=50" + APIURLSUFFIX,
function(response) {
+ //console.log(response);
+ displayLatestbuilds(response);
+ window.setTimeout(startLatestbuildsrequest, 50000 /* ms */);
+ }, function(status, statusText) {
+ setStatusText("Error " + status + " " + statusText + ". Trying
again...");
+ tryAgain(startLatestbuildsrequest);
+ });
+}
+
+/** Gets a list of the latest queued builds. */
+function startQueuerequest() {
+ return startRequest(APIURLPREFIX + "queue?nr=50" + APIURLSUFFIX,
function(response) {
+ //console.log(response);
+ displayQueuedbuilds(response);
+ window.setTimeout(startQueuerequest, 30000 /* ms */);
+ }, function(status, statusText) {
+ setStatusText("Error " + status + " " + statusText + ". Trying
again...");
+ tryAgain(startQueuerequest);
+ });
+}
+
+/** Updates the filter for column INDEX to CALLBACK, then refilters. */
+function updateFilter(table, filters, index, callback) {
+ filters[index] = callback;
+ refilter(table, filters);
+}
+
+function installPackagenamefilter(table, td, filters, querypackagenames) {
+ let inputs = td.getElementsByTagName("input");
+ Array.prototype.forEach.call(inputs, function(e) {
+ if (querypackagenames.length > 0) {
+ e.value = querypackagenames[0];
+ }
+ e.oninput = function() {
+ updateFilter(table, filters, 0, function(td) {
+ let packagenamespan = getElementsByName(td,
"packagename")[0];
+ return
packagenamespan.textContent.startsWith(e.value);
+ });
+ };
+ });
+}
+
+function checkbox(name) {
+ let r = document.createElement("input");
+ r.type = "checkbox";
+ r.name = name;
+ r.value = name;
+ r.checked = true;
+ return r;
+}
+
+function installBuildfilter(table, td, filters, querybuilds) {
+ let values = systems;
+ let widgets = values.map(function(value) {
+ let input = element("input", value);
+ input.type = "checkbox";
+ input.name = value;
+ input.title = value; // .replace(/-linux$/, "");
+ input.checked = querybuilds.length == 0 ||
querybuilds.indexOf(value) != -1;
+ td.appendChild(input);
+ return input;
+ });
+ function check(td) {
+ let divs = td.getElementsByTagName("div");
+ let j = 0;
+ for (j = 0; j < divs.length; ++j) {
+ let div = divs[j];
+ let a = div.getElementsByTagName("a")[0];
+ let i = 0;
+ for (i = 0; i < widgets.length; ++i) {
+ let widget = widgets[i];
+ if (widget.checked) {
+ let xsystem = a.title.split(" ")[0];
+ if (xsystem == widget.name)
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+ function check1() {
+ return updateFilter(table, filters, 1, check);
+ }
+ widgets.forEach(function(widget) {
+ widget.oninput = check1;
+ });
+}
+
+function installBuildfilter2(tableid, filters) {
+ let table = document.getElementById(tableid);
+ let thead = table.getElementsByTagName("thead")[0];
+ let theadtr = thead.getElementsByTagName("tr")[0];
+ let headertds = theadtr.getElementsByTagName("td");
+ let headerbuildstd = headertds[1];
+ installBuildfilter(table, headerbuildstd, filters,
getQueryValues("systems"));
+}
+
+function startJobsetsrequest() {
+ startRequest(URLPREFIX + "jobsets?nr=100", function(response) {
+ let projectelement = document.getElementById("project");
+ let jobsetelement = document.getElementById("jobset");
+ systems = [];
+
+ let row = response; // pretty sure cuirass has a bug here.
+ let project = row.name;
+ let jobset = row.branch;
+ let xsystems = row.arguments.systems;
+
+ systems = systems.concat(xsystems.filter(function (item) {
+ return systems.indexOf(item) == -1;
+ }));
+ if (!isOptionPresent(projectelement, project))
+ projectelement.appendChild(createOption(project));
+ if (!isOptionPresent(jobsetelement, jobset))
+ jobsetelement.appendChild(createOption(jobset));
+
+ // The global systems are now correct, so install these filters
only now.
+ installBuildfilter2('latestbuilds', latestbuildsfilters);
+ installBuildfilter2('queuedbuilds', queuedbuildsfilters);
+ }, function(status, statusText) {
+ });
+}
+
+function installBuildtimefilter(table, td, filters, querybuildtimes) {
+ let inputs = td.getElementsByTagName("input");
+ Array.prototype.forEach.call(inputs, function(e) {
+ if (querybuildtimes.length > 0)
+ e.value = querybuildtimes[0];
+ e.oninput = function() {
+ updateFilter(table, filters, 2, function(td) {
+ if (!e.value)
+ return true;
+ let value = new Date(e.value);
+ let span = td.getElementsByTagName("span")[0];
+ let xvalue = new Date(span.textContent);
+ if (xvalue >= value)
+ return true;
+ return false;
+ });
+ };
+ });
+}
+
+function installFilters(tableid, filters) {
+ let table = document.getElementById(tableid);
+ let thead = table.getElementsByTagName("thead")[0];
+ let theadtr = thead.getElementsByTagName("tr")[0];
+ let headertds = theadtr.getElementsByTagName("td");
+ let headerpackagenametd = headertds[0];
+ let headerbuildstd = headertds[1];
+ let headerbuildtimestd = headertds[2];
+ installPackagenamefilter(table, headerpackagenametd, filters,
getQueryValues("packagename"));
+ // done when we have jobsets. installBuildfilter(table, headerbuildstd,
filters, getQueryValues("systems"));
+ installBuildtimefilter(table, headerbuildtimestd, filters,
getQueryValues("buildtime"));
+ refilter(table, filters);
+}
+
+/** Call only once! */
+function updateBranchinfo() {
+ let projectelement = document.getElementById("project");
+ let jobsetelement = document.getElementById("jobset");
+ function switchBranch() {
+ let search = "project=" +
encodeURIComponent(projectelement.value) + "&jobset=" +
encodeURIComponent(jobsetelement.value) + "&" + location.search.substr(1);
+ // TODO Recover anchors?
+ location.href = location.href.split("?")[0] + "?" + search;
+ }
+ let projectoptions = [];
+ // The user is always right.
+ if (projectoptions.indexOf(project) == -1)
+ projectoptions.push(project);
+ projectoptions.forEach(function(value) {
+ projectelement.appendChild(createOption(value));
+ });
+ projectelement.value = project;
+ projectelement.onchange = switchBranch;
+ let jobsetoptions = [];
+ // The user is always right.
+ if (jobsetoptions.indexOf(jobset) == -1)
+ jobsetoptions.push(jobset);
+ jobsetoptions.forEach(function(value) {
+ jobsetelement.appendChild(createOption(value));
+ });
+ jobsetelement.value = jobset;
+ jobsetelement.onchange = switchBranch;
+ startJobsetsrequest();
+}
+
+//-->
+</script>
+</head>
+<body onload="updateBranchinfo(); installFilters('latestbuilds',
latestbuildsfilters); installFilters('queuedbuilds', queuedbuildsfilters);
startLatestbuildsrequest(); startQueuerequest();">
+<form>
+<div class="branch">Project: <select id="project" name="project"></select>;
jobset: <select name="jobset" id="jobset"></select></div>
+</form>
+<div class="status">Request status:
+ <span id="status">
+ </span>
+</div>
+<!-- TODO sort alphabetically or by time -->
+<h1>Finished Builds</h1>
+<form>
+<table class="latestbuilds" id="latestbuilds">
+<thead><tr class="filter"><td>Starts with <input type="text"
name="packagenamefilter"></td><td>At least </td><td>At least <input
type="datetime-local" name="buildtimefilter"></td></tr>
+<tr><td>Packagename</td><td>Builds</td><td>Latest build finished
at</td></tr></thead>
+<tbody>
+</tbody>
+</table>
+</form>
+<h1>Queued Builds</h1>
+<form>
+<table class="queuedbuilds" id="queuedbuilds">
+<thead><tr class="filter"><td>Starts with <input type="text"
name="packagenamefilter"></td><td>At least </td><td>At least <input
type="datetime-local" name="buildtimefilter"></td></tr>
+<tr><td>Packagename</td><td>Builds</td><td>Build most recently enqueued
at</td></tr></thead>
+<tbody>
+</tbody>
+</table>
+</form>
+<!--
+<h1>Failed Builds</h1>
+<form>
+<table class="failedbuilds" id="failedbuilds">
+<thead><tr class="filter"><td>Starts with <input type="text"
name="packagenamefilter"></td><td>At least </td><td>At least <input
type="datetime-local" name="buildtimefilter"></td></tr>
+<tr><td>Packagename</td><td>Builds</td><td>Build most recently enqueued
at</td></tr></thead>
+<tbody>
+</tbody>
+</table>
+</form>
+-->
+
+</body>
+</html>