web: add /{tenant}/jobs route

This change adds the 'job:list' job to the scheduler gearman worker
to expose the tenant jobs list.

This change also adds the /{tenant}/jobs.json endpoint to the zuul-web as well
as a /{tenant}/jobs.html web interface and command line client:
  zuul show jobs $tenant

Change-Id: I950cb6a809a360867b2daccded9a8a45ac46359c
diff --git a/zuul/configloader.py b/zuul/configloader.py
index 99f10f6..e034329 100644
--- a/zuul/configloader.py
+++ b/zuul/configloader.py
@@ -517,6 +517,7 @@
         # "job.run.append(...)").
 
         job = model.Job(name)
+        job.description = conf.get('description')
         job.source_context = conf.get('_source_context')
         job.source_line = conf.get('_start_mark').line + 1
 
diff --git a/zuul/model.py b/zuul/model.py
index 7ab413d..e59ccb5 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -858,6 +858,7 @@
             source_line=None,
             inheritance_path=(),
             parent_data=None,
+            description=None,
         )
 
         self.inheritable_attributes = {}
diff --git a/zuul/rpclistener.py b/zuul/rpclistener.py
index 8c8c783..9c9b59c 100644
--- a/zuul/rpclistener.py
+++ b/zuul/rpclistener.py
@@ -58,6 +58,7 @@
         self.worker.registerFunction("zuul:get_job_log_stream_address")
         self.worker.registerFunction("zuul:tenant_list")
         self.worker.registerFunction("zuul:status_get")
+        self.worker.registerFunction("zuul:job_list")
 
     def getFunctions(self):
         functions = {}
@@ -283,3 +284,17 @@
         args = json.loads(job.arguments)
         output = self.sched.formatStatusJSON(args.get("tenant"))
         job.sendWorkComplete(output)
+
+    def handle_job_list(self, job):
+        args = json.loads(job.arguments)
+        tenant = self.sched.abide.tenants.get(args.get("tenant"))
+        output = []
+        for job_name in sorted(tenant.layout.jobs):
+            desc = None
+            for tenant_job in tenant.layout.jobs[job_name]:
+                if tenant_job.description:
+                    desc = tenant_job.description.split('\n')[0]
+                    break
+            output.append({"name": job_name,
+                           "description": desc})
+        job.sendWorkComplete(json.dumps(output))
diff --git a/zuul/web/__init__.py b/zuul/web/__init__.py
index 766a21d..db14343 100755
--- a/zuul/web/__init__.py
+++ b/zuul/web/__init__.py
@@ -162,6 +162,7 @@
         self.controllers = {
             'tenant_list': self.tenant_list,
             'status_get': self.status_get,
+            'job_list': self.job_list,
         }
 
     def tenant_list(self, request):
@@ -182,6 +183,11 @@
         resp.last_modified = self.cache_time[tenant]
         return resp
 
+    def job_list(self, request):
+        tenant = request.match_info["tenant"]
+        job = self.rpc.submitJob('zuul:job_list', {'tenant': tenant})
+        return web.json_response(json.loads(job.data[0]))
+
     async def processRequest(self, request, action):
         try:
             resp = self.controllers[action](request)
@@ -224,12 +230,17 @@
     async def _handleStatusRequest(self, request):
         return await self.gearman_handler.processRequest(request, 'status_get')
 
+    async def _handleJobsRequest(self, request):
+        return await self.gearman_handler.processRequest(request, 'job_list')
+
     async def _handleStaticRequest(self, request):
         fp = None
         if request.path.endswith("tenants.html") or request.path.endswith("/"):
             fp = os.path.join(STATIC_DIR, "index.html")
         elif request.path.endswith("status.html"):
             fp = os.path.join(STATIC_DIR, "status.html")
+        elif request.path.endswith("jobs.html"):
+            fp = os.path.join(STATIC_DIR, "jobs.html")
         headers = {}
         if self.static_cache_expiry:
             headers['Cache-Control'] = "public, max-age=%d" % \
@@ -251,7 +262,9 @@
             ('GET', '/console-stream', self._handleWebsocket),
             ('GET', '/tenants.json', self._handleTenantsRequest),
             ('GET', '/{tenant}/status.json', self._handleStatusRequest),
+            ('GET', '/{tenant}/jobs.json', self._handleJobsRequest),
             ('GET', '/{tenant}/status.html', self._handleStaticRequest),
+            ('GET', '/{tenant}/jobs.html', self._handleStaticRequest),
             ('GET', '/tenants.html', self._handleStaticRequest),
             ('GET', '/', self._handleStaticRequest),
         ]
diff --git a/zuul/web/static/javascripts/zuul.angular.js b/zuul/web/static/javascripts/zuul.angular.js
index 3152fc0..27e1432 100644
--- a/zuul/web/static/javascripts/zuul.angular.js
+++ b/zuul/web/static/javascripts/zuul.angular.js
@@ -30,3 +30,16 @@
     }
     $scope.tenants_fetch();
 });
+
+angular.module('zuulJobs', []).controller(
+    'mainController', function($scope, $http)
+{
+    $scope.jobs = undefined;
+    $scope.jobs_fetch = function() {
+        $http.get("jobs.json")
+            .then(function success(result) {
+                $scope.jobs = result.data;
+            });
+    }
+    $scope.jobs_fetch();
+});
diff --git a/zuul/web/static/jobs.html b/zuul/web/static/jobs.html
new file mode 100644
index 0000000..6946723
--- /dev/null
+++ b/zuul/web/static/jobs.html
@@ -0,0 +1,55 @@
+<!--
+Copyright 2017 Red Hat
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may
+not use this file except in compliance with the License. You may obtain
+a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+License for the specific language governing permissions and limitations
+under the License.
+-->
+<!DOCTYPE html>
+<html>
+<head>
+    <title>Zuul Builds</title>
+    <link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
+    <link rel="stylesheet" href="../static/styles/zuul.css" />
+    <script src="/static/js/jquery.min.js"></script>
+    <script src="/static/js/angular.min.js"></script>
+    <script src="../static/javascripts/zuul.angular.js"></script>
+</head>
+<body ng-app="zuulJobs" ng-controller="mainController"><div class="container-fluid">
+  <nav class="navbar navbar-default">
+  <div class="container-fluid">
+    <div class="navbar-header">
+      <a class="navbar-brand" href="../" target="_self">Zuul Dashboard</a>
+    </div>
+    <ul class="nav navbar-nav">
+      <li><a href="status.html" target="_self">Status</a></li>
+      <li class="active"><a href="jobs.html" target="_self">Jobs</a></li>
+      <li><a href="builds.html" target="_self">Builds</a></li>
+    </ul>
+  </div>
+  </nav>
+  <table class="table table-hover table-condensed">
+    <thead>
+      <tr>
+        <th>Name</th>
+        <th>Description</th>
+        <th>Last builds</th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr ng-repeat="job in jobs">
+        <td>{{ job.name }}</td>
+        <td>{{ job.description }}</td>
+        <td><a href="builds.html?job_name={{ job.name }}">builds</a></td>
+      </tr>
+    </tbody>
+  </table>
+</div></body></html>