Merge "Allow test_playbook to run long"
diff --git a/doc/source/admin/components.rst b/doc/source/admin/components.rst
index ba14752..84ebc10 100644
--- a/doc/source/admin/components.rst
+++ b/doc/source/admin/components.rst
@@ -660,6 +660,16 @@
       Base URL on which the websocket service is exposed, if different
       than the base URL of the web app.
 
+   .. attr:: stats_url
+
+      Base URL from which statistics emitted via statsd can be queried.
+
+   .. attr:: stats_type
+      :default: graphite
+
+      Type of server hosting the statistics information. Currently only
+      'graphite' is supported by the dashboard.
+
    .. attr:: static_cache_expiry
       :default: 3600
 
diff --git a/etc/status/public_html/jquery.zuul.js b/etc/status/public_html/jquery.zuul.js
index 50dbed5..ac8a302 100644
--- a/etc/status/public_html/jquery.zuul.js
+++ b/etc/status/public_html/jquery.zuul.js
@@ -49,7 +49,7 @@
         options = $.extend({
             'enabled': true,
             'graphite_url': '',
-            'source': 'status.json',
+            'source': 'status',
             'msg_id': '#zuul_msg',
             'pipelines_id': '#zuul_pipelines',
             'queue_events_num': '#zuul_queue_events_num',
diff --git a/etc/status/public_html/zuul.app.js b/etc/status/public_html/zuul.app.js
index bf90a4d..6e35eb3 100644
--- a/etc/status/public_html/zuul.app.js
+++ b/etc/status/public_html/zuul.app.js
@@ -55,7 +55,7 @@
     var demo = location.search.match(/[?&]demo=([^?&]*)/),
         source_url = location.search.match(/[?&]source_url=([^?&]*)/),
         source = demo ? './status-' + (demo[1] || 'basic') + '.json-sample' :
-            'status.json';
+            'status';
     source = source_url ? source_url[1] : source;
 
     var zuul = $.zuul({
diff --git a/requirements.txt b/requirements.txt
index 7057c5a..47c0f5e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,7 +5,6 @@
 git+https://github.com/sigmavirus24/github3.py.git@develop#egg=Github3.py
 PyYAML>=3.1.0
 Paste
-WebOb>=1.2.3
 paramiko>=2.0.1
 GitPython>=2.1.8
 python-daemon>=2.0.4,<2.1.0
diff --git a/tests/unit/test_web.py b/tests/unit/test_web.py
index b5ebe9f..602209f 100644
--- a/tests/unit/test_web.py
+++ b/tests/unit/test_web.py
@@ -22,20 +22,30 @@
 import urllib
 import time
 import socket
-from unittest import skip
-
-import webob
 
 import zuul.web
 
 from tests.base import ZuulTestCase, FIXTURE_DIR
 
 
-class TestWeb(ZuulTestCase):
+class FakeConfig(object):
+
+    def __init__(self, config):
+        self.config = config or {}
+
+    def has_option(self, section, option):
+        return option in self.config.get(section, {})
+
+    def get(self, section, option):
+        return self.config.get(section, {}).get(option)
+
+
+class BaseTestWeb(ZuulTestCase):
     tenant_config_file = 'config/single-tenant/main.yaml'
+    config_ini_data = {}
 
     def setUp(self):
-        super(TestWeb, self).setUp()
+        super(BaseTestWeb, self).setUp()
         self.executor_server.hold_jobs_in_build = True
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         A.addApproval('Code-Review', 2)
@@ -45,10 +55,13 @@
         self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
 
+        self.zuul_ini_config = FakeConfig(self.config_ini_data)
         # Start the web server
         self.web = zuul.web.ZuulWeb(
             listen_address='127.0.0.1', listen_port=0,
-            gear_server='127.0.0.1', gear_port=self.gearman_server.port)
+            gear_server='127.0.0.1', gear_port=self.gearman_server.port,
+            info=zuul.model.WebInfo.fromConfig(self.zuul_ini_config)
+        )
         loop = asyncio.new_event_loop()
         loop.set_debug(True)
         ws_thread = threading.Thread(target=self.web.run, args=(loop,))
@@ -75,7 +88,10 @@
         self.executor_server.hold_jobs_in_build = False
         self.executor_server.release()
         self.waitUntilSettled()
-        super(TestWeb, self).tearDown()
+        super(BaseTestWeb, self).tearDown()
+
+
+class TestWeb(BaseTestWeb):
 
     def test_web_status(self):
         "Test that we can retrieve JSON status info"
@@ -89,7 +105,7 @@
         self.waitUntilSettled()
 
         req = urllib.request.Request(
-            "http://localhost:%s/tenant-one/status.json" % self.port)
+            "http://localhost:%s/tenant-one/status" % self.port)
         f = urllib.request.urlopen(req)
         headers = f.info()
         self.assertIn('Content-Length', headers)
@@ -184,7 +200,6 @@
             "http://localhost:%s/status/foo" % self.port)
         self.assertRaises(urllib.error.HTTPError, urllib.request.urlopen, req)
 
-    @skip("This is not supported by zuul-web")
     def test_web_find_change(self):
         # can we filter by change id
         req = urllib.request.Request(
@@ -213,24 +228,84 @@
         f = urllib.request.urlopen(req)
         self.assertEqual(f.read(), public_pem)
 
-    @skip("This may not apply to zuul-web")
-    def test_web_custom_handler(self):
-        def custom_handler(path, tenant_name, request):
-            return webob.Response(body='ok')
-
-        self.webapp.register_path('/custom', custom_handler)
-        req = urllib.request.Request(
-            "http://localhost:%s/custom" % self.port)
-        f = urllib.request.urlopen(req)
-        self.assertEqual(b'ok', f.read())
-
-        self.webapp.unregister_path('/custom')
-        self.assertRaises(urllib.error.HTTPError, urllib.request.urlopen, req)
-
-    @skip("This returns a 500")
     def test_web_404_on_unknown_tenant(self):
         req = urllib.request.Request(
-            "http://localhost:{}/non-tenant/status.json".format(self.port))
+            "http://localhost:{}/non-tenant/status".format(self.port))
         e = self.assertRaises(
             urllib.error.HTTPError, urllib.request.urlopen, req)
         self.assertEqual(404, e.code)
+
+
+class TestInfo(BaseTestWeb):
+
+    def setUp(self):
+        super(TestInfo, self).setUp()
+        web_config = self.config_ini_data.get('web', {})
+        self.websocket_url = web_config.get('websocket_url')
+        self.stats_url = web_config.get('stats_url')
+        statsd_config = self.config_ini_data.get('statsd', {})
+        self.stats_prefix = statsd_config.get('prefix')
+
+    def test_info(self):
+        req = urllib.request.Request(
+            "http://localhost:%s/info" % self.port)
+        f = urllib.request.urlopen(req)
+        info = json.loads(f.read().decode('utf8'))
+        self.assertEqual(
+            info, {
+                "info": {
+                    "endpoint": "http://localhost:%s" % self.port,
+                    "capabilities": {
+                        "job_history": False
+                    },
+                    "stats": {
+                        "url": self.stats_url,
+                        "prefix": self.stats_prefix,
+                        "type": "graphite",
+                    },
+                    "websocket_url": self.websocket_url,
+                }
+            })
+
+    def test_tenant_info(self):
+        req = urllib.request.Request(
+            "http://localhost:%s/tenant-one/info" % self.port)
+        f = urllib.request.urlopen(req)
+        info = json.loads(f.read().decode('utf8'))
+        self.assertEqual(
+            info, {
+                "info": {
+                    "endpoint": "http://localhost:%s" % self.port,
+                    "tenant": "tenant-one",
+                    "capabilities": {
+                        "job_history": False
+                    },
+                    "stats": {
+                        "url": self.stats_url,
+                        "prefix": self.stats_prefix,
+                        "type": "graphite",
+                    },
+                    "websocket_url": self.websocket_url,
+                }
+            })
+
+
+class TestWebSocketInfo(TestInfo):
+
+    config_ini_data = {
+        'web': {
+            'websocket_url': 'wss://ws.example.com'
+        }
+    }
+
+
+class TestGraphiteUrl(TestInfo):
+
+    config_ini_data = {
+        'statsd': {
+            'prefix': 'example'
+        },
+        'web': {
+            'stats_url': 'https://graphite.example.com',
+        }
+    }
diff --git a/tools/zuul-changes.py b/tools/zuul-changes.py
index d258354..cdedf51 100755
--- a/tools/zuul-changes.py
+++ b/tools/zuul-changes.py
@@ -24,7 +24,7 @@
 parser.add_argument('pipeline', help='The name of the Zuul pipeline')
 options = parser.parse_args()
 
-data = urllib2.urlopen('%s/status.json' % options.url).read()
+data = urllib2.urlopen('%s/status' % options.url).read()
 data = json.loads(data)
 
 for pipeline in data['pipelines']:
diff --git a/zuul/cmd/web.py b/zuul/cmd/web.py
index abdb1cb..8b0e3ee 100755
--- a/zuul/cmd/web.py
+++ b/zuul/cmd/web.py
@@ -20,6 +20,7 @@
 import threading
 
 import zuul.cmd
+import zuul.model
 import zuul.web
 
 from zuul.lib.config import get_default
@@ -33,8 +34,11 @@
         self.web.stop()
 
     def _run(self):
+        info = zuul.model.WebInfo.fromConfig(self.config)
+
         params = dict()
 
+        params['info'] = info
         params['listen_address'] = get_default(self.config,
                                                'web', 'listen_address',
                                                '127.0.0.1')
diff --git a/zuul/configloader.py b/zuul/configloader.py
index 270b91c..df6336d 100644
--- a/zuul/configloader.py
+++ b/zuul/configloader.py
@@ -1754,20 +1754,22 @@
                             config_path)
         return config_path
 
-    def loadConfig(self, config_path, project_key_dir):
-        abide = model.Abide()
-
+    def readConfig(self, config_path):
         config_path = self.expandConfigPath(config_path)
         with open(config_path) as config_file:
             self.log.info("Loading configuration from %s" % (config_path,))
             data = yaml.safe_load(config_file)
-        config = model.UnparsedAbideConfig()
-        config.extend(data)
         base = os.path.dirname(os.path.realpath(config_path))
+        unparsed_abide = model.UnparsedAbideConfig(base)
+        unparsed_abide.extend(data)
+        return unparsed_abide
 
-        for conf_tenant in config.tenants:
+    def loadConfig(self, unparsed_abide, project_key_dir):
+        abide = model.Abide()
+        for conf_tenant in unparsed_abide.tenants:
             # When performing a full reload, do not use cached data.
-            tenant = self.tenant_parser.fromYaml(base, project_key_dir,
+            tenant = self.tenant_parser.fromYaml(unparsed_abide.base,
+                                                 project_key_dir,
                                                  conf_tenant, old_tenant=None)
             abide.tenants[tenant.name] = tenant
         return abide
diff --git a/zuul/connection/__init__.py b/zuul/connection/__init__.py
index 86f14d6..1c62f4d 100644
--- a/zuul/connection/__init__.py
+++ b/zuul/connection/__init__.py
@@ -75,11 +75,14 @@
         still in use.  Anything in our cache that isn't in the supplied
         list should be safe to remove from the cache."""
 
-    def getWebHandlers(self, zuul_web):
+    def getWebHandlers(self, zuul_web, info):
         """Return a list of web handlers to register with zuul-web.
 
         :param zuul.web.ZuulWeb zuul_web:
             Zuul Web instance.
+        :param zuul.model.WebInfo info:
+            The WebInfo object for the Zuul Web instance. Can be used by
+            plugins to toggle API capabilities.
         :returns: List of `zuul.web.handler.BaseWebHandler` instances.
         """
         return []
diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py
index 6dfcdd3..772ba9b 100644
--- a/zuul/driver/github/githubconnection.py
+++ b/zuul/driver/github/githubconnection.py
@@ -1141,7 +1141,7 @@
 
         return statuses
 
-    def getWebHandlers(self, zuul_web):
+    def getWebHandlers(self, zuul_web, info):
         return [GithubWebhookHandler(self, zuul_web, 'POST', 'payload')]
 
     def validateWebConfig(self, config, connections):
diff --git a/zuul/driver/sql/sqlconnection.py b/zuul/driver/sql/sqlconnection.py
index 501a2c5..e931301 100644
--- a/zuul/driver/sql/sqlconnection.py
+++ b/zuul/driver/sql/sqlconnection.py
@@ -125,9 +125,10 @@
 
         return zuul_buildset_table, zuul_build_table
 
-    def getWebHandlers(self, zuul_web):
+    def getWebHandlers(self, zuul_web, info):
+        info.capabilities.job_history = True
         return [
-            SqlWebHandler(self, zuul_web, 'GET', '/{tenant}/builds.json'),
+            SqlWebHandler(self, zuul_web, 'GET', '/{tenant}/builds'),
             StaticHandler(zuul_web, '/{tenant}/builds.html'),
         ]
 
diff --git a/zuul/model.py b/zuul/model.py
index 763eb66..44e8d06 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -24,6 +24,7 @@
 import textwrap
 
 from zuul import change_matcher
+from zuul.lib.config import get_default
 
 MERGER_MERGE = 1          # "git merge"
 MERGER_MERGE_RESOLVE = 2  # "git merge -s resolve"
@@ -2446,8 +2447,10 @@
     An Abide is a collection of tenants.
     """
 
-    def __init__(self):
+    def __init__(self, base=None):
         self.tenants = []
+        self.known_tenants = set()
+        self.base = base
 
     def extend(self, conf):
         if isinstance(conf, UnparsedAbideConfig):
@@ -2465,6 +2468,8 @@
             key, value = list(item.items())[0]
             if key == 'tenant':
                 self.tenants.append(value)
+                if 'name' in value:
+                    self.known_tenants.add(value['name'])
             else:
                 raise ConfigItemUnknownError()
 
@@ -3178,3 +3183,80 @@
         td = self._getTD(build)
         td.add(elapsed, result)
         td.save()
+
+
+class Capabilities(object):
+    """The set of capabilities this Zuul installation has.
+
+    Some plugins add elements to the external API. In order to
+    facilitate consumers knowing if functionality is available
+    or not, keep track of distinct capability flags.
+    """
+    def __init__(self, job_history=False):
+        self.job_history = job_history
+
+    def __repr__(self):
+        return '<Capabilities 0x%x %s>' % (id(self), self._renderFlags())
+
+    def _renderFlags(self):
+        d = self.toDict()
+        return " ".join(['{k}={v}'.format(k=k, v=v) for (k, v) in d.items()])
+
+    def copy(self):
+        return Capabilities(**self.toDict())
+
+    def toDict(self):
+        d = dict()
+        d['job_history'] = self.job_history
+        return d
+
+
+class WebInfo(object):
+    """Information about the system needed by zuul-web /info."""
+
+    def __init__(self, websocket_url=None, endpoint=None,
+                 capabilities=None, stats_url=None,
+                 stats_prefix=None, stats_type=None):
+        self.capabilities = capabilities or Capabilities()
+        self.websocket_url = websocket_url
+        self.stats_url = stats_url
+        self.stats_prefix = stats_prefix
+        self.stats_type = stats_type
+        self.endpoint = endpoint
+        self.tenant = None
+
+    def __repr__(self):
+        return '<WebInfo 0x%x capabilities=%s>' % (
+            id(self), str(self.capabilities))
+
+    def copy(self):
+        return WebInfo(
+            websocket_url=self.websocket_url,
+            endpoint=self.endpoint,
+            stats_url=self.stats_url,
+            stats_prefix=self.stats_prefix,
+            stats_type=self.stats_type,
+            capabilities=self.capabilities.copy())
+
+    @staticmethod
+    def fromConfig(config):
+        return WebInfo(
+            websocket_url=get_default(config, 'web', 'websocket_url', None),
+            stats_url=get_default(config, 'web', 'stats_url', None),
+            stats_prefix=get_default(config, 'statsd', 'prefix'),
+            stats_type=get_default(config, 'web', 'stats_type', 'graphite'),
+        )
+
+    def toDict(self):
+        d = dict()
+        d['websocket_url'] = self.websocket_url
+        stats = dict()
+        stats['url'] = self.stats_url
+        stats['prefix'] = self.stats_prefix
+        stats['type'] = self.stats_type
+        d['stats'] = stats
+        d['endpoint'] = self.endpoint
+        d['capabilities'] = self.capabilities.toDict()
+        if self.tenant:
+            d['tenant'] = self.tenant
+        return d
diff --git a/zuul/scheduler.py b/zuul/scheduler.py
index 083cb12..606cd04 100644
--- a/zuul/scheduler.py
+++ b/zuul/scheduler.py
@@ -246,6 +246,7 @@
         self.result_event_queue = queue.Queue()
         self.management_event_queue = zuul.lib.queue.MergedQueue()
         self.abide = model.Abide()
+        self.unparsed_abide = model.UnparsedAbideConfig()
 
         if not testonly:
             time_dir = self._get_time_database_dir()
@@ -550,8 +551,10 @@
             self.log.info("Full reconfiguration beginning")
             loader = configloader.ConfigLoader(
                 self.connections, self, self.merger)
+            self.unparsed_abide = loader.readConfig(
+                self.config.get('scheduler', 'tenant_config'))
             abide = loader.loadConfig(
-                self.config.get('scheduler', 'tenant_config'),
+                self.unparsed_abide,
                 self._get_project_key_dir())
             for tenant in abide.tenants.values():
                 self._reconfigureTenant(tenant)
@@ -1149,6 +1152,8 @@
         data['pipelines'] = pipelines
         tenant = self.abide.tenants.get(tenant_name)
         if not tenant:
+            if tenant_name not in self.unparsed_abide.known_tenants:
+                return json.dumps({"message": "Unknown tenant"})
             self.log.warning("Tenant %s isn't loaded" % tenant_name)
             return json.dumps(
                 {"message": "Tenant %s isn't ready" % tenant_name})
diff --git a/zuul/web/__init__.py b/zuul/web/__init__.py
index e962738..7a1af30 100755
--- a/zuul/web/__init__.py
+++ b/zuul/web/__init__.py
@@ -16,6 +16,7 @@
 
 
 import asyncio
+import copy
 import json
 import logging
 import os
@@ -25,6 +26,7 @@
 import aiohttp
 from aiohttp import web
 
+import zuul.model
 import zuul.rpcclient
 from zuul.web.handler import StaticHandler
 
@@ -158,41 +160,47 @@
             'key_get': self.key_get,
         }
 
-    async def tenant_list(self, request):
+    async def tenant_list(self, request, result_filter=None):
         job = self.rpc.submitJob('zuul:tenant_list', {})
         return web.json_response(json.loads(job.data[0]))
 
-    async def status_get(self, request):
+    async def status_get(self, request, result_filter=None):
         tenant = request.match_info["tenant"]
         if tenant not in self.cache or \
            (time.time() - self.cache_time[tenant]) > self.cache_expiry:
             job = self.rpc.submitJob('zuul:status_get', {'tenant': tenant})
             self.cache[tenant] = json.loads(job.data[0])
             self.cache_time[tenant] = time.time()
-        resp = web.json_response(self.cache[tenant])
+        payload = self.cache[tenant]
+        if payload.get('message') == 'Unknown tenant':
+            return web.HTTPNotFound()
+        if result_filter:
+            payload = result_filter.filterPayload(payload)
+        resp = web.json_response(payload)
         resp.headers['Access-Control-Allow-Origin'] = '*'
         resp.headers["Cache-Control"] = "public, max-age=%d" % \
                                         self.cache_expiry
         resp.last_modified = self.cache_time[tenant]
         return resp
 
-    async def job_list(self, request):
+    async def job_list(self, request, result_filter=None):
         tenant = request.match_info["tenant"]
         job = self.rpc.submitJob('zuul:job_list', {'tenant': tenant})
         resp = web.json_response(json.loads(job.data[0]))
         resp.headers['Access-Control-Allow-Origin'] = '*'
         return resp
 
-    async def key_get(self, request):
+    async def key_get(self, request, result_filter=None):
         tenant = request.match_info["tenant"]
         project = request.match_info["project"]
         job = self.rpc.submitJob('zuul:key_get', {'tenant': tenant,
                                                   'project': project})
         return web.Response(body=job.data[0])
 
-    async def processRequest(self, request, action):
+    async def processRequest(self, request, action, result_filter=None):
+        resp = None
         try:
-            resp = await self.controllers[action](request)
+            resp = await self.controllers[action](request, result_filter)
         except asyncio.CancelledError:
             self.log.debug("request handling cancelled")
         except Exception as e:
@@ -202,6 +210,24 @@
         return resp
 
 
+class ChangeFilter(object):
+    def __init__(self, desired):
+        self.desired = desired
+
+    def filterPayload(self, payload):
+        status = []
+        for pipeline in payload['pipelines']:
+            for change_queue in pipeline['change_queues']:
+                for head in change_queue['heads']:
+                    for change in head:
+                        if self.wantChange(change):
+                            status.append(copy.deepcopy(change))
+        return status
+
+    def wantChange(self, change):
+        return change['id'] == self.desired
+
+
 class ZuulWeb(object):
 
     log = logging.getLogger("zuul.web.ZuulWeb")
@@ -210,13 +236,16 @@
                  gear_server, gear_port,
                  ssl_key=None, ssl_cert=None, ssl_ca=None,
                  static_cache_expiry=3600,
-                 connections=None):
+                 connections=None,
+                 info=None):
+        self.start_time = time.time()
         self.listen_address = listen_address
         self.listen_port = listen_port
         self.event_loop = None
         self.term = None
         self.server = None
         self.static_cache_expiry = static_cache_expiry
+        self.info = info
         # instanciate handlers
         self.rpc = zuul.rpcclient.RPCClient(gear_server, gear_port,
                                             ssl_key, ssl_cert, ssl_ca)
@@ -225,12 +254,37 @@
         self._plugin_routes = []  # type: List[zuul.web.handler.BaseWebHandler]
         connections = connections or []
         for connection in connections:
-            self._plugin_routes.extend(connection.getWebHandlers(self))
+            self._plugin_routes.extend(
+                connection.getWebHandlers(self, self.info))
 
     async def _handleWebsocket(self, request):
         return await self.log_streaming_handler.processRequest(
             request)
 
+    async def _handleRootInfo(self, request):
+        info = self.info.copy()
+        info.endpoint = str(request.url.parent)
+        return self._handleInfo(info)
+
+    def _handleTenantInfo(self, request):
+        info = self.info.copy()
+        info.tenant = request.match_info["tenant"]
+        # yarl.URL.parent on a root url returns the root url, so this is
+        # both safe and accurate for white-labeled tenants like OpenStack,
+        # zuul-web running on / and zuul-web running on a sub-url like
+        # softwarefactory-project.io
+        info.endpoint = str(request.url.parent.parent.parent)
+        return self._handleInfo(info)
+
+    def _handleInfo(self, info):
+        resp = web.json_response({'info': info.toDict()}, status=200)
+        resp.headers['Access-Control-Allow-Origin'] = '*'
+        if self.static_cache_expiry:
+            resp.headers['Cache-Control'] = "public, max-age=%d" % \
+                self.static_cache_expiry
+        resp.last_modified = self.start_time
+        return resp
+
     async def _handleTenantsRequest(self, request):
         return await self.gearman_handler.processRequest(request,
                                                          'tenant_list')
@@ -238,6 +292,11 @@
     async def _handleStatusRequest(self, request):
         return await self.gearman_handler.processRequest(request, 'status_get')
 
+    async def _handleStatusChangeRequest(self, request):
+        change = request.match_info["change"]
+        return await self.gearman_handler.processRequest(
+            request, 'status_get', ChangeFilter(change))
+
     async def _handleJobsRequest(self, request):
         return await self.gearman_handler.processRequest(request, 'job_list')
 
@@ -256,9 +315,13 @@
             is run within a separate (non-main) thread.
         """
         routes = [
-            ('GET', '/tenants.json', self._handleTenantsRequest),
-            ('GET', '/{tenant}/status.json', self._handleStatusRequest),
-            ('GET', '/{tenant}/jobs.json', self._handleJobsRequest),
+            ('GET', '/info', self._handleRootInfo),
+            ('GET', '/{tenant}/info', self._handleTenantInfo),
+            ('GET', '/tenants', self._handleTenantsRequest),
+            ('GET', '/{tenant}/status', self._handleStatusRequest),
+            ('GET', '/{tenant}/jobs', self._handleJobsRequest),
+            ('GET', '/{tenant}/status/change/{change}',
+             self._handleStatusChangeRequest),
             ('GET', '/{tenant}/console-stream', self._handleWebsocket),
             ('GET', '/{tenant}/{project:.*}.pub', self._handleKeyRequest),
         ]
diff --git a/zuul/web/static/javascripts/jquery.zuul.js b/zuul/web/static/javascripts/jquery.zuul.js
index 7e6788b..7da81dc 100644
--- a/zuul/web/static/javascripts/jquery.zuul.js
+++ b/zuul/web/static/javascripts/jquery.zuul.js
@@ -49,7 +49,7 @@
         options = $.extend({
             'enabled': true,
             'graphite_url': '',
-            'source': 'status.json',
+            'source': 'status',
             'msg_id': '#zuul_msg',
             'pipelines_id': '#zuul_pipelines',
             'queue_events_num': '#zuul_queue_events_num',
diff --git a/zuul/web/static/javascripts/zuul.angular.js b/zuul/web/static/javascripts/zuul.angular.js
index 87cbbdd..49f2518 100644
--- a/zuul/web/static/javascripts/zuul.angular.js
+++ b/zuul/web/static/javascripts/zuul.angular.js
@@ -23,7 +23,7 @@
 {
     $scope.tenants = undefined;
     $scope.tenants_fetch = function() {
-        $http.get("tenants.json")
+        $http.get("tenants")
             .then(function success(result) {
                 $scope.tenants = result.data;
             });
@@ -36,7 +36,7 @@
 {
     $scope.jobs = undefined;
     $scope.jobs_fetch = function() {
-        $http.get("jobs.json")
+        $http.get("jobs")
             .then(function success(result) {
                 $scope.jobs = result.data;
             });
@@ -78,7 +78,7 @@
         if ($scope.job_name) {query_string += "&job_name="+$scope.job_name;}
         if ($scope.project) {query_string += "&project="+$scope.project;}
         if (query_string != "") {query_string = "?" + query_string.substr(1);}
-        $http.get("builds.json" + query_string)
+        $http.get("builds" + query_string)
             .then(function success(result) {
                 for (build_pos = 0;
                      build_pos < result.data.length;
diff --git a/zuul/web/static/javascripts/zuul.app.js b/zuul/web/static/javascripts/zuul.app.js
index bf90a4d..6e35eb3 100644
--- a/zuul/web/static/javascripts/zuul.app.js
+++ b/zuul/web/static/javascripts/zuul.app.js
@@ -55,7 +55,7 @@
     var demo = location.search.match(/[?&]demo=([^?&]*)/),
         source_url = location.search.match(/[?&]source_url=([^?&]*)/),
         source = demo ? './status-' + (demo[1] || 'basic') + '.json-sample' :
-            'status.json';
+            'status';
     source = source_url ? source_url[1] : source;
 
     var zuul = $.zuul({