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

[GitHub] sijie closed pull request #2545: [docker] introduce a pulsar standalone image

sijie closed pull request #2545: [docker] introduce a pulsar standalone image
URL: https://github.com/apache/incubator-pulsar/pull/2545
 
 
   

This is a PR merged from a forked repository.
As GitHub hides the original diff on merge, it is displayed below for
the sake of provenance:

As this is a foreign pull request (from a fork), the diff is supplied
below (as it won't show otherwise due to GitHub magic):

diff --git a/docker/pom.xml b/docker/pom.xml
index 675656a199..bdc99f7297 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 0000000000..869b4c0fb2
--- /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 0000000000..6f57c1019c
--- /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 0000000000..201dbba9da
--- /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 0000000000..6a02245ee5
--- /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 0000000000..f027436aad
--- /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 0000000000..f539451b6f
--- /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 0000000000..76c0646df9
--- /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 0000000000..1c1f4d3fa1
--- /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 0000000000..d8a500d9d8
--- /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 0000000000..37e8c2cf06
--- /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 0000000000..71b1f1177e
--- /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 0000000000..9bdcc64025
--- /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 0000000000..44022a4ebe
--- /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 0000000000..d8a500d9d8
--- /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 0000000000..b1bb360799
--- /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 0000000000..5941cbeb72
--- /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 0000000000..1ce53fae40
--- /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 0000000000..d321c94c9b
--- /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 0000000000..11bd23d334
--- /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 0000000000..1e5843f9c1
--- /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 0000000000..38f68a9bf1
--- /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 0000000000..b022a878aa
--- /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 0000000000..afcf22ed93
--- /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 0000000000..828bfaed39
--- /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 0000000000..8af1c5fbbf
--- /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 0000000000..7958919bc3
--- /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 0000000000..467b5a7b45
--- /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 0000000000..021443cc98
--- /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 0000000000..d8a500d9d8
--- /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 0000000000..78f74275eb
--- /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 0000000000..2613092c26
--- /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 0000000000..8cdbd9cdae
--- /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 0000000000..0c488bd3e9
--- /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 0000000000..426e9b1b6e
--- /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 0000000000..716c14163f
--- /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 3f95cddb4d..efafb22fca 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 29cf000a35..38d5e02e89 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:


 

----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on GitHub and use the
URL above to go to the specific comment.
 
For queries about this service, please contact Infrastructure at:
users@infra.apache.org


With regards,
Apache Git Services