You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pulsar.apache.org by si...@apache.org on 2018/09/10 20:31:21 UTC

[incubator-pulsar] branch master updated: [docker] introduce a pulsar standalone image (#2545)

This is an automated email from the ASF dual-hosted git repository.

sijie pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-pulsar.git


The following commit(s) were added to refs/heads/master by this push:
     new 2812fef  [docker] introduce a pulsar standalone image (#2545)
2812fef is described below

commit 2812fefb72aa8f7277a8a97d8842b0ad2dc6719b
Author: Sijie Guo <gu...@gmail.com>
AuthorDate: Mon Sep 10 13:31:19 2018 -0700

    [docker] introduce a pulsar standalone image (#2545)
    
    ## Motivation
    
    `pulsar` and `pulsar-all` are designed for running pulsar components on production.
    although it can be used for running standalone, people still need to run a separate docker container for pulsar dashboard.
    
     ## Changes
    
    introduce a `pulsar-standalone` image to package everything into one image. so people can run launch a pulsar standalone in one line command
    in docker, including dashboard.
---
 docker/pom.xml                                     |   1 +
 docker/pulsar-standalone/Dockerfile                |  55 ++++
 docker/pulsar-standalone/conf/nginx-app.conf       |  37 +++
 docker/pulsar-standalone/conf/postgresql.conf      |  38 +++
 docker/pulsar-standalone/conf/supervisor-app.conf  |  34 ++
 docker/pulsar-standalone/conf/uwsgi.ini            |  45 +++
 docker/pulsar-standalone/conf/uwsgi_params         |  16 +
 docker/pulsar-standalone/django/collector.py       | 345 +++++++++++++++++++++
 docker/pulsar-standalone/django/collector.sh       |  23 ++
 .../pulsar-standalone/django/dashboard/__init__.py |  19 ++
 .../pulsar-standalone/django/dashboard/settings.py | 181 +++++++++++
 docker/pulsar-standalone/django/dashboard/urls.py  |  45 +++
 docker/pulsar-standalone/django/dashboard/wsgi.py  |  35 +++
 docker/pulsar-standalone/django/manage.py          |  41 +++
 docker/pulsar-standalone/django/stats/__init__.py  |  19 ++
 docker/pulsar-standalone/django/stats/admin.py     |  34 ++
 docker/pulsar-standalone/django/stats/apps.py      |  26 ++
 .../django/stats/migrations/0001_initial.py        | 221 +++++++++++++
 .../django/stats/migrations/__init__.py            |  20 ++
 docker/pulsar-standalone/django/stats/models.py    | 200 ++++++++++++
 .../django/stats/templates/stats/base.html         |  98 ++++++
 .../django/stats/templates/stats/broker.html       |  71 +++++
 .../django/stats/templates/stats/brokers.html      |  90 ++++++
 .../django/stats/templates/stats/clusters.html     | 106 +++++++
 .../django/stats/templates/stats/home.html         |  74 +++++
 .../django/stats/templates/stats/namespace.html    |  96 ++++++
 .../django/stats/templates/stats/property.html     |  72 +++++
 .../django/stats/templates/stats/topic.html        | 197 ++++++++++++
 .../django/stats/templates/stats/topics.html       |  91 ++++++
 .../django/stats/templatetags/__init__.py          |  19 ++
 .../django/stats/templatetags/stats_extras.py      |  64 ++++
 .../django/stats/templatetags/table.py             |  91 ++++++
 docker/pulsar-standalone/django/stats/tests.py     |  22 ++
 docker/pulsar-standalone/django/stats/urls.py      |  37 +++
 docker/pulsar-standalone/django/stats/views.py     | 283 +++++++++++++++++
 docker/pulsar-standalone/pom.xml                   |  81 +++++
 site2/docs/getting-started-standalone.md           |  27 +-
 .../getting-started-standalone.md                  |  14 +-
 38 files changed, 2957 insertions(+), 11 deletions(-)

diff --git a/docker/pom.xml b/docker/pom.xml
index 675656a..bdc99f7 100644
--- a/docker/pom.xml
+++ b/docker/pom.xml
@@ -38,5 +38,6 @@
     <module>pulsar</module>
     <module>grafana</module>
     <module>pulsar-all</module>
+    <module>pulsar-standalone</module>
   </modules>
 </project>
diff --git a/docker/pulsar-standalone/Dockerfile b/docker/pulsar-standalone/Dockerfile
new file mode 100644
index 0000000..869b4c0
--- /dev/null
+++ b/docker/pulsar-standalone/Dockerfile
@@ -0,0 +1,55 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+FROM apachepulsar/pulsar-all:latest
+
+RUN apt-get update
+RUN apt-get -y install postgresql sudo nginx supervisor
+
+# Python dependencies
+RUN pip install uwsgi 'Django<2.0' psycopg2 pytz requests
+
+# Postgres configuration
+COPY conf/postgresql.conf /etc/postgresql/9.6/main/
+
+# Configure nginx and supervisor
+RUN echo "daemon off;" >> /etc/nginx/nginx.conf
+COPY conf/nginx-app.conf /etc/nginx/sites-available/default
+COPY conf/supervisor-app.conf /etc/supervisor/conf.d/
+
+# Copy web-app sources
+COPY . /pulsar/
+
+# Setup database and create tables
+RUN sudo -u postgres /etc/init.d/postgresql start && \
+    sudo -u postgres psql --command "CREATE USER docker WITH PASSWORD 'docker';" && \
+    sudo -u postgres createdb -O docker pulsar_dashboard && \
+    cd /pulsar/django && \
+    ./manage.py migrate && \
+    sudo -u postgres /etc/init.d/postgresql stop
+
+# Collect all static files needed by Django in a
+# single place. Needed to run the app outside the
+# Django test web server
+RUN cd /pulsar/django && ./manage.py collectstatic --no-input
+
+ENV SERVICE_URL http://127.0.0.1:8080
+EXPOSE 80
+
+CMD ["supervisord", "-n"]
diff --git a/docker/pulsar-standalone/conf/nginx-app.conf b/docker/pulsar-standalone/conf/nginx-app.conf
new file mode 100644
index 0000000..6f57c10
--- /dev/null
+++ b/docker/pulsar-standalone/conf/nginx-app.conf
@@ -0,0 +1,37 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+upstream django {
+    server unix:/tmp/uwsgi.sock;
+}
+
+server {
+    listen      80 default_server;
+
+    charset     utf-8;
+
+    location /static {
+        alias /pulsar/django/static;
+    }
+
+    location / {
+        uwsgi_pass  django;
+        include     /pulsar/conf/uwsgi_params;
+    }
+}
diff --git a/docker/pulsar-standalone/conf/postgresql.conf b/docker/pulsar-standalone/conf/postgresql.conf
new file mode 100644
index 0000000..201dbba
--- /dev/null
+++ b/docker/pulsar-standalone/conf/postgresql.conf
@@ -0,0 +1,38 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+# Relax durability to increase write throughput
+fsync = off
+full_page_writes = off
+synchronous_commit = off
+
+# Default configs
+data_directory = '/var/lib/postgresql/9.6/main'
+hba_file = '/etc/postgresql/9.6/main/pg_hba.conf'
+ident_file = '/etc/postgresql/9.6/main/pg_ident.conf'
+external_pid_file = '/var/run/postgresql/9.6-main.pid'
+
+port = 5432
+max_connections = 100
+
+datestyle = 'iso, mdy'
+default_text_search_config = 'pg_catalog.english'
+stats_temp_directory = '/var/run/postgresql/9.6-main.pg_stat_tmp'
+timezone = 'UTC'
+log_timezone = 'UTC'
diff --git a/docker/pulsar-standalone/conf/supervisor-app.conf b/docker/pulsar-standalone/conf/supervisor-app.conf
new file mode 100644
index 0000000..6a02245
--- /dev/null
+++ b/docker/pulsar-standalone/conf/supervisor-app.conf
@@ -0,0 +1,34 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+[program:postgres]
+command = /usr/lib/postgresql/9.6/bin/postgres -D /etc/postgresql/9.6/main
+user = postgres
+
+[program:uwsgi]
+command = /usr/local/bin/uwsgi --ini /pulsar/conf/uwsgi.ini
+
+[program:nginx]
+command = /usr/sbin/nginx
+
+[program:collector]
+command = /pulsar/django/collector.sh
+
+[program:pulsar]
+command = /pulsar/bin/pulsar standalone
diff --git a/docker/pulsar-standalone/conf/uwsgi.ini b/docker/pulsar-standalone/conf/uwsgi.ini
new file mode 100644
index 0000000..f027436
--- /dev/null
+++ b/docker/pulsar-standalone/conf/uwsgi.ini
@@ -0,0 +1,45 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+# django.ini file
+[uwsgi]
+
+uid  = www-data
+gid  = www-data
+
+# master
+master                  = true
+
+# maximum number of processes
+processes               = 10
+
+# the socket (use the full path to be safe)
+socket          = /tmp/uwsgi.sock
+
+# with appropriate permissions - *may* be needed
+# chmod-socket    = 664
+
+# the base directory
+chdir           = /pulsar/django
+
+# Django's wsgi file
+module          = dashboard.wsgi
+
+# clear environment on exit
+vacuum          = true
diff --git a/docker/pulsar-standalone/conf/uwsgi_params b/docker/pulsar-standalone/conf/uwsgi_params
new file mode 100644
index 0000000..f539451
--- /dev/null
+++ b/docker/pulsar-standalone/conf/uwsgi_params
@@ -0,0 +1,16 @@
+
+uwsgi_param  QUERY_STRING       $query_string;
+uwsgi_param  REQUEST_METHOD     $request_method;
+uwsgi_param  CONTENT_TYPE       $content_type;
+uwsgi_param  CONTENT_LENGTH     $content_length;
+
+uwsgi_param  REQUEST_URI        $request_uri;
+uwsgi_param  PATH_INFO          $document_uri;
+uwsgi_param  DOCUMENT_ROOT      $document_root;
+uwsgi_param  SERVER_PROTOCOL    $server_protocol;
+uwsgi_param  HTTPS              $https if_not_empty;
+
+uwsgi_param  REMOTE_ADDR        $remote_addr;
+uwsgi_param  REMOTE_PORT        $remote_port;
+uwsgi_param  SERVER_PORT        $server_port;
+uwsgi_param  SERVER_NAME        $server_name;
diff --git a/docker/pulsar-standalone/django/collector.py b/docker/pulsar-standalone/django/collector.py
new file mode 100755
index 0000000..76c0646
--- /dev/null
+++ b/docker/pulsar-standalone/django/collector.py
@@ -0,0 +1,345 @@
+#!/usr/bin/env python
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+
+import os
+import django
+import requests
+import pytz
+import multiprocessing
+import traceback
+import sys
+from django.utils import timezone
+from django.utils.dateparse import parse_datetime
+from django.db import connection
+import time
+import argparse
+
+current_milli_time = lambda: int(round(time.time() * 1000))
+
+def get(base_url, path):
+    if base_url.endswith('/'): path = path[1:]
+    return requests.get(base_url + path,
+            headers=http_headers,
+            proxies=http_proxyes,
+        ).json()
+
+def parse_date(d):
+    if d:
+        dt = parse_datetime(d)
+        if dt.tzinfo:
+            # There is already the timezone set
+            return dt
+        else:
+            # Assume UTC if no timezone
+            return pytz.timezone('UTC').localize(parse_datetime(d))
+    else: return None
+
+# Fetch the stats for a given broker
+def fetch_broker_stats(cluster, broker_url, timestamp):
+    try:
+        _fetch_broker_stats(cluster, broker_url, timestamp)
+    except Exception as e:
+        traceback.print_exc(file=sys.stderr)
+        raise e
+
+
+def _fetch_broker_stats(cluster, broker_host_port, timestamp):
+    broker_url = 'http://%s/' % broker_host_port
+    print '    Getting stats for %s' % broker_host_port
+
+    broker, _ = Broker.objects.get_or_create(
+                        url     = broker_host_port,
+                        cluster = cluster
+                )
+    active_broker = ActiveBroker(broker=broker, timestamp=timestamp)
+    active_broker.save()
+
+    # Get topics stats
+    topics_stats = get(broker_url, '/admin/broker-stats/destinations')
+
+    clusters = dict( (cluster.name, cluster) for cluster in Cluster.objects.all() )
+
+    db_bundles = []
+    db_topics = []
+    db_subscriptions = []
+    db_consumers = []
+    db_replication = []
+
+    for namespace_name, bundles_stats in topics_stats.items():
+        property_name = namespace_name.split('/')[0]
+        property, _ = Property.objects.get_or_create(name=property_name)
+
+        namespace, _ = Namespace.objects.get_or_create(
+                            name=namespace_name,
+                            property=property)
+        namespace.clusters.add(cluster)
+        namespace.save()
+
+        for bundle_range, topics_stats in bundles_stats.items():
+            bundle = Bundle(
+                            broker    = broker,
+                            namespace = namespace,
+                            range     = bundle_range,
+                            cluster   = cluster,
+                            timestamp = timestamp)
+            db_bundles.append(bundle)
+
+            for topic_name, stats in topics_stats['persistent'].items():
+                topic = Topic(
+                    broker                 = broker,
+                    active_broker          = active_broker,
+                    name                   = topic_name,
+                    namespace              = namespace,
+                    bundle                 = bundle,
+                    cluster                = cluster,
+                    timestamp              = timestamp,
+                    averageMsgSize         = stats['averageMsgSize'],
+                    msgRateIn              = stats['msgRateIn'],
+                    msgRateOut             = stats['msgRateOut'],
+                    msgThroughputIn        = stats['msgThroughputIn'],
+                    msgThroughputOut       = stats['msgThroughputOut'],
+                    pendingAddEntriesCount = stats['pendingAddEntriesCount'],
+                    producerCount          = stats['producerCount'],
+                    storageSize            = stats['storageSize']
+                )
+
+                db_topics.append(topic)
+                totalBacklog = 0
+                numSubscriptions = 0
+                numConsumers = 0
+
+                for subscription_name, subStats in stats['subscriptions'].items():
+                    numSubscriptions += 1
+                    subscription = Subscription(
+                        topic            = topic,
+                        name             = subscription_name,
+                        namespace        = namespace,
+                        timestamp        = timestamp,
+                        msgBacklog       = subStats['msgBacklog'],
+                        msgRateExpired   = subStats['msgRateExpired'],
+                        msgRateOut       = subStats['msgRateOut'],
+                        msgRateRedeliver = subStats.get('msgRateRedeliver', 0),
+                        msgThroughputOut = subStats['msgThroughputOut'],
+                        subscriptionType = subStats['type'][0],
+                        unackedMessages  = subStats.get('unackedMessages', 0),
+                    )
+                    db_subscriptions.append(subscription)
+
+                    totalBacklog += subStats['msgBacklog']
+
+                    for consStats in subStats['consumers']:
+                        numConsumers += 1
+                        consumer = Consumer(
+                            subscription     = subscription,
+                            timestamp        = timestamp,
+                            address          = consStats['address'],
+                            availablePermits = consStats.get('availablePermits', 0),
+                            connectedSince   = parse_date(consStats.get('connectedSince')),
+                            consumerName     = consStats.get('consumerName'),
+                            msgRateOut       = consStats.get('msgRateOut', 0),
+                            msgRateRedeliver = consStats.get('msgRateRedeliver', 0),
+                            msgThroughputOut = consStats.get('msgThroughputOut', 0),
+                            unackedMessages  = consStats.get('unackedMessages', 0),
+                            blockedConsumerOnUnackedMsgs  = consStats.get('blockedConsumerOnUnackedMsgs', False)
+                        )
+                        db_consumers.append(consumer)
+
+                topic.backlog = totalBacklog
+                topic.subscriptionCount = numSubscriptions
+                topic.consumerCount = numConsumers
+
+                replicationMsgIn = 0
+                replicationMsgOut = 0
+                replicationThroughputIn = 0
+                replicationThroughputOut = 0
+                replicationBacklog = 0
+
+                for remote_cluster, replStats in stats['replication'].items():
+                    replication = Replication(
+                        timestamp              = timestamp,
+                        topic                  = topic,
+                        local_cluster          = cluster,
+                        remote_cluster         = clusters[remote_cluster],
+
+                        msgRateIn              = replStats['msgRateIn'],
+                        msgRateOut             = replStats['msgRateOut'],
+                        msgThroughputIn        = replStats['msgThroughputIn'],
+                        msgThroughputOut       = replStats['msgThroughputOut'],
+                        replicationBacklog     = replStats['replicationBacklog'],
+                        connected              = replStats['connected'],
+                        replicationDelayInSeconds = replStats['replicationDelayInSeconds'],
+                        msgRateExpired         = replStats['msgRateExpired'],
+
+                        inboundConnectedSince  = parse_date(replStats.get('inboundConnectedSince')),
+                        outboundConnectedSince = parse_date(replStats.get('outboundConnectedSince')),
+                    )
+
+                    db_replication.append(replication)
+
+                    replicationMsgIn         += replication.msgRateIn
+                    replicationMsgOut        += replication.msgRateOut
+                    replicationThroughputIn  += replication.msgThroughputIn
+                    replicationThroughputOut += replication.msgThroughputOut
+                    replicationBacklog       += replication.replicationBacklog
+
+                topic.replicationRateIn = replicationMsgIn
+                topic.replicationRateOut = replicationMsgOut
+                topic.replicationThroughputIn = replicationThroughputIn
+                topic.replicationThroughputOut = replicationThroughputOut
+                topic.replicationBacklog = replicationBacklog
+                topic.localRateIn = topic.msgRateIn - replicationMsgIn
+                topic.localRateOut = topic.msgRateOut - replicationMsgOut
+                topic.localThroughputIn = topic.msgThroughputIn - replicationThroughputIn
+                topic.localThroughputOut = topic.msgThroughputIn - replicationThroughputOut
+
+
+    if connection.vendor == 'postgresql':
+        # Bulk insert into db
+        Bundle.objects.bulk_create(db_bundles, batch_size=10000)
+
+        # Trick to refresh primary keys after previous bulk import
+        for topic in db_topics: topic.bundle = topic.bundle
+        Topic.objects.bulk_create(db_topics, batch_size=10000)
+
+        for subscription in db_subscriptions: subscription.topic = subscription.topic
+        Subscription.objects.bulk_create(db_subscriptions, batch_size=10000)
+
+        for consumer in db_consumers: consumer.subscription = consumer.subscription
+        Consumer.objects.bulk_create(db_consumers, batch_size=10000)
+
+        for replication in db_replication: replication.topic = replication.topic
+        Replication.objects.bulk_create(db_replication, batch_size=10000)
+
+    else:
+        # For other DB providers we have to insert one by one
+        # to be able to retrieve the PK of the newly inserted records
+        for bundle in db_bundles:
+            bundle.save()
+
+        for topic in db_topics:
+            topic.bundle = topic.bundle
+            topic.save()
+
+        for subscription in db_subscriptions:
+            subscription.topic = subscription.topic
+            subscription.save()
+
+        for consumer in db_consumers:
+            consumer.subscription = consumer.subscription
+            consumer.save()
+
+        for replication in db_replication:
+            replication.topic = replication.topic
+            replication.save()
+
+
+def fetch_stats():
+    timestamp = current_milli_time()
+
+    pool = multiprocessing.Pool(args.workers)
+
+    futures = []
+
+    for cluster_name in get(args.serviceUrl, '/admin/clusters'):
+        if cluster_name == 'global': continue
+
+        cluster_url = get(args.serviceUrl, '/admin/clusters/' + cluster_name)['serviceUrl']
+        print 'Cluster:', cluster_name,  '->', cluster_url
+        cluster, created = Cluster.objects.get_or_create(name=cluster_name)
+        if cluster_url != cluster.serviceUrl:
+            cluster.serviceUrl = cluster_url
+            cluster.save()
+
+    # Get the list of brokers for each cluster
+    for cluster in Cluster.objects.all():
+        try:
+            for broker_host_port in get(cluster.serviceUrl, '/admin/brokers/' + cluster.name):
+                f = pool.apply_async(fetch_broker_stats, (cluster, broker_host_port, timestamp))
+                futures.append(f)
+        except Exception as e:
+            print 'ERROR: ', e
+
+    pool.close()
+
+    for f in futures:
+        f.get()
+
+    pool.join()
+
+    # Update Timestamp in DB
+    latest, _ = LatestTimestamp.objects.get_or_create(name='latest')
+    latest.timestamp = timestamp
+    latest.save()
+
+def purge_db():
+    now = current_milli_time()
+    ttl_minutes = args.purge
+    threshold = now - (ttl_minutes * 60 * 1000)
+
+    Bundle.objects.filter(timestamp__lt = threshold).delete()
+    Topic.objects.filter(timestamp__lt = threshold).delete()
+    Subscription.objects.filter(timestamp__lt = threshold).delete()
+    Consumer.objects.filter(timestamp__lt = threshold).delete()
+
+def collect_and_purge():
+    print '-- Starting stats collection'
+    fetch_stats()
+    purge_db()
+    print '-- Finished collecting stats'
+
+if __name__ == "__main__":
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dashboard.settings")
+    django.setup()
+
+    from stats.models import *
+
+    parser = argparse.ArgumentParser(description='Pulsar Stats collector')
+    parser.add_argument(action="store", dest="serviceUrl", help='Service URL of one cluster in the Pulsar instance')
+
+    parser.add_argument('--proxy', action='store',
+                            help="Connect using a HTTP proxy", dest="proxy")
+    parser.add_argument('--header', action="append", dest="header",
+                            help='Add an additional HTTP header to all requests')
+    parser.add_argument('--purge', action="store", dest="purge", type=int, default=60,
+                            help='Purge statistics older than PURGE minutes. (default: 60min)')
+
+    parser.add_argument('--workers', action="store", dest="workers", type=int, default=64,
+                            help='Number of worker processes to be used to fetch the stats (default: 64)')
+
+    global args
+    args = parser.parse_args(sys.argv[1:])
+
+    global http_headers
+    http_headers = {}
+    if args.header:
+        http_headers = dict(x.split(': ') for x in args.header)
+
+    global http_proxyes
+    http_proxyes = {}
+    if args.proxy:
+        http_proxyes['http'] = args.proxy
+        http_proxyes['https'] = args.proxy
+
+    # Schedule a new collection every 1min
+    while True:
+        p = multiprocessing.Process(target=collect_and_purge)
+        p.start()
+        time.sleep(60)
diff --git a/docker/pulsar-standalone/django/collector.sh b/docker/pulsar-standalone/django/collector.sh
new file mode 100755
index 0000000..1c1f4d3
--- /dev/null
+++ b/docker/pulsar-standalone/django/collector.sh
@@ -0,0 +1,23 @@
+#!/bin/bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+
+cd /pulsar/django
+./collector.py $SERVICE_URL
diff --git a/docker/pulsar-standalone/django/dashboard/__init__.py b/docker/pulsar-standalone/django/dashboard/__init__.py
new file mode 100644
index 0000000..d8a500d
--- /dev/null
+++ b/docker/pulsar-standalone/django/dashboard/__init__.py
@@ -0,0 +1,19 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
diff --git a/docker/pulsar-standalone/django/dashboard/settings.py b/docker/pulsar-standalone/django/dashboard/settings.py
new file mode 100644
index 0000000..37e8c2c
--- /dev/null
+++ b/docker/pulsar-standalone/django/dashboard/settings.py
@@ -0,0 +1,181 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+"""
+Django settings for dashboard project.
+
+Generated by 'django-admin startproject' using Django 1.10.3.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/1.10/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/1.10/ref/settings/
+"""
+
+import os
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = 'kxmt78byexqz$9m!9o3f7!h3d$lz@2fe9-+te7!=0rfwm2afcb'
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = ['*']
+
+# Application definition
+
+INSTALLED_APPS = [
+    'stats.apps.StatsConfig',
+    'django.contrib.admin',
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.messages',
+    'django.contrib.staticfiles',
+    'django.contrib.humanize',
+]
+
+MIDDLEWARE = [
+    'django.middleware.security.SecurityMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.middleware.common.CommonMiddleware',
+    'django.middleware.csrf.CsrfViewMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
+    'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = 'dashboard.urls'
+
+TEMPLATES = [
+    {
+        'BACKEND': 'django.template.backends.django.DjangoTemplates',
+        'DIRS': [],
+        'APP_DIRS': True,
+        'OPTIONS': {
+            'context_processors': [
+                'django.template.context_processors.debug',
+                'django.template.context_processors.request',
+                'django.contrib.auth.context_processors.auth',
+                'django.contrib.messages.context_processors.messages',
+                'django.template.context_processors.request',
+            ],
+        },
+    },
+]
+
+WSGI_APPLICATION = 'dashboard.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
+
+# DATABASES = {
+#     'default': {
+#         'ENGINE': 'django.db.backends.sqlite3',
+#         'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
+#     }
+# }
+
+#
+
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.postgresql',
+        'USER' : 'docker',
+        'PASSWORD' : 'docker',
+        'HOST' : 'localhost',
+        'NAME' : 'pulsar_dashboard',
+    }
+}
+
+# DATABASES = {
+#     'default': {
+#         'ENGINE': 'django.db.backends.mysql',
+#         'USER' : 'root',
+#         'HOST' : 'localhost',
+#         'NAME' : 'pulsar_dashboard',
+#     }
+# }
+
+
+# Password validation
+# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+    {
+        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+    },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/1.10/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/1.10/howto/static-files/
+
+STATIC_URL = '/static/'
+
+STATIC_ROOT = os.path.join(BASE_DIR, "static/")
+
+LOGGING = {
+    'version': 1,
+    'disable_existing_loggers': False,
+    'handlers': {
+        'console': {
+            'level': 'INFO',
+            'class': 'logging.StreamHandler',
+        }
+    },
+    'loggers': {
+        'django.db.backends': {
+            'handlers': ['console'],
+            'level': 'INFO',
+        },
+    }
+}
diff --git a/docker/pulsar-standalone/django/dashboard/urls.py b/docker/pulsar-standalone/django/dashboard/urls.py
new file mode 100644
index 0000000..71b1f11
--- /dev/null
+++ b/docker/pulsar-standalone/django/dashboard/urls.py
@@ -0,0 +1,45 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+"""dashboard URL Configuration
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+    https://docs.djangoproject.com/en/1.10/topics/http/urls/
+Examples:
+Function views
+    1. Add an import:  from my_app import views
+    2. Add a URL to urlpatterns:  url(r'^$', views.home, name='home')
+Class-based views
+    1. Add an import:  from other_app.views import Home
+    2. Add a URL to urlpatterns:  url(r'^$', Home.as_view(), name='home')
+Including another URLconf
+    1. Import the include() function: from django.conf.urls import url, include
+    2. Add a URL to urlpatterns:  url(r'^blog/', include('blog.urls'))
+"""
+from django.conf.urls import include, url
+from django.contrib import admin
+
+from stats import views
+
+
+urlpatterns = [
+    url(r'^admin/', admin.site.urls),
+    url(r'^stats/', include('stats.urls')),
+    url(r'^$', views.home, name='home'),
+]
diff --git a/docker/pulsar-standalone/django/dashboard/wsgi.py b/docker/pulsar-standalone/django/dashboard/wsgi.py
new file mode 100644
index 0000000..9bdcc64
--- /dev/null
+++ b/docker/pulsar-standalone/django/dashboard/wsgi.py
@@ -0,0 +1,35 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+"""
+WSGI config for dashboard project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dashboard.settings")
+
+application = get_wsgi_application()
diff --git a/docker/pulsar-standalone/django/manage.py b/docker/pulsar-standalone/django/manage.py
new file mode 100755
index 0000000..44022a4
--- /dev/null
+++ b/docker/pulsar-standalone/django/manage.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+import os
+import sys
+
+if __name__ == "__main__":
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dashboard.settings")
+    try:
+        from django.core.management import execute_from_command_line
+    except ImportError:
+        # The above import may fail for some other reason. Ensure that the
+        # issue is really that Django is missing to avoid masking other
+        # exceptions on Python 2.
+        try:
+            import django
+        except ImportError:
+            raise ImportError(
+                "Couldn't import Django. Are you sure it's installed and "
+                "available on your PYTHONPATH environment variable? Did you "
+                "forget to activate a virtual environment?"
+            )
+        raise
+    execute_from_command_line(sys.argv)
diff --git a/docker/pulsar-standalone/django/stats/__init__.py b/docker/pulsar-standalone/django/stats/__init__.py
new file mode 100644
index 0000000..d8a500d
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/__init__.py
@@ -0,0 +1,19 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
diff --git a/docker/pulsar-standalone/django/stats/admin.py b/docker/pulsar-standalone/django/stats/admin.py
new file mode 100644
index 0000000..b1bb360
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/admin.py
@@ -0,0 +1,34 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+from django.contrib import admin
+
+# Register your models here.
+from .models import *
+admin.site.register(Cluster)
+admin.site.register(Property)
+admin.site.register(Namespace)
+admin.site.register(Bundle)
+
+
+class TopicAdmin(admin.ModelAdmin):
+    list_filter = ('cluster', 'namespace__property__name')
+
+
+admin.site.register(Topic, TopicAdmin)
diff --git a/docker/pulsar-standalone/django/stats/apps.py b/docker/pulsar-standalone/django/stats/apps.py
new file mode 100644
index 0000000..5941cbe
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/apps.py
@@ -0,0 +1,26 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+from __future__ import unicode_literals
+
+from django.apps import AppConfig
+
+
+class StatsConfig(AppConfig):
+    name = 'stats'
diff --git a/docker/pulsar-standalone/django/stats/migrations/0001_initial.py b/docker/pulsar-standalone/django/stats/migrations/0001_initial.py
new file mode 100644
index 0000000..1ce53fa
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/migrations/0001_initial.py
@@ -0,0 +1,221 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.5 on 2017-02-21 21:20
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ActiveBroker',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('timestamp', models.BigIntegerField(db_index=True)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Broker',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('url', models.URLField(db_index=True)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Bundle',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('timestamp', models.BigIntegerField(db_index=True)),
+                ('range', models.CharField(max_length=200)),
+                ('broker', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='stats.Broker')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Cluster',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=200, unique=True)),
+                ('serviceUrl', models.URLField()),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Consumer',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('timestamp', models.BigIntegerField(db_index=True)),
+                ('address', models.CharField(max_length=64, null=True)),
+                ('availablePermits', models.IntegerField(default=0)),
+                ('connectedSince', models.DateTimeField(null=True)),
+                ('consumerName', models.CharField(max_length=64, null=True)),
+                ('msgRateOut', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('msgRateRedeliver', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('msgThroughputOut', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('unackedMessages', models.BigIntegerField(default=0)),
+                ('blockedConsumerOnUnackedMsgs', models.BooleanField(default=False)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='LatestTimestamp',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=10, unique=True)),
+                ('timestamp', models.BigIntegerField(default=0)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Namespace',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=200, unique=True)),
+                ('clusters', models.ManyToManyField(to='stats.Cluster')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Property',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=200, unique=True)),
+            ],
+            options={
+                'verbose_name_plural': 'properties',
+            },
+        ),
+        migrations.CreateModel(
+            name='Replication',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('timestamp', models.BigIntegerField(db_index=True)),
+                ('msgRateIn', models.DecimalField(decimal_places=1, max_digits=12)),
+                ('msgThroughputIn', models.DecimalField(decimal_places=1, max_digits=12)),
+                ('msgRateOut', models.DecimalField(decimal_places=1, max_digits=12)),
+                ('msgThroughputOut', models.DecimalField(decimal_places=1, max_digits=12)),
+                ('msgRateExpired', models.DecimalField(decimal_places=1, max_digits=12)),
+                ('replicationBacklog', models.BigIntegerField(default=0)),
+                ('connected', models.BooleanField(default=False)),
+                ('replicationDelayInSeconds', models.IntegerField(default=0)),
+                ('inboundConnectedSince', models.DateTimeField(null=True)),
+                ('outboundConnectedSince', models.DateTimeField(null=True)),
+                ('local_cluster', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='stats.Cluster')),
+                ('remote_cluster', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='remote_cluster', to='stats.Cluster')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Subscription',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=200)),
+                ('timestamp', models.BigIntegerField(db_index=True)),
+                ('msgBacklog', models.BigIntegerField(default=0)),
+                ('msgRateExpired', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('msgRateOut', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('msgRateRedeliver', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('msgThroughputOut', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('subscriptionType', models.CharField(choices=[('N', 'Not connected'), ('E', 'Exclusive'), ('S', 'Shared'), ('F', 'Failover')], default='N', max_length=1)),
+                ('unackedMessages', models.BigIntegerField(default=0)),
+                ('namespace', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='stats.Namespace')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Topic',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(db_index=True, max_length=1024)),
+                ('timestamp', models.BigIntegerField(db_index=True)),
+                ('averageMsgSize', models.IntegerField(default=0)),
+                ('msgRateIn', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('msgRateOut', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('msgThroughputIn', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('msgThroughputOut', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('pendingAddEntriesCount', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('producerCount', models.IntegerField(default=0)),
+                ('subscriptionCount', models.IntegerField(default=0)),
+                ('consumerCount', models.IntegerField(default=0)),
+                ('storageSize', models.BigIntegerField(default=0)),
+                ('backlog', models.BigIntegerField(default=0)),
+                ('localRateIn', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('localRateOut', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('localThroughputIn', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('localThroughputOut', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('replicationRateIn', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('replicationRateOut', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('replicationThroughputIn', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('replicationThroughputOut', models.DecimalField(decimal_places=1, default=0, max_digits=12)),
+                ('replicationBacklog', models.BigIntegerField(default=0)),
+                ('active_broker', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='stats.ActiveBroker')),
+                ('broker', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='stats.Broker')),
+                ('bundle', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='stats.Bundle')),
+                ('cluster', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='stats.Cluster')),
+                ('namespace', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='stats.Namespace')),
+            ],
+        ),
+        migrations.AddField(
+            model_name='subscription',
+            name='topic',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='stats.Topic'),
+        ),
+        migrations.AddField(
+            model_name='replication',
+            name='topic',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='stats.Topic'),
+        ),
+        migrations.AddField(
+            model_name='namespace',
+            name='property',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='stats.Property'),
+        ),
+        migrations.AddField(
+            model_name='consumer',
+            name='subscription',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='stats.Subscription'),
+        ),
+        migrations.AddField(
+            model_name='bundle',
+            name='cluster',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='stats.Cluster'),
+        ),
+        migrations.AddField(
+            model_name='bundle',
+            name='namespace',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='stats.Namespace'),
+        ),
+        migrations.AddField(
+            model_name='broker',
+            name='cluster',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='stats.Cluster'),
+        ),
+        migrations.AddField(
+            model_name='activebroker',
+            name='broker',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='stats.Broker'),
+        ),
+        migrations.AlterIndexTogether(
+            name='topic',
+            index_together=set([('name', 'cluster', 'timestamp')]),
+        ),
+    ]
diff --git a/docker/pulsar-standalone/django/stats/migrations/__init__.py b/docker/pulsar-standalone/django/stats/migrations/__init__.py
new file mode 100644
index 0000000..d321c94
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/migrations/__init__.py
@@ -0,0 +1,20 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+#
\ No newline at end of file
diff --git a/docker/pulsar-standalone/django/stats/models.py b/docker/pulsar-standalone/django/stats/models.py
new file mode 100644
index 0000000..11bd23d
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/models.py
@@ -0,0 +1,200 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+from __future__ import unicode_literals
+
+from django.utils.encoding import python_2_unicode_compatible
+from django.db.models import *
+from django.urls import reverse
+
+# Used to store the latest
+class LatestTimestamp(Model):
+    name = CharField(max_length=10, unique=True)
+    timestamp = BigIntegerField(default=0)
+
+@python_2_unicode_compatible
+class Cluster(Model):
+    name = CharField(max_length=200, unique=True)
+    serviceUrl = URLField()
+
+    def __str__(self):
+        return self.name
+
+@python_2_unicode_compatible
+class Broker(Model):
+    url = URLField(db_index=True)
+    cluster = ForeignKey(Cluster, on_delete=SET_NULL, db_index=True, null=True)
+
+    def __str__(self):
+        return self.url
+
+@python_2_unicode_compatible
+class ActiveBroker(Model):
+    broker    = ForeignKey(Broker, on_delete=SET_NULL, db_index=True, null=True)
+    timestamp = BigIntegerField(db_index=True)
+
+    def __str__(self):
+        return self.url
+
+@python_2_unicode_compatible
+class Property(Model):
+    name = CharField(max_length=200, unique=True)
+
+    def __str__(self):
+        return self.name
+
+    class Meta:
+        verbose_name_plural = 'properties'
+
+@python_2_unicode_compatible
+class Namespace(Model):
+    name = CharField(max_length=200, unique=True)
+    property = ForeignKey(Property, on_delete=SET_NULL, db_index=True, null=True)
+    clusters = ManyToManyField(Cluster)
+
+    def is_global(self):
+        return self.name.split('/', 2)[1] == 'global'
+
+    def __str__(self):
+        return self.name
+
+@python_2_unicode_compatible
+class Bundle(Model):
+    timestamp = BigIntegerField(db_index=True)
+    broker = ForeignKey(Broker, on_delete=SET_NULL, db_index=True, null=True)
+    namespace = ForeignKey(Namespace, on_delete=SET_NULL, db_index=True, null=True)
+    cluster = ForeignKey(Cluster, on_delete=SET_NULL, db_index=True, null=True)
+    range = CharField(max_length=200)
+
+    def __str__(self):
+        return str(self.pk) + '--' + self.namespace.name + '/' + self.range
+
+@python_2_unicode_compatible
+class Topic(Model):
+    name      = CharField(max_length=1024, db_index=True)
+    active_broker = ForeignKey(ActiveBroker, on_delete=SET_NULL, db_index=True, null=True)
+    broker = ForeignKey(Broker, on_delete=SET_NULL, db_index=True, null=True)
+    namespace = ForeignKey(Namespace, on_delete=SET_NULL, db_index=True, null=True)
+    cluster = ForeignKey(Cluster, on_delete=SET_NULL, db_index=True, null=True)
+    bundle = ForeignKey(Bundle, on_delete=SET_NULL, db_index=True, null=True)
+
+    timestamp              = BigIntegerField(db_index=True)
+    averageMsgSize         = IntegerField(default=0)
+    msgRateIn              = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    msgRateOut             = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    msgThroughputIn        = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    msgThroughputOut       = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    pendingAddEntriesCount = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    producerCount          = IntegerField(default=0)
+    subscriptionCount      = IntegerField(default=0)
+    consumerCount          = IntegerField(default=0)
+    storageSize            = BigIntegerField(default=0)
+    backlog                = BigIntegerField(default=0)
+
+    localRateIn        = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    localRateOut       = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    localThroughputIn  = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    localThroughputOut = DecimalField(max_digits = 12, decimal_places=1, default=0)
+
+    replicationRateIn        = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    replicationRateOut       = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    replicationThroughputIn  = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    replicationThroughputOut = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    replicationBacklog       = BigIntegerField(default=0)
+
+    def short_name(self):
+        return self.name.split('/', 5)[-1]
+
+    def is_global(self):
+        return self.namespace.is_global()
+
+    def url_name(self):
+        return '/'.join(self.name.split('://', 1))
+
+    def get_absolute_url(self):
+        url = reverse('topic', args=[self.url_name()])
+        if self.namespace.is_global():
+            url += '?cluster=' + self.cluster.name
+        return url
+
+    class Meta:
+        index_together = ('name', 'cluster', 'timestamp')
+
+    def __str__(self):
+        return self.name
+
+@python_2_unicode_compatible
+class Subscription(Model):
+    name             = CharField(max_length=200)
+    topic            = ForeignKey(Topic, on_delete=SET_NULL, null=True)
+    namespace        = ForeignKey(Namespace, on_delete=SET_NULL, null=True, db_index=True)
+
+    timestamp        = BigIntegerField(db_index=True)
+    msgBacklog       = BigIntegerField(default=0)
+    msgRateExpired   = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    msgRateOut       = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    msgRateRedeliver = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    msgThroughputOut = DecimalField(max_digits = 12, decimal_places=1, default=0)
+
+    SUBSCRIPTION_TYPES = (
+        ('N', 'Not connected'),
+        ('E', 'Exclusive'),
+        ('S', 'Shared'),
+        ('F', 'Failover'),
+    )
+    subscriptionType = CharField(max_length=1, choices=SUBSCRIPTION_TYPES, default='N')
+    unackedMessages  = BigIntegerField(default=0)
+
+    def __str__(self):
+        return self.name
+
+
+class Consumer(Model):
+    timestamp        = BigIntegerField(db_index=True)
+    subscription     = ForeignKey(Subscription, on_delete=SET_NULL, db_index=True, null=True)
+
+    address = CharField(max_length=64, null=True)
+    availablePermits = IntegerField(default=0)
+    connectedSince   = DateTimeField(null=True)
+    consumerName     = CharField(max_length=64, null=True)
+    msgRateOut       = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    msgRateRedeliver = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    msgThroughputOut = DecimalField(max_digits = 12, decimal_places=1, default=0)
+    unackedMessages  = BigIntegerField(default=0)
+    blockedConsumerOnUnackedMsgs = BooleanField(default=False)
+
+
+class Replication(Model):
+    timestamp      = BigIntegerField(db_index=True)
+    topic          = ForeignKey(Topic, on_delete=SET_NULL, null=True)
+    local_cluster  = ForeignKey(Cluster, on_delete=SET_NULL, null=True)
+    remote_cluster = ForeignKey(Cluster, on_delete=SET_NULL, null=True, related_name='remote_cluster')
+
+    msgRateIn               = DecimalField(max_digits = 12, decimal_places=1)
+    msgThroughputIn         = DecimalField(max_digits = 12, decimal_places=1)
+    msgRateOut              = DecimalField(max_digits = 12, decimal_places=1)
+    msgThroughputOut        = DecimalField(max_digits = 12, decimal_places=1)
+    msgRateExpired          = DecimalField(max_digits = 12, decimal_places=1)
+    replicationBacklog      = BigIntegerField(default=0)
+
+    connected  = BooleanField(default=False)
+    replicationDelayInSeconds  = IntegerField(default=0)
+
+    inboundConnectedSince   = DateTimeField(null=True)
+    outboundConnectedSince   = DateTimeField(null=True)
diff --git a/docker/pulsar-standalone/django/stats/templates/stats/base.html b/docker/pulsar-standalone/django/stats/templates/stats/base.html
new file mode 100644
index 0000000..1e5843f
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/templates/stats/base.html
@@ -0,0 +1,98 @@
+<!--
+
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you 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.
+
+-->
+{% load static %}<!DOCTYPE html>
+
+<html>
+<head>
+<title>Pulsar Dashboard</title>
+<link rel="stylesheet" type="text/css" href="{% static "admin/css/base.css" %}" />
+<link rel="stylesheet" type="text/css" href="{% static "admin/css/changelists.css" %}" />
+
+<script
+  src="https://code.jquery.com/jquery-3.1.1.slim.min.js"
+  integrity="sha256-/SIrNqv8h6QGKDuNoLGA4iret+kyesCkHGzVUUV0shc="
+  crossorigin="anonymous"></script>
+
+{% block extrastyle %}{% endblock %}
+{% block extrahead %}{% endblock %}
+{% block blockbots %}<meta name="robots" content="NONE,NOARCHIVE" />{% endblock %}
+</head>
+
+<body class="{% if is_popup %}popup {% endif %}{% block bodyclass %}{% endblock %}"
+  data-admin-utc-offset="{% now "Z" %}">
+
+<!-- Container -->
+<div id="container">
+
+    {% if not is_popup %}
+    <!-- Header -->
+    <div id="header">
+        <div id="branding">
+        {% block branding %}<h1 id="site-name"><a href="{% url 'home' %}">Pulsar Dashboard</a></h1>{% endblock %}
+        </div>
+        {% block nav-global %}
+            <h2> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+                <a href="{% url 'home' %}">Properties</a> &nbsp; | &nbsp;
+                <a href="{% url 'brokers' %}">Brokers</a> &nbsp; | &nbsp;
+                <a href="{% url 'topics' %}">Topics</a> &nbsp; | &nbsp;
+                <a href="{% url 'clusters' %}">Clusters</a>
+            </h2>
+        </div>
+        {% endblock %}
+    </div>
+    <!-- END Header -->
+
+
+    {% block breadcrumbs %}
+    <div class="breadcrumbs">
+    <a href="{% url 'home' %}">Home</a>
+    {% if title %} &rsaquo; {{ title }}{% endif %}
+    </div>
+    {% endblock %}
+    {% endif %}
+
+    {% block messages %}
+        {% if messages %}
+        <ul class="messagelist">{% for message in messages %}
+          <li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message|capfirst }}</li>
+        {% endfor %}</ul>
+        {% endif %}
+    {% endblock messages %}
+
+    <!-- Content -->
+    <div id="content" class="{% block coltype %}colM{% endblock %}">
+        {% block pretitle %}{% endblock %}
+        {% block content_title %}{% if title %}<h1>{{ title }}</h1>{% endif %}{% endblock %}
+        {% block content %}
+        {% block object-tools %}{% endblock %}
+        {{ content }}
+        {% endblock %}
+        {% block sidebar %}{% endblock %}
+        <br class="clear" />
+    </div>
+    <!-- END Content -->
+
+    {% block footer %}<div id="footer"></div>{% endblock %}
+</div>
+<!-- END Container -->
+
+</body>
+</html>
diff --git a/docker/pulsar-standalone/django/stats/templates/stats/broker.html b/docker/pulsar-standalone/django/stats/templates/stats/broker.html
new file mode 100644
index 0000000..38f68a9
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/templates/stats/broker.html
@@ -0,0 +1,71 @@
+<!--
+
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you 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.
+
+-->
+{% extends "stats/base.html" %}
+{% load humanize %}
+{% load table %}
+
+{% block title %}Broker | {{property.name}}{% endblock %}
+
+{% block breadcrumbs %}
+<div class="breadcrumbs">
+    <a href="{% url 'home' %}">Home</a>
+    &rsaquo; <a href="{% url 'brokers' %}">Brokers</a>
+    &rsaquo; {{broker_url}}
+</div>
+{% endblock %}
+
+
+{% block content %}
+
+<table>
+<thead>
+    <tr>
+        {% column_header topics 'namespace__name' 'Namespace' %}
+        {% column_header topics 'name' 'Topic' %}
+        {% column_header topics 'msgRateIn' 'Msg/s in' %}
+        {% column_header topics 'msgRateOut' 'Msg/s out' %}
+        {% column_header topics 'msgThroughputIn' 'Bytes/s in' %}
+        {% column_header topics 'msgThroughputOut' 'Bytes/s out' %}
+        {% column_header topics 'backlog' 'Backlog' %}
+    </tr>
+</thead>
+<tbody>
+
+{% for topic in topics.results %}
+    <tr class="{% cycle 'row1' 'row2' %}">
+        <th><a href="{% url 'namespace' topic.namespace.name %}">{{topic.namespace}}</a></th>
+        <th><a href="{{topic.get_absolute_url}}">{{topic.short_name}}</a></th>
+        <td>{{topic.msgRateIn | intcomma}}</td>
+        <td>{{topic.msgRateOut | intcomma}}</td>
+        <td>{{topic.msgThroughputIn | intcomma}}</td>
+        <td>{{topic.msgThroughputOut | intcomma}}</td>
+        <td>{{topic.backlog | intcomma}}</td>
+    </tr>
+{% empty %}
+    <tr><td>No topics</td></tr>
+{% endfor %}
+</tbody>
+</table>
+
+{% table_footer topics %}
+
+
+{% endblock %}
diff --git a/docker/pulsar-standalone/django/stats/templates/stats/brokers.html b/docker/pulsar-standalone/django/stats/templates/stats/brokers.html
new file mode 100644
index 0000000..b022a87
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/templates/stats/brokers.html
@@ -0,0 +1,90 @@
+<!--
+
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you 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.
+
+-->
+{% extends "stats/base.html" %}
+
+{% load humanize %}
+{% load table %}
+{% load stats_extras %}
+
+{% block title %}Brokers{% endblock %}
+
+{% block content %}
+
+<div class="module filtered" id="changelist">
+<div id="changelist-filter">
+    <h2>Clusters</h2>
+<ul>
+   <li {% if not selectedCluster %}class="selected"{% endif %}>
+       <a href="{% url 'brokers' %}">All</a></li>
+
+   {% for cluster in clusters.all %}
+      <li {% if selectedCluster == cluster.name %}class="selected"{% endif %}
+          ><a href="{% url 'brokers_cluster' cluster %}">{{cluster}}</a></li>
+   {% endfor %}
+
+   <li>
+</ul>
+</div>
+</div>
+
+<table>
+<thead>
+    <tr>
+        {% column_header brokers 'url' 'Broker' %}
+        {% column_header brokers 'numBundles' 'Bundles' %}
+        {% column_header brokers 'numTopics' 'Topics' %}
+        {% column_header brokers 'numProducers' 'Producers' %}
+        {% column_header brokers 'numSubscriptions' 'Subscriptions' %}
+        {% column_header brokers 'numConsumers' 'Consumers' %}
+        {% column_header brokers 'rateIn' 'Rate In' %}
+        {% column_header brokers 'rateOut' 'Rate Out' %}
+        {% column_header brokers 'throughputIn' 'Mbps In' %}
+        {% column_header brokers 'throughputOut' 'Mbps Out' %}
+        {% column_header brokers 'backlog' 'Backlog' %}
+    </tr>
+</thead>
+<tbody>
+
+{% for broker in brokers.results %}
+    <tr class="{% cycle 'row1' 'row2' %}">
+        <th>
+            <a href="{% url 'broker' broker.url %}">{{broker.url}}</a>
+        </th>
+        <td>{{broker.numBundles | safe_intcomma}}</td>
+        <td>{{broker.numTopics | safe_intcomma}}</td>
+        <td>{{broker.numProducers | safe_intcomma}}</td>
+        <td>{{broker.numSubscriptions | safe_intcomma}}</td>
+        <td>{{broker.numConsumers | safe_intcomma}}</td>
+        <td>{{broker.rateIn | safe_intcomma}}</td>
+        <td>{{broker.rateOut | safe_intcomma}}</td>
+        <td>{{broker.throughputIn | mbps | floatformat | intcomma}}</td>
+        <td>{{broker.throughputOut | mbps | floatformat | intcomma}}</td>
+        <td>{{broker.backlog | safe_intcomma}}</td>
+    </tr>
+{% empty %}
+    <tr><td>No Brokers</td></tr>
+{% endfor %}
+</tbody>
+</table>
+
+{% table_footer brokers %}
+
+{% endblock %}
diff --git a/docker/pulsar-standalone/django/stats/templates/stats/clusters.html b/docker/pulsar-standalone/django/stats/templates/stats/clusters.html
new file mode 100644
index 0000000..afcf22e
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/templates/stats/clusters.html
@@ -0,0 +1,106 @@
+<!--
+
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you 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.
+
+-->
+{% extends "stats/base.html" %}
+
+{% load humanize %}
+{% load table %}
+{% load stats_extras %}
+
+{% block title %}Clusters{% endblock %}
+
+{% block content %}
+
+
+<table>
+<thead>
+    <tr>
+        {% column_header clusters 'name' 'Cluster' %}
+        {% column_header clusters 'numTopics' 'Topics' %}
+
+        {% column_header clusters 'localRateIn' 'Local Rate In' %}
+        {% column_header clusters 'localRateOut' 'Local Rate Out' %}
+        {% column_header clusters 'replicationRateIn' 'Replication Rate In' %}
+        {% column_header clusters 'replicationRateOut' 'Replication Rate Out' %}
+
+        {% column_header clusters 'localBacklog' 'Local Backlog' %}
+        {% column_header clusters 'replicationBacklog' 'Replication Backlog' %}
+        {% column_header clusters 'storage' 'Storage' %}
+    </tr>
+</thead>
+<tbody>
+
+{% for cluster in clusters.results %}
+    <tr class="{% cycle 'row1' 'row2' %}">
+        <th>{{cluster.name}}</th>
+
+        <td>{{cluster.numTopics | intcomma}}</td>
+        <td>{{cluster.localRateIn | intcomma}}</td>
+        <td>{{cluster.localRateOut | intcomma}}</td>
+        <td>{{cluster.replicationRateIn | intcomma}}</td>
+        <td>{{cluster.replicationRateOut | intcomma}}</td>
+
+        <td>{{cluster.localBacklog | intcomma}}</td>
+        <td>{{cluster.replicationBacklog | intcomma}}</td>
+        <td>{{cluster.storage | filesizeformat}}</td>
+    </tr>
+
+    <tr class="{% cycle 'row1' 'row2' %}">
+        <td></td>
+        <td colspan="6">
+            <table>
+            <thead>
+                <tr>
+                    <th>Remote Cluster</th>
+                    <th title="Msg/s">Rate in</th>
+                    <th title="Msg/s">Rate out</th>
+                    <th>Mbps in</th>
+                    <th>Mbps out</th>
+                    <th title="Messages">Replication backlog</th>
+                </tr>
+            </thead>
+            <tbody>
+            {% for peer in cluster.peers %}
+                <tr>
+                    <td>{{peer.remote_cluster__name}}</td>
+                    <td title="{{peer.remote_cluster__name}} ⟶ {{cluster.name}}">{{peer.msgRateIn__sum | intcomma}}</td>
+                    <td title="{{cluster.name}} ⟶ {{peer.remote_cluster__name}}">{{peer.msgRateOut__sum | intcomma}}</td>
+                    <td title="{{peer.remote_cluster__name}} ⟶ {{cluster.name}}">{{peer.msgThroughputIn__sum | mbps | floatformat | intcomma}}</td>
+                    <td title="{{cluster.name}} ⟶ {{peer.remote_cluster__name}}">{{peer.msgThroughputOut__sum | mbps | floatformat | intcomma}}</td>
+                    <td title="{{cluster.name}} ⟶ {{peer.remote_cluster__name}}">{{peer.replicationBacklog__sum | intcomma}}</td>
+                </tr>
+            {% empty %}
+                <tr><td>No replication</tr></td>
+            {% endfor %}
+            </tbody>
+            </table>
+        </td>
+    </tr>
+
+
+{% empty %}
+    <tr><td>No Clusters</td></tr>
+{% endfor %}
+</tbody>
+</table>
+
+{% table_footer clusters %}
+
+{% endblock %}
diff --git a/docker/pulsar-standalone/django/stats/templates/stats/home.html b/docker/pulsar-standalone/django/stats/templates/stats/home.html
new file mode 100644
index 0000000..828bfae
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/templates/stats/home.html
@@ -0,0 +1,74 @@
+<!--
+
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you 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.
+
+-->
+{% extends "stats/base.html" %}
+
+{% load table %}
+{% load humanize %}
+
+{% block title %}Home{% endblock %}
+
+{% block content %}
+
+<table>
+<thead>
+    <tr>
+        {% column_header properties 'name' 'Property' %}
+        {% column_header properties 'numNamespaces' 'Namespaces' %}
+        {% column_header properties 'numTopics' 'Topics' %}
+        {% column_header properties 'numProducers' 'Producers' %}
+        {% column_header properties 'numSubscriptions' 'Subscriptions' %}
+        {% column_header properties 'numConsumers' 'Consumers' %}
+        {% column_header properties 'rateIn' 'Rate In' %}
+        {% column_header properties 'rateOut' 'Rate Out' %}
+        {% column_header properties 'throughputIn' 'Throughput In' %}
+        {% column_header properties 'throughputOut' 'Throughput Out' %}
+        {% column_header properties 'backlog' 'Backlog' %}
+        {% column_header properties 'storage' 'Storage' %}
+    </tr>
+</thead>
+<tbody>
+
+{% for property in properties.results %}
+    <tr class="{% cycle 'row1' 'row2' %}">
+        <th>
+            <a href="{% url 'property' property.name %}">{{property.name}}</a>
+        </th>
+        <td>{{property.numNamespaces | intcomma}}</td>
+        <td>{{property.numTopics | intcomma}}</td>
+        <td>{{property.numProducers | intcomma}}</td>
+        <td>{{property.numSubscriptions | intcomma}}</td>
+        <td>{{property.numConsumers | intcomma}}</td>
+        <td>{{property.rateIn | intcomma}}</td>
+        <td>{{property.rateOut | intcomma}}</td>
+        <td>{{property.throughputIn | intcomma}}</td>
+        <td>{{property.throughputOut | intcomma}}</td>
+        <td>{{property.backlog | intcomma}}</td>
+        <td>{{property.storage | filesizeformat}}</td>
+    </tr>
+{% empty %}
+    <tr><td>No properties</td></tr>
+{% endfor %}
+</tbody>
+</table>
+
+
+
+{% endblock %}
diff --git a/docker/pulsar-standalone/django/stats/templates/stats/namespace.html b/docker/pulsar-standalone/django/stats/templates/stats/namespace.html
new file mode 100644
index 0000000..8af1c5f
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/templates/stats/namespace.html
@@ -0,0 +1,96 @@
+<!--
+
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you 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.
+
+-->
+{% extends "stats/base.html" %}
+{% load humanize %}
+{% load table %}
+
+{% block title %}Namespace | {{property.name}}{% endblock %}
+
+{% block breadcrumbs %}
+<div class="breadcrumbs">
+    <a href="{% url 'home' %}">Home</a>
+    &rsaquo; <a href="{% url 'property' namespace.property.name %}">{{ namespace.property }}</a>
+    &rsaquo; {{ namespace.name }}
+</div>
+{% endblock %}
+
+
+{% block content %}
+
+{% if namespace.is_global %}
+<div class="module filtered" id="changelist">
+<div id="changelist-filter">
+    <h2>Clusters</h2>
+<ul>
+   <li {% if not selectedCluster %}class="selected"{% endif %}><a href="?">All</a></li>
+
+   {% for cluster in namespace.clusters.all %}
+      <li {% if selectedCluster == cluster.name %}class="selected"{% endif %}
+          ><a href="?cluster={{cluster}}">{{cluster}}</a></li>
+   {% endfor %}
+
+   <li>
+</ul>
+</div>
+</div>
+{% endif %}
+
+<table>
+<thead>
+    <tr>
+        {% if namespace.is_global %}
+            {% column_header topics 'cluster__name' 'Cluster' %}
+        {% endif %}
+        {% column_header topics 'name' 'Topic' %}
+        {% column_header topics 'msgRateIn' 'Msg/s in' %}
+        {% column_header topics 'msgRateOut' 'Msg/s out' %}
+        {% column_header topics 'msgThroughputIn' 'Bytes/s in' %}
+        {% column_header topics 'msgThroughputOut' 'Bytes/s out' %}
+        {% column_header topics 'backlog' 'Backlog' %}
+        {% column_header topics 'broker' 'Broker' %}
+    </tr>
+</thead>
+<tbody>
+
+{% for topic in topics.results %}
+    <tr class="{% cycle 'row1' 'row2' %}">
+        {% if namespace.is_global %}
+            <th>{{topic.cluster}}</td>
+        {% endif %}
+
+        <th><a href="{{topic.get_absolute_url}}">{{topic.short_name}}</a></th>
+        <td>{{topic.msgRateIn | intcomma}}</td>
+        <td>{{topic.msgRateOut | intcomma}}</td>
+        <td>{{topic.msgThroughputIn | intcomma}}</td>
+        <td>{{topic.msgThroughputOut | intcomma}}</td>
+        <td>{{topic.backlog | intcomma}}</td>
+        <td><span title="{{topic.broker | escape}}">{{topic.broker | escape | truncatechars:20 }}</span></td>
+    </tr>
+{% empty %}
+    <tr><td>No topics</td></tr>
+{% endfor %}
+</tbody>
+</table>
+
+{% table_footer topics %}
+
+
+{% endblock %}
diff --git a/docker/pulsar-standalone/django/stats/templates/stats/property.html b/docker/pulsar-standalone/django/stats/templates/stats/property.html
new file mode 100644
index 0000000..7958919
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/templates/stats/property.html
@@ -0,0 +1,72 @@
+<!--
+
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you 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.
+
+-->
+{% extends "stats/base.html" %}
+
+{% load humanize %}
+{% load table %}
+
+{% block title %}Property | {{property.name}}{% endblock %}
+
+{% block content %}
+
+<table>
+<thead>
+    <tr>
+        {% column_header namespaces 'name' 'Namespace' %}
+        {% column_header namespaces 'numTopics' 'Topics' %}
+        {% column_header namespaces 'numProducers' 'Producers' %}
+        {% column_header namespaces 'numSubscriptions' 'Subscriptions' %}
+        {% column_header namespaces 'numConsumers' 'Consumers' %}
+        {% column_header namespaces 'rateIn' 'Rate In' %}
+        {% column_header namespaces 'rateOut' 'Rate Out' %}
+        {% column_header namespaces 'throughputIn' 'Throughput In' %}
+        {% column_header namespaces 'throughputOut' 'Throughput Out' %}
+        {% column_header namespaces 'backlog' 'Backlog' %}
+        {% column_header namespaces 'storage' 'Storage' %}
+    </tr>
+</thead>
+<tbody>
+
+{% for namespace in namespaces.results %}
+    <tr class="{% cycle 'row1' 'row2' %}">
+        <th>
+            <a href="{% url 'namespace' namespace.name %}">{{namespace.name}}</a>
+        </th>
+        <td>{{namespace.numTopics | intcomma}}</td>
+        <td>{{namespace.numProducers | intcomma}}</td>
+        <td>{{namespace.numSubscriptions | intcomma}}</td>
+        <td>{{namespace.numConsumers | intcomma}}</td>
+        <td>{{namespace.rateIn | intcomma}}</td>
+        <td>{{namespace.rateOut | intcomma}}</td>
+        <td>{{namespace.throughputIn | intcomma}}</td>
+        <td>{{namespace.throughputOut | intcomma}}</td>
+        <td>{{namespace.backlog | intcomma}}</td>
+        <td>{{namespace.storage | filesizeformat}}</td>
+    </tr>
+{% empty %}
+    <tr><td>No namespaces</td></tr>
+{% endfor %}
+</tbody>
+</table>
+
+{% table_footer namespaces %}
+
+{% endblock %}
diff --git a/docker/pulsar-standalone/django/stats/templates/stats/topic.html b/docker/pulsar-standalone/django/stats/templates/stats/topic.html
new file mode 100644
index 0000000..467b5a7
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/templates/stats/topic.html
@@ -0,0 +1,197 @@
+<!--
+
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you 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.
+
+-->
+{% extends "stats/base.html" %}
+
+{% load humanize %}
+{% load stats_extras %}
+
+{% block title %}Topic | {{topic.name}}{% endblock %}
+
+{% block breadcrumbs %}
+<div class="breadcrumbs">
+    <a href="{% url 'home' %}">Home</a>
+    &rsaquo; <a href="{% url 'property' topic.namespace.property.name %}">{{ topic.namespace.property }}</a>
+    &rsaquo; <a href="{% url 'namespace' topic.namespace.name %}">{{ topic.namespace.name }}</a>
+    &rsaquo; {{ topic.short_name }}
+</div>
+{% endblock %}
+
+{% block content %}
+
+{% if topic.is_global %}
+<div class="module filtered" id="changelist">
+<div id="changelist-filter">
+    <h2>Cluster</h2>
+<ul>
+   {% for cluster in clusters %}
+      <li {% if selectedCluster == cluster.name %}class="selected"{% endif %}
+          ><a href="{% url 'topic' topic.url_name %}?cluster={{cluster}}">{{cluster}}</a></li>
+   {% endfor %}
+
+   <li>
+</ul>
+</div>
+</div>
+{% endif %}
+
+
+<h2>Stats</h2>
+
+<table>
+    <tr class="row1"><th>Average msg size</th><td>{{topic.averageMsgSize | file_size_value}}</td>
+        <td>{{topic.averageMsgSize | file_size_unit}}</td></tr>
+    <tr class="row2"><th>Rate in</th><td>{{topic.msgRateIn | intcomma }}</td><td>msg/s</td></tr>
+    <tr class="row1"><th>Rate out</th><td>{{topic.msgRateOut | intcomma }}</td><td>msg/s</td></tr>
+
+    <tr class="row2"><th>Throughput in</th><td>{{topic.msgThroughputIn | file_size_value }}</td>
+        <td>{{topic.msgThroughputIn | file_size_unit }} / sec</td></tr>
+    <tr class="row1"><th>Throughput out</th><td>{{topic.msgThroughputOut | file_size_value }}</td>
+        <td>{{topic.msgThroughputOut | file_size_unit }} / sec</td></tr>
+
+    <tr class="row2"><th>Pending add entries</th><td>{{topic.pendingAddEntriesCount | intcomma }}</td><td></td></tr>
+    <tr class="row1"><th>Producer count</th><td>{{topic.producerCount | intcomma }}</td><td></td></tr>
+
+    <tr class="row2"><th>Storage size</th><td>{{topic.storageSize | file_size_value}}</td>
+        <td>{{topic.storageSize | file_size_unit}}</td></tr>
+    <tr class="row1">
+        <th>Broker</th>
+        <td colspan="2">
+            <a href="{% url 'broker' topic.broker.url %}">{{topic.broker | escape }}</a>
+        </td>
+    </tr>
+</table>
+
+<h2>Subscriptions</h2>
+
+<table>
+<thead>
+    <tr>
+        <th>Subscription</th>
+        <th>Type</th>
+        <th title="messages">Backlog</th>
+        <th title="Msg/s">Rate out</th>
+        <th title="Bytes/s">Throughput out</th>
+        <th title="Msg/s">Rate expired</th>
+        <th title="Msg/s">Unacked</th>
+    </tr>
+</thead>
+
+<tbody
+{% for sub, consumers in subscriptions %}
+    <tr class="{% cycle 'row1' 'row2' %}">
+        {% if consumers %}
+        <th><a data-toggle="#consumers-{{sub.id}}" href="#">{{sub.name}}</a></th>
+        {% else %}
+        <th>{{sub.name}}</th>
+        {% endif %}
+        <td>{{sub.get_subscriptionType_display}}</td>
+        <td>{{sub.msgBacklog | intcomma }}</td>
+        <td>{{sub.msgRateOut | intcomma}}</td>
+        <td>{{sub.msgThroughputOut | intcomma}}</td>
+        <td>{{sub.msgRateExpired | intcomma}}</td>
+        <td>{{sub.unackedMessages | intcomma}}</td>
+    </tr>
+
+    <tr class="{% cycle 'row1' 'row2' %}" id="consumers-{{sub.id}}"
+        style="display:none">
+        <td></td>
+        <td colspan="6">
+            <table>
+            <thead>
+                <tr>
+                    <th>Consumer</th>
+                    <th>Address</th>
+                    <th title="Msg/s">Rate out</th>
+                    <th title="bytes/s">Throughput out</th>
+                    <th title="Msg/s">Redelivery rate</th>
+                    <th>Connected since</th>
+                    <th>Available permits</th>
+                    <th title="Messages">Unacked</th>
+                    <th title="Blocked for max unacked messages">Blocked</th>
+                </tr>
+            </thead>
+            <tbody>
+            {% for consumerStats in consumers %}
+                <tr>
+                    <td>{{consumerStats.consumerName}}</td>
+                    <td>{{consumerStats.address}}</td>
+                    <td>{{consumerStats.msgRateOut | intcomma}}</td>
+                    <td>{{consumerStats.msgThroughputOut | intcomma}}</td>
+                    <td>{{consumerStats.msgRateRedeliver | intcomma}}</td>
+                    <td title="{{consumerStats.connectedSince}} UTC">{{consumerStats.connectedSince | naturaltime}}</td>
+                    <td>{{consumerStats.availablePermits | intcomma}}</td>
+                    <td>{{consumerStats.unackedMessages | intcomma}}</td>
+                    <td>{{consumerStats.blockedConsumerOnUnackedMsgs | yesno }}</td>
+                </tr>
+            {% empty %}
+                <tr><td>No consumers</tr></td>
+            {% endfor %}
+            </tbody>
+            </table>
+        </td>
+    </tr>
+{% empty %}
+    <tr><td>No subscriptions</tr></td>
+{% endfor %}
+</tbody>
+</table>
+
+{%if topic.is_global%}
+
+<h2>Replication from {{topic.cluster}}</h2>
+
+<table>
+<thead>
+    <tr>
+        <th>Remote Cluster</th>
+        <th title="Msg/s">Rate in</th>
+        <th title="Msg/s">Rate out</th>
+        <th>Mbps in</th>
+        <th>Mbps out</th>
+        <th title="Messages">Replication backlog</th>
+    </tr>
+</thead>
+<tbody>
+{% for peer in peers %}
+    <tr>
+        <td>{{peer.remote_cluster__name}}</td>
+        <td title="{{peer.remote_cluster__name}} ⟶ {{selectedCluster}}">{{peer.msgRateIn__sum | intcomma}}</td>
+        <td title="{{selectedCluster}} ⟶ {{peer.remote_cluster__name}}">{{peer.msgRateOut__sum | intcomma}}</td>
+        <td title="{{peer.remote_cluster__name}} ⟶ {{selectedCluster}}">{{peer.msgThroughputIn__sum | mbps | floatformat | intcomma}}</td>
+        <td title="{{selectedCluster}} ⟶ {{peer.remote_cluster__name}}">{{peer.msgThroughputOut__sum | mbps | floatformat | intcomma}}</td>
+        <td title="{{selectedCluster}} ⟶ {{peer.remote_cluster__name}}">{{peer.replicationBacklog__sum | intcomma}}</td>
+    </tr>
+{% empty %}
+    <tr><td>No replication</tr></td>
+{% endfor %}
+</tbody>
+</table>
+{% endif %}
+
+<script>
+$("a[data-toggle]").on("click", function(e) {
+  e.preventDefault();  // prevent navigating
+  var selector = $(this).data("toggle");  // get corresponding selector from data-toggle
+  $(selector).toggle( "slow", function() {
+  });
+});
+</script>
+{% endblock %}
diff --git a/docker/pulsar-standalone/django/stats/templates/stats/topics.html b/docker/pulsar-standalone/django/stats/templates/stats/topics.html
new file mode 100644
index 0000000..021443c
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/templates/stats/topics.html
@@ -0,0 +1,91 @@
+<!--
+
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you 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.
+
+-->
+{% extends "stats/base.html" %}
+{% load humanize %}
+{% load table %}
+
+{% block title %}Namespace | {{property.name}}{% endblock %}
+
+{% block breadcrumbs %}
+<div class="breadcrumbs">
+    <a href="{% url 'home' %}">Home</a>
+    &rsaquo; all topics
+</div>
+{% endblock %}
+
+
+{% block content %}
+
+<div class="module filtered" id="changelist">
+<div id="changelist-filter">
+    <h2>Clusters</h2>
+<ul>
+   <li {% if not selectedCluster %}class="selected"{% endif %}><a href="?">All</a></li>
+
+   {% for cluster in clusters %}
+      <li {% if selectedCluster == cluster.name %}class="selected"{% endif %}
+          ><a href="?cluster={{cluster}}">{{cluster}}</a></li>
+   {% endfor %}
+
+   <li>
+</ul>
+</div>
+</div>
+
+<table>
+<thead>
+    <tr>
+        {% column_header topics 'cluster__name' 'Cluster' %}
+        {% column_header topics 'namespace__name' 'Namespace' %}
+        {% column_header topics 'name' 'Topic' %}
+        {% column_header topics 'msgRateIn' 'Msg/s in' %}
+        {% column_header topics 'msgRateOut' 'Msg/s out' %}
+        {% column_header topics 'msgThroughputIn' 'Bytes/s in' %}
+        {% column_header topics 'msgThroughputOut' 'Bytes/s out' %}
+        {% column_header topics 'backlog' 'Backlog' %}
+        {% column_header topics 'broker' 'Broker' %}
+    </tr>
+</thead>
+<tbody>
+
+{% for topic in topics.results %}
+    <tr class="{% cycle 'row1' 'row2' %}">
+        <th>{{topic.cluster}}</td>
+        <th><a href="{% url 'namespace' topic.namespace.name %}">{{topic.namespace}}</a></th>
+        <th><a href="{{topic.get_absolute_url}}">{{topic.short_name}}</a></th>
+        <td>{{topic.msgRateIn | intcomma}}</td>
+        <td>{{topic.msgRateOut | intcomma}}</td>
+        <td>{{topic.msgThroughputIn | intcomma}}</td>
+        <td>{{topic.msgThroughputOut | intcomma}}</td>
+        <td>{{topic.backlog | intcomma}}</td>
+        <td title="{{topic.broker | escape}}"><a href="{% url 'broker' topic.broker %}">
+            {{topic.broker | escape | truncatechars:20 }}</a></td>
+    </tr>
+{% empty %}
+    <tr><td>No topics</td></tr>
+{% endfor %}
+</tbody>
+</table>
+
+{% table_footer topics %}
+
+
+{% endblock %}
diff --git a/docker/pulsar-standalone/django/stats/templatetags/__init__.py b/docker/pulsar-standalone/django/stats/templatetags/__init__.py
new file mode 100644
index 0000000..d8a500d
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/templatetags/__init__.py
@@ -0,0 +1,19 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
diff --git a/docker/pulsar-standalone/django/stats/templatetags/stats_extras.py b/docker/pulsar-standalone/django/stats/templatetags/stats_extras.py
new file mode 100644
index 0000000..78f7427
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/templatetags/stats_extras.py
@@ -0,0 +1,64 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+
+from django import template
+from django.utils import formats
+from django.contrib.humanize.templatetags.humanize import intcomma
+
+register = template.Library()
+
+KB = 1 << 10
+MB = 1 << 20
+GB = 1 << 30
+TB = 1 << 40
+PB = 1 << 50
+
+def fmt(x):
+     return str(formats.number_format(round(x, 1), 1))
+
+@register.filter(name='file_size_value')
+def file_size_value(bytes_):
+    bytes_ = float(bytes_)
+    if bytes_ < KB:   return str(bytes_)
+    elif bytes_ < MB: return fmt(bytes_ / KB)
+    elif bytes_ < GB: return fmt(bytes_ / MB)
+    elif bytes_ < TB: return fmt(bytes_ / GB)
+    elif bytes_ < PB: return fmt(bytes_ / TB)
+    else:  return fmt(bytes_ / PB)
+
+@register.filter(name='file_size_unit')
+def file_size_unit(bytes_):
+    if   bytes_ < KB: return 'bytes'
+    elif bytes_ < MB: return 'KB'
+    elif bytes_ < GB: return 'MB'
+    elif bytes_ < TB: return 'GB'
+    elif bytes_ < PB: return 'TB'
+    else:            return 'PB'
+
+
+@register.filter(name='mbps')
+def mbps(bytes_per_seconds):
+    if not bytes_per_seconds: return 0.0
+    else: return float(bytes_per_seconds) * 8 / 1024 / 1024
+
+@register.filter(name='safe_intcomma')
+def safe_intcomma(n):
+    if not n: return 0
+    else: return intcomma(n)
diff --git a/docker/pulsar-standalone/django/stats/templatetags/table.py b/docker/pulsar-standalone/django/stats/templatetags/table.py
new file mode 100644
index 0000000..2613092
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/templatetags/table.py
@@ -0,0 +1,91 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+
+from django import template
+from django.utils.html import format_html
+from django.utils.safestring import mark_safe
+from django.core.paginator import Paginator
+
+register = template.Library()
+
+@register.simple_tag
+def column_header(table, column, text):
+    selected = 'style="color: #D84A24"'
+    if table.sort == column:
+        sort = '-' + column
+        arrow = '&darr;'
+    elif table.sort == ('-' + column):
+        sort = column
+        arrow = '&uarr;'
+    else:
+        sort = '-' + column
+        arrow = ''
+        selected = ''
+
+    params = dict(table.request.GET)
+    params['sort'] = [sort]
+    params_str = '&'.join( (k + '=' + v[0]) for k,v in params.items())
+
+    return format_html('<th><a href="?{}"><span {}>{} {}</span></a></th>\n',
+        params_str,
+        mark_safe(selected),
+        mark_safe(arrow),
+        text
+    )
+
+@register.simple_tag
+def table_footer(table):
+    if table.show_all or table.paginator.num_pages == 1:
+         return ''
+
+    params = dict(table.request.GET)
+
+    footer = '<p class="paginator">'
+    for page in table.paginator.page_range:
+        if page == table.page:
+            footer += '<span class="this-page">%d</span>\n' % page
+        else:
+            params['page'] = [str(page)]
+            params_str = '&'.join( (k + '=' + v[0]) for k,v in params.items())
+            footer += '<a href="?%s">%d</a>\n' % (params_str, page)
+
+    footer += ' Total: %d\n' % table.paginator.count
+
+    del params['page']
+    params['show-all'] = '1'
+    params_str = '&'.join( (k + '=' + v[0]) for k,v in params.items())
+    footer += ' | <a href="?%s">Show all</a>' % params_str
+    footer += '</p>'
+    return mark_safe(footer)
+
+
+class Table:
+    def __init__(self, request, queryset, default_sort, default_page_size=25):
+        self.request = request
+        self.sort = request.GET.get('sort', default_sort)
+        self.page_size = int(request.GET.get('page-size', default_page_size))
+        self.page = int(request.GET.get('page', 1))
+        self.results = queryset.order_by(self.sort)
+        self.show_all = request.GET.get('show-all', False)
+
+        if not self.show_all:
+            # Paginate results unless explicitely turned off
+            self.paginator = Paginator(self.results, self.page_size)
+            self.results = self.paginator.page(self.page)
diff --git a/docker/pulsar-standalone/django/stats/tests.py b/docker/pulsar-standalone/django/stats/tests.py
new file mode 100644
index 0000000..8cdbd9c
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/tests.py
@@ -0,0 +1,22 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/docker/pulsar-standalone/django/stats/urls.py b/docker/pulsar-standalone/django/stats/urls.py
new file mode 100644
index 0000000..0c488bd
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/urls.py
@@ -0,0 +1,37 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+from django.conf.urls import url
+
+from . import views
+
+urlpatterns = [
+    url(r'^property/(?P<property_name>.+)/$', views.property, name='property'),
+    url(r'^namespace/(?P<namespace_name>.+)/$', views.namespace, name='namespace'),
+
+
+    url(r'^brokers/$', views.brokers, name='brokers'),
+    url(r'^brokers/(?P<cluster_name>.+)/$', views.brokers_cluster, name='brokers_cluster'),
+    url(r'^broker/(?P<broker_url>.+)/$', views.broker, name='broker'),
+
+    url(r'^topics/$', views.topics, name='topics'),
+    url(r'^topic/(?P<topic_name>.+)/$', views.topic, name='topic'),
+
+    url(r'^clusters/$', views.clusters, name='clusters'),
+]
diff --git a/docker/pulsar-standalone/django/stats/views.py b/docker/pulsar-standalone/django/stats/views.py
new file mode 100644
index 0000000..426e9b1
--- /dev/null
+++ b/docker/pulsar-standalone/django/stats/views.py
@@ -0,0 +1,283 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+from django.shortcuts import render, get_object_or_404
+from django.template import loader
+from django.urls import reverse
+from django.views import generic
+from django.db.models import Q
+
+from django.http import HttpResponseRedirect, HttpResponse
+from .models import *
+
+from stats.templatetags.table import Table
+
+def get_timestamp():
+    try:
+        return LatestTimestamp.objects.get(name='latest').timestamp
+    except:
+        return 0
+
+class HomeView(generic.ListView):
+    model = Property
+    template_name = 'stats/home.html'
+
+def home(request):
+    ts = get_timestamp()
+    properties = Property.objects.filter(
+            namespace__topic__timestamp = ts,
+        ).annotate(
+            numNamespaces = Count('namespace__name', distinct=True),
+            numTopics    = Count('namespace__topic__name', distinct=True),
+            numProducers = Sum('namespace__topic__producerCount'),
+            numSubscriptions = Sum('namespace__topic__subscriptionCount'),
+            numConsumers  = Sum('namespace__topic__consumerCount'),
+            backlog       = Sum('namespace__topic__backlog'),
+            storage       = Sum('namespace__topic__storageSize'),
+            rateIn        = Sum('namespace__topic__msgRateIn'),
+            rateOut       = Sum('namespace__topic__msgRateOut'),
+            throughputIn  = Sum('namespace__topic__msgThroughputIn'),
+            throughputOut = Sum('namespace__topic__msgThroughputOut'),
+        )
+
+    print properties.query
+
+    properties = Table(request, properties, default_sort='name')
+
+    return render(request, 'stats/home.html', {
+        'properties': properties,
+        'title' : 'Properties',
+    })
+
+def property(request, property_name):
+    property = get_object_or_404(Property, name=property_name)
+    ts = get_timestamp()
+    namespaces = Namespace.objects.filter(
+            property = property,
+            topic__timestamp = ts,
+        ).annotate(
+            numTopics    = Count('topic'),
+            numProducers = Sum('topic__producerCount'),
+            numSubscriptions = Sum('topic__subscriptionCount'),
+            numConsumers  = Sum('topic__consumerCount'),
+            backlog       = Sum('topic__backlog'),
+            storage       = Sum('topic__storageSize'),
+            rateIn        = Sum('topic__msgRateIn'),
+            rateOut       = Sum('topic__msgRateOut'),
+            throughputIn  = Sum('topic__msgThroughputIn'),
+            throughputOut = Sum('topic__msgThroughputOut'),
+        )
+
+    namespaces = Table(request, namespaces, default_sort='name')
+
+    return render(request, 'stats/property.html', {
+        'property': property,
+        'namespaces' : namespaces,
+        'title' : property.name,
+    })
+
+
+def namespace(request, namespace_name):
+    selectedClusterName = request.GET.get('cluster')
+
+    namespace = get_object_or_404(Namespace, name=namespace_name)
+    topics = Topic.objects.select_related('broker', 'namespace', 'cluster')
+    if selectedClusterName:
+        topics = topics.filter(
+                    namespace     = namespace,
+                    timestamp     = get_timestamp(),
+                    cluster__name = selectedClusterName
+                )
+    else:
+        topics = topics.filter(
+                    namespace = namespace,
+                    timestamp = get_timestamp()
+                )
+
+    topics = Table(request, topics, default_sort='name')
+    return render(request, 'stats/namespace.html', {
+        'namespace' : namespace,
+        'topics' : topics,
+        'title' : namespace.name,
+        'selectedCluster' : selectedClusterName,
+    })
+
+
+def topic(request, topic_name):
+    timestamp = get_timestamp()
+    topic_name = 'persistent://' + topic_name.split('persistent/', 1)[1]
+    cluster_name = request.GET.get('cluster')
+    clusters = []
+
+    if cluster_name:
+        topic = get_object_or_404(Topic, name=topic_name, cluster__name=cluster_name, timestamp=timestamp)
+        clusters = [x.cluster for x in Topic.objects.filter(name=topic_name, timestamp=timestamp).order_by('cluster__name')]
+    else:
+        topic = get_object_or_404(Topic, name=topic_name, timestamp=timestamp)
+    subscriptions = Subscription.objects.filter(topic=topic).order_by('name')
+
+    subs = []
+
+    for sub in subscriptions:
+        consumers = Consumer.objects.filter(subscription=sub).order_by('address')
+        subs.append((sub, consumers))
+
+    if topic.is_global():
+        peers_clusters = Replication.objects.filter(
+                        timestamp = timestamp,
+                        local_cluster__name = cluster_name
+                    ).values('remote_cluster__name'
+                    ).annotate(
+                            Sum('msgRateIn'),
+                            Sum('msgThroughputIn'),
+                            Sum('msgRateOut'),
+                            Sum('msgThroughputOut'),
+                            Sum('replicationBacklog')
+                    )
+    else:
+        peers_clusters = []
+
+    return render(request, 'stats/topic.html', {
+        'topic' : topic,
+        'subscriptions' : subs,
+        'title' : topic.name,
+        'selectedCluster' : cluster_name,
+        'clusters' : clusters,
+        'peers' : peers_clusters,
+    })
+
+def topics(request):
+    selectedClusterName = request.GET.get('cluster')
+
+    topics = Topic.objects.select_related('broker', 'namespace', 'cluster')
+    if selectedClusterName:
+        topics = topics.filter(
+                    timestamp     = get_timestamp(),
+                    cluster__name = selectedClusterName
+                )
+    else:
+        topics = topics.filter(
+                    timestamp = get_timestamp()
+                )
+
+    topics = Table(request, topics, default_sort='cluster__name')
+    return render(request, 'stats/topics.html', {
+        'clusters' : Cluster.objects.all(),
+        'topics' : topics,
+        'title' : 'Topics',
+        'selectedCluster' : selectedClusterName,
+    })
+
+
+def brokers(request):
+    return brokers_cluster(request, None)
+
+def brokers_cluster(request, cluster_name):
+    ts = get_timestamp()
+
+    brokers = Broker.objects
+    if cluster_name:
+        brokers = brokers.filter(
+                    Q(topic__timestamp=ts) | Q(topic__timestamp__isnull=True),
+                    activebroker__timestamp = ts,
+                    cluster__name    = cluster_name,
+
+                )
+    else:
+        brokers = brokers.filter(
+                    Q(topic__timestamp=ts) | Q(topic__timestamp__isnull=True),
+                    activebroker__timestamp = ts
+                )
+
+    brokers = brokers.annotate(
+        numBundles       = Count('topic__bundle'),
+        numTopics        = Count('topic'),
+        numProducers     = Sum('topic__producerCount'),
+        numSubscriptions = Sum('topic__subscriptionCount'),
+        numConsumers     = Sum('topic__consumerCount'),
+        backlog          = Sum('topic__backlog'),
+        storage          = Sum('topic__storageSize'),
+        rateIn           = Sum('topic__msgRateIn'),
+        rateOut          = Sum('topic__msgRateOut'),
+        throughputIn     = Sum('topic__msgThroughputIn'),
+        throughputOut    = Sum('topic__msgThroughputOut'),
+    )
+
+    brokers = Table(request, brokers, default_sort='url')
+
+    return render(request, 'stats/brokers.html', {
+        'clusters' : Cluster.objects.all(),
+        'brokers' : brokers,
+        'selectedCluster' : cluster_name,
+    })
+
+def broker(request, broker_url):
+    broker = Broker.objects.get(url = broker_url)
+    topics = Topic.objects.filter(
+                timestamp = get_timestamp(),
+                broker__url = broker_url
+            )
+
+    topics = Table(request, topics, default_sort='namespace__name')
+    return render(request, 'stats/broker.html', {
+        'topics' : topics,
+        'title' : 'Broker - %s - %s' % (broker.cluster, broker_url),
+        'broker_url' : broker_url
+    })
+
+
+def clusters(request):
+    ts = get_timestamp()
+
+    clusters = Cluster.objects.filter(
+                    topic__timestamp = ts
+                )
+
+    clusters = clusters.annotate(
+        numTopics           = Count('topic'),
+        localBacklog        = Sum('topic__backlog'),
+        replicationBacklog  = Sum('topic__replicationBacklog'),
+        storage             = Sum('topic__storageSize'),
+        localRateIn         = Sum('topic__localRateIn'),
+        localRateOut        = Sum('topic__localRateOut'),
+        replicationRateIn   = Sum('topic__replicationRateIn'),
+        replicationRateOut  = Sum('topic__replicationRateOut'),
+    )
+
+    clusters = Table(request, clusters, default_sort='name')
+
+    for cluster in clusters.results:
+        # Fetch per-remote peer stats
+        peers = Replication.objects.filter(
+                        timestamp = ts,
+                        local_cluster = cluster,
+                    ).values('remote_cluster__name')
+
+        peers = peers.annotate(
+            Sum('msgRateIn'),
+            Sum('msgThroughputIn'),
+            Sum('msgRateOut'),
+            Sum('msgThroughputOut'),
+            Sum('replicationBacklog')
+        )
+        cluster.peers = peers
+
+    return render(request, 'stats/clusters.html', {
+        'clusters' : clusters,
+    })
diff --git a/docker/pulsar-standalone/pom.xml b/docker/pulsar-standalone/pom.xml
new file mode 100644
index 0000000..716c141
--- /dev/null
+++ b/docker/pulsar-standalone/pom.xml
@@ -0,0 +1,81 @@
+<!--
+
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you 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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <parent>
+    <groupId>org.apache.pulsar</groupId>
+    <artifactId>docker-images</artifactId>
+    <version>2.2.0-incubating-SNAPSHOT</version>
+  </parent>
+  <modelVersion>4.0.0</modelVersion>
+  <groupId>org.apache.pulsar</groupId>
+  <artifactId>pulsar-standalone-docker-image</artifactId>
+  <name>Apache Pulsar :: Docker Images :: Pulsar Standalone</name>
+  <packaging>pom</packaging>
+
+  <profiles>
+    <profile>
+      <id>docker</id>
+      <!-- include the docker image only when docker profile is active -->
+      <dependencies>
+        <dependency>
+          <groupId>org.apache.pulsar</groupId>
+          <artifactId>pulsar-all-docker-image</artifactId>
+          <version>${project.parent.version}</version>
+          <classifier>docker-info</classifier>
+        </dependency>
+      </dependencies>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>com.spotify</groupId>
+            <artifactId>dockerfile-maven-plugin</artifactId>
+            <version>${dockerfile-maven.version}</version>
+            <executions>
+              <execution>
+                <id>default</id>
+                <goals>
+                  <goal>build</goal>
+                </goals>
+              </execution>
+              <execution>
+                <id>tag-and-push-latest</id>
+                <goals>
+                  <goal>tag</goal>
+                  <goal>push</goal>
+                </goals>
+                <configuration>
+                  <repository>${docker.organization}/pulsar-standalone</repository>
+                  <tag>latest</tag>
+                </configuration>
+              </execution>
+            </executions>
+            <configuration>
+              <repository>${docker.organization}/pulsar-standalone</repository>
+              <pullNewerImage>false</pullNewerImage>
+              <tag>${project.version}</tag>
+            </configuration>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+  </profiles>
+</project>
diff --git a/site2/docs/getting-started-standalone.md b/site2/docs/getting-started-standalone.md
index 3f95cdd..efafb22 100644
--- a/site2/docs/getting-started-standalone.md
+++ b/site2/docs/getting-started-standalone.md
@@ -9,12 +9,14 @@ For the purposes of local development and testing, you can run Pulsar in standal
 > #### Pulsar in production? 
 > If you're looking to run a full production Pulsar installation, see the [Deploying a Pulsar instance](deploy-bare-metal.md) guide.
 
-## System requirements
+## Run Pulsar Standalone Manually
+
+### System requirements
 
 Pulsar is currently available for **MacOS** and **Linux**. In order to use Pulsar, you'll need to install [Java 8](http://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html).
 
 
-## Installing Pulsar
+### Installing Pulsar
 
 To get started running Pulsar, download a binary tarball release in one of the following ways:
 
@@ -37,7 +39,7 @@ $ tar xvfz apache-pulsar-{{pulsar:version}}-bin.tar.gz
 $ cd apache-pulsar-{{pulsar:version}}
 ```
 
-## What your package contains
+### What your package contains
 
 The Pulsar binary package initially contains the following directories:
 
@@ -58,7 +60,7 @@ Directory | Contains
 `logs` | Logs created by the installation
 
 
-## Installing Builtin Connectors
+### Installing Builtin Connectors
 
 Since release `2.1.0-incubating`, Pulsar releases a separate binary distribution, containing all the `builtin` connectors.
 If you would like to enable those `builtin` connectors, you can download the connectors tarball release in one of the following ways:
@@ -104,7 +106,7 @@ pulsar-io-twitter-{{pulsar:version}}.nar
 > If you are [running Pulsar in Docker](getting-started-docker.md) or deploying Pulsar using a docker image (e.g. [K8S](deploy-kubernetes.md) or [DCOS](deploy-dcos.md)),
 > you can use `apachepulsar/pulsar-all` image instead of `apachepulsar/pulsar` image. `apachepulsar/pulsar-all` image has already bundled [all builtin connectors](io-overview.md#working-with-connectors).
 
-## Starting the cluster
+### Starting the cluster
 
 Once you have an up-to-date local copy of the release, you can start up a local cluster using the [`pulsar`](reference-cli-tools.md#pulsar) command, which is stored in the `bin` directory, and specifying that you want to start up Pulsar in standalone mode:
 
@@ -123,6 +125,21 @@ If Pulsar has been successfully started, you should see `INFO`-level log message
 > #### Automatically created namespace
 > When you start a local standalone cluster, Pulsar will automatically create a `public/default` [namespace](concepts-messaging.md#namespaces) that you can use for development purposes. All Pulsar topics are managed within namespaces. For more info, see [Topics](concepts-messaging.md#topics).
 
+## Run Pulsar Standalone in Docker
+
+Alternatively, you can run pulsar standalone locally in docker.
+
+```bash
+docker run -it -p 80:80 -p 8080:8080 -p 6650:6650 apachepulsar/pulsar-standalone
+```
+
+The command forwards following port to localhost:
+
+- 80: the port for pulsar dashboard
+- 8080: the http service url for pulsar service
+- 6650: the binary protocol service url for pulsar service
+
+After the docker container is running, you can access the dashboard under http://localhost .
 
 ## Testing your cluster setup
 
diff --git a/site2/website/versioned_docs/version-2.1.0-incubating/getting-started-standalone.md b/site2/website/versioned_docs/version-2.1.0-incubating/getting-started-standalone.md
index 29cf000..38d5e02 100644
--- a/site2/website/versioned_docs/version-2.1.0-incubating/getting-started-standalone.md
+++ b/site2/website/versioned_docs/version-2.1.0-incubating/getting-started-standalone.md
@@ -10,12 +10,15 @@ For the purposes of local development and testing, you can run Pulsar in standal
 > #### Pulsar in production? 
 > If you're looking to run a full production Pulsar installation, see the [Deploying a Pulsar instance](deploy-bare-metal.md) guide.
 
-## System requirements
+
+## Run Pulsar Standalone Manually
+
+### System requirements
 
 Pulsar is currently available for **MacOS** and **Linux**. In order to use Pulsar, you'll need to install [Java 8](http://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html).
 
 
-## Installing Pulsar
+### Installing Pulsar
 
 To get started running Pulsar, download a binary tarball release in one of the following ways:
 
@@ -38,7 +41,7 @@ $ tar xvfz apache-pulsar-{{pulsar:version}}-bin.tar.gz
 $ cd apache-pulsar-{{pulsar:version}}
 ```
 
-## What your package contains
+### What your package contains
 
 The Pulsar binary package initially contains the following directories:
 
@@ -59,7 +62,7 @@ Directory | Contains
 `logs` | Logs created by the installation
 
 
-## Installing Builtin Connectors
+### Installing Builtin Connectors
 
 Since release `2.1.0-incubating`, Pulsar releases a separate binary distribution, containing all the `builtin` connectors.
 If you would like to enable those `builtin` connectors, you can download the connectors tarball release in one of the following ways:
@@ -105,7 +108,7 @@ pulsar-io-twitter-{{pulsar:version}}.nar
 > If you are [running Pulsar in Docker](getting-started-docker.md) or deploying Pulsar using a docker image (e.g. [K8S](deploy-kubernetes.md) or [DCOS](deploy-dcos.md)),
 > you can use `apachepulsar/pulsar-all` image instead of `apachepulsar/pulsar` image. `apachepulsar/pulsar-all` image has already bundled [all builtin connectors](io-overview.md#working-with-connectors).
 
-## Starting the cluster
+### Starting the cluster
 
 Once you have an up-to-date local copy of the release, you can start up a local cluster using the [`pulsar`](reference-cli-tools.md#pulsar) command, which is stored in the `bin` directory, and specifying that you want to start up Pulsar in standalone mode:
 
@@ -124,7 +127,6 @@ If Pulsar has been successfully started, you should see `INFO`-level log message
 > #### Automatically created namespace
 > When you start a local standalone cluster, Pulsar will automatically create a `public/default` [namespace](concepts-messaging.md#namespaces) that you can use for development purposes. All Pulsar topics are managed within namespaces. For more info, see [Topics](concepts-messaging.md#topics).
 
-
 ## Testing your cluster setup
 
 Pulsar provides a CLI tool called [`pulsar-client`](reference-cli-tools.md#pulsar-client) that enables you to do things like send messages to a Pulsar topic in a running cluster. This command will send a simple message saying `hello-pulsar` to the `my-topic` topic: