Production grade dashboard

Deploy a production ready dashboard from scratch

Posted by SV on Friday, October 30, 2020

Table of content

Production grade dashboard

As an analyst or researcher, you ever wondered how you can present your findings/research to your clients, partners, manager in a clean and concise manner?

In this blog post I will go through the process of creating a production grade dashboard. This involves developing the dashboard, dockerizing it, using docker compose to attached a second container as a reverse proxy, and finally launching it on a cloud service provider.

For creating a dashboard, there are several providers, and many of them offer good functionality. PowerBI and Tableau are probably two of the dashboard providers that has the most publicity, and are both good choices as they are easy to work with and works out of the box.

However, in this blog I will focus on open source alternatives, and as I am quite familiar with Flask, going for Dash was very easy, as Dash use Flask under the hood.

If you have some experience in using R, Python or Julie, working with dash is very easy. If in addition you know a bit of CSS and HTML, you can more or less create what ever you like.

As Dash use Flask, and Flask utilize a development server, we need to use a production ready server. Gunicorn is very easy to work with and as you will see later, the setup is pretty straight forward. To serve static files, I have chosen Nginx, as it is the most popular and a relatively easy to setup reverse proxy.

The installation of the different components of this blog I assume you have already done.

Initialize project

As we want our dependecies to be consistent we use pipenv to setup a virtual environment

Create directory mkdir “YourDevelopmentDirectory”

make “YourDevelopmentDirectory” your current directory cd “YourDevelopmentDirectory”

To initilize the virtual environment pipenv shell

Project

As most people do not deploy Hello World apps, I wanted to show you how it works with a real app.

The data for this specific task is the volume of mortgages in Switzerland for the different kinds of banks. The data range is from 2002 to 2019, and it includs all the cantons in Switzerland. You can find the data here. Download the selection version of the file, as not all Cantons are represented before 2002

First let us import the different libraries needed.

import pandas as pd
import urllib.request

import dash
import dash_core_components as dcc
import dash_html_components as html
import plotly.graph_objects as go
import plotly.express as px
from jupyter_dash import JupyterDash
import dash_bootstrap_components as dbc

from dash.dependencies import Input, Output, State

import io
import os
import json
import geojson
#Read the CSV file (renamed to mor_loc.csv), no header and skip 3 first rows.
df = pd.read_csv("mor_loc.csv",header=None, skiprows=3)

#The data is read into only one column, so we split based on ";" and delete the qoutation marks. 
df = df[0].str.split(';', expand=True).replace('"', '', regex=True)

#We make the first row the name of the columns
df.columns = df.iloc[0]

#We drop the first row as this is now the column names
df = df.drop([0])

#Rename column INLANDAUSLAND to Cantons
df.rename(columns={'INLANDAUSLAND': 'Cantons'}, inplace=True)

#Replace the codeword for the different group of banks to the actual naming of the different banking groups.
df['BANKENGRUPPE'] = df['BANKENGRUPPE'].replace({'S10': 'AllBanks', 'G10': 'CantonalBanks', 'G15': 'BigBanks', 'G20': 'RegAndSavBanks', 'G25':'Raiffeisen'})
df
Date Cantons BANKENGRUPPE Value
2002 CHE1 AllBanks 539800.725
2002 CHE1 CantonalBanks 192223.564
2002 CHE1 BigBanks 181508.542
2002 CHE1 RegAndSavBanks 61235.759
2002 CHE1 Raiffeisen 68570.353
2019 JU AllBanks 7924.59101535
2019 JU CantonalBanks 2393.74600703
2019 JU BigBanks 1716.07640735
2019 JU RegAndSavBanks 682.45943634
2019 JU Raiffeisen 2841.20766297
#We create a new dataframe (mortgage Cantons), where we group by the different banking groups
mor_Cant = df.groupby(["Date","Cantons", "BANKENGRUPPE"])['Value'].aggregate(lambda x: x).unstack().reset_index()

#Create a copy of the columns and drop the one which is not numeric
cols = mor_Cant.columns.drop('Cantons')

#Grab the columns which should be numeric and transform to numeric
mor_Cant[cols] = mor_Cant[cols].apply(pd.to_numeric, errors='coerce')

#Create a third dataframe only for "CH1" (All Switzerland)
mor_Nat = mor_Cant[mor_Cant["Cantons"] == 'CHE1']

#Drop the values in mor_Cant which are in mor_Nat dataframes
mor_Cant = mor_Cant[~mor_Cant.isin(mor_Nat)].dropna()

#Make all the float to int, as we do not really need decimals in tis case.
mor_Cant[cols] = mor_Cant[cols].astype(int)
mor_Cant
Date Cantons AllBanks BigBanks CantonalBanks Raiffeisen RegAndSavBanks
2002 AG 48219 10901 10341 8683 16029
2002 AI 1168 121 782 244 12
2002 AR 3132 1563 610 617 220
2002 BE 60454 23585 11264 5264 15828
2002 BL 21310 6739 10391 1659 590
2019 UR 4380 628 2314 1265 48
2019 VD 88451 32625 31015 10969 2974
2019 VS 40481 12366 11075 13666 455
2019 ZG 19606 4855 10204 3358 311
2019 ZH 197071 60859 91126 15436 13379
#Get the unique Cantons in the mor_Cant dataframe
available_indicators = mor_Cant['Cantons'].unique()
#Get the values for the different cantons in 2019
All_Bank2019 = mor_Cant.loc[mor_Cant["Date"] == 2019,:]
#Create an array of the values of 2019
All_Bank2019V = All_Bank2019.values
All_Bank2019V
array([[2019, 'AG', 89938, 14844, 25940, 19031, 24365],
       [2019, 'AI', 1992, 172, 1450, 318, 34],
       [2019, 'AR', 6520, 1290, 2317, 2200, 463],
       [2019, 'BE', 108226, 30760, 23373, 17087, 26340],
       [2019, 'BL', 39054, 9560, 18569, 4661, 1546],
       [2019, 'BS', 18809, 5247, 8208, 1454, 488],
       [2019, 'FR', 36291, 7567, 14794, 9548, 2069],
       [2019, 'GE', 53118, 26797, 11470, 4341, 649],
       [2019, 'GL', 4458, 490, 2453, 654, 540],
       [2019, 'GR', 34365, 8711, 16678, 5861, 516],
       [2019, 'JU', 7924, 1716, 2393, 2841, 682],
       [2019, 'LU', 49148, 7696, 24086, 10130, 5256],
       [2019, 'NE', 16579, 4520, 7614, 2128, 345],
       [2019, 'NW', 5492, 773, 3106, 1380, 82],
       [2019, 'OW', 4879, 508, 3151, 960, 185],
       [2019, 'SG', 59851, 7208, 22708, 19193, 6112],
       [2019, 'SH', 9278, 1461, 3936, 1072, 1929],
       [2019, 'SO', 33563, 6895, 5774, 9643, 8186],
       [2019, 'SZ', 24241, 4184, 13348, 3398, 1535],
       [2019, 'TG', 37159, 4563, 18155, 11588, 1001],
       [2019, 'TI', 50684, 14886, 11100, 13093, 443],
       [2019, 'UR', 4380, 628, 2314, 1265, 48],
       [2019, 'VD', 88451, 32625, 31015, 10969, 2974],
       [2019, 'VS', 40481, 12366, 11075, 13666, 455],
       [2019, 'ZG', 19606, 4855, 10204, 3358, 311],
       [2019, 'ZH', 197071, 60859, 91126, 15436, 13379]], dtype=object)

Visualization

To make the visualization more appealing I have included a map of Switzerland, where we visualize the different cantons and their contribution to the mortgage volume.

#Download link with geojson coordinates
swiss_url = 'https://raw.githubusercontent.com/empet/Datasets/master/swiss-cantons.geojson'

#Funtion to read the geojson file
def read_geojson(url):
    with urllib.request.urlopen(url) as url:
        jdata = json.loads(url.read().decode())
    return jdata 
jdata = read_geojson(swiss_url)

As for now we are working in jupyter notebook, we need to use JupyterDash.

We also import an external stylesheet which we can play with later on.

app = JupyterDash(__name__, external_stylesheets=[dbc.themes.LUMEN])

We set the title of first graph.

title = '2019 Cantonal mortgage volume'

Define the graph object and the figure we want to plot: * We use the Choropletmapbox as we want to display map of the cantons on the world map * Geojson; Get the longitude and latitude data * Locations: The cantons we just extracted from the mor_Cant dataframe * z; the mor_Cant dataframe for the year 2019 * customdata; array of the mor_Cant dataframe 2019 * Extract the data for the different banks to be on the map * Set title for the colorbar

swissmap = go.Figure(go.Choroplethmapbox(geojson=jdata, 
                                    locations=available_indicators, 
                                    z=All_Bank2019["AllBanks"],
                                    featureidkey='properties.id',
                                    coloraxis="coloraxis",
                                    customdata=All_Bank2019V,
                                    hovertemplate= 'Canton: %{customdata[1]}'+\
                                    '<br>All Banks: %{customdata[2]}CHF mill'+\
                                    '<br>Big Banks: %{customdata[3]}CHF mill'+\
                                    '<br>Cantonal Banks: %{customdata[4]}CHF mill'+\
                                    '<br>Raiffeisen Banks: %{customdata[5]}CHF mill'+\
                                    '<br>Reg & Sav Banks: %{customdata[6]}CHF mill<extra></extra>',
                                    colorbar_title="Millions CHF",
                                    marker_line_width=1))

We update the layout of the figure: * size of title * width and height of graph * what backgroundcolor and color of the different values in the map * mapbox: where the center of the map should be and what zoom we want to use.

swissmap.update_layout(title_text = title,
                  title_x=0.5,
                  #width=800,
                  #height=500,
                  #margin={"r":0,"t":30,"l":30,"b":30},
                  #paper_bgcolor="LightSteelBlue",
                  coloraxis_colorscale='Tealgrn',
                  mapbox=dict(style='carto-positron',
                              zoom=5.8, 
                              center = {"lat": 46.8181877 , "lon":8.2275124 },
                              ))

Let us create a barplot to stack the volume mortage of the different banks per canton (Excluding all banks as that is the total stack). * For this chart we use the plotly express function, which is more automated and therefore easier to define. * We create an automated slider defined by the different year * Set the max value of y axis to be the max of the stack bar * Set size of title and size of graph

bar_fig = px.bar(mor_Cant, x="Cantons", y=["BigBanks", "CantonalBanks", "Raiffeisen", "RegAndSavBanks"],
                 title="Mortgages per type of bank Switzerland",
                 animation_frame="Date",
                 labels={"value": "CHF mill", "variable": "Type of Bank"})
bar_fig.update_yaxes(range=[0, 183000])
bar_fig.update_layout(title_x=0.5, margin={"r":0,"t":30,"l":30,"b":30})

We can finally plot the graphs we have made so far, and create a dashboard, where we also add a line chart with an interactive callback function.

app.layout = html.Div(children=[
    #Set headline of the page.
    html.H1(children='Overview of the Swiss mortgage market', style = {'text-align':'center'}),
    
    #Set the dash core component graph, for the swissmap we created
    dcc.Graph(
        id='example-graph',
        figure=swissmap
    ),
    
    #Set the id for the line plot
    html.Div([
        dcc.Graph(id='line_graph')
    ]),
    
    #Set the details for dropdown of the line plot
    html.Div([

        html.Br(),
        html.Label(['Compare different bank categories in different canonts:'],style={'font-weight': 'bold', "text-align": "center"}),
        dcc.Dropdown(id='Cantons',
            options=[{'label':x, 'value':x} for x in df.Cantons.unique()],
            value='ZH',
            multi=False,
            disabled=False,
            clearable=True,
            searchable=True,
            placeholder='Choose Cuisine...',
            className='form-dropdown',
            style={'width':"90%"},
            persistence='string',
            persistence_type='memory'),

    ]),
    
    #Set the dash core component graph for the automated bar-graph we created
    dcc.Graph(
        id='bar-graph',
        figure=bar_fig
    )
    
])

#Set callback function. When the input of the dropdown we created above changes, 
#the output will aslo change accordingly. 
#In this case the input value is the canton and the values of that canton
@app.callback(
    Output('line_graph','figure'),
    [Input('Cantons','value')]
)

#The function defines the lineplot
def build_graph(DropDown):
    #Select the canton from the database which is defined in the dropdown
    dff=df[(df['Cantons']==DropDown)]

    linep = px.line(dff, x="Date", y="Value", color='BANKENGRUPPE', height=600)
    linep.update_layout(yaxis={'title':'Volume'},
                      title={'text':'Development of mortage volume',
                      'font':{'size':28},'x':0.5,'xanchor':'center'})
    return linep



app.run_server(mode="inline", port="8060")

Let us try to make the plot a bit more appealing, rearranging the plots, adding a nav and write a small explanation text.

Finishing Touches on the dashboard

#Add a font that is pleasant
fontawesome = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css'
appBeautify = JupyterDash(__name__, assets_folder="static" ,external_stylesheets=[dbc.themes.LUMEN, fontawesome])
#Include a nav bar
navbar = dbc.Nav(className="nav nav-pills navbar-expand-lg navbar-light bg-ligth", children=[
    ## Add a logo to the navbar
    dbc.NavItem(html.Img(src=appBeautify.get_asset_url("mortgage.png"), height="40px")),
    ## create a button for readers to understand more about the app
    dbc.Col(width="auto"),
    dbc.NavItem(html.Div([
        dbc.NavLink("Info",href="/", id="info-popover", active=False),
        dbc.Popover(id="info", is_open=False, target="info-popover", children=[
            dbc.PopoverHeader("Try it out!"), 
            dbc.PopoverBody("Choose the different interactive tools and see how the mortgage market evolves")
        ])
    ])),
    ## Include link to the repository
    dbc.DropdownMenu(label="Code", nav=True, children=[
        dbc.DropdownMenuItem([html.I(className="fa fa-github"), "  Github"], 
                             href="https://www.google.com", target="_blank")
    ])
])



appBeautify.layout = html.Div(children=[
    #Set headline of the page.
    navbar,
    html.H1('Overview of the Swiss mortgage market',  id="nav-pills", 
            className="table-primary", style = {'text-align':'center', 'height':'50px'}),
    
    #Set the details for dropdown of the line plot
    html.Div([
        html.Br(),
        html.Label(['Compare different bank categories in different canonts:'],style={'font-weight': 'bold', "text-align": "center"}),
        dcc.Dropdown(id='Cantons',
            options=[{'label':x, 'value':x} for x in df.Cantons.unique()],
            value='ZH',
            multi=False,
            disabled=False,
            clearable=True,
            searchable=True,
            placeholder='Choose Cuisine...',
            className='form-dropdown',
            #style={'width':"90%"},
            persistence='string',
            persistence_type='memory'),

    ]),
    #Arrange the 
    dbc.Row([
        dbc.Col(
            children=[html.Br(),
                      html.P("On the right you can find the volume of mortgages in 2019. Under you can see the development over time. At the bottom you can interactively play with the chart and see how the mortages evolve as a total over time." 
                             ,className="table-dark",
                             style={'margin-left': '40px','color': 'white',
                                    'fontSize': 14,'margin-right': '40px'}),
                               html.Br(),dcc.Graph(id='line_graph'
                                                   ,style={'width': '100%', 'height':'80%'}),
    ]),dbc.Col(children=[html.Div([
            dcc.Graph(id='example-graph',
                      figure=swissmap,style={'width': '48%', 'align': 'right'})
        ])])]
    ),
    
    
    #Set the dash core component graph, for the swissmap we created
    
    
    #Set the id for the line plot
    
    
    
    
    #Set the dash core component graph for the automated bar-graph we created
    dcc.Graph(
        id='bar-graph',
        figure=bar_fig
    )
    
])

#Set callback function. When the input of the dropdown we created above changes, 
#the output will aslo change accordingly. 
#In this case the input value is the canton and the values of that canton
@appBeautify.callback(
    Output('line_graph','figure'),
    [Input('Cantons','value')]
)

def build_graph(DropDown):
    dff=df[(df['Cantons']==DropDown)]

    linep = px.line(dff, x="Date", y="Value", color='BANKENGRUPPE')
    linep.update_layout(yaxis={'title':'Volume'},
                      title={'text':'Development of mortage volume',
                      'font':{'size':17},'x':0.5,'xanchor':'center'})
    return linep

#Callback function for the header options
@appBeautify.callback(
    output=Output("info","is_open"), 
    inputs=[Input("info-popover","n_clicks")], 
    state=[State("info","is_open")])

def info_popover(n, is_open):
    if n:
        return not is_open
    return is_open

@appBeautify.callback(
    output=Output("info-popover","active"), 
    inputs=[Input("info-popover","n_clicks")], 
    state=[State("info-popover","active")])

def info_active(n, active):
    if n:
        return not active
    return active



appBeautify.run_server(mode="inline", port="8091")

The code tends to get very messy when working with the html tags of plotly, but our app is finally done. As this is an example app, I will not style it further, but feel free to work with the html tags and css to make it prettier. Also when you are developing a dashboard, I highly recommend to not work with the inline mode of dash, as it distorts the layout.

Deployment

First, let us wrap the app in a docker image. For this we have to do some minor tweaks to the code, and create a docker file. In this repository, you can find the setup as we do not deploy this jupyter file, but rather an app.py file, where practiacally the same code is found.

The adjustments we need to do:

  • install flask and import it
  • add: server = Flask(name)
  • change: appBeautify = dash.Dash(name, assets_folder=“static” ,external_stylesheets=[dbc.themes.LUMEN, fontawesome])
    • to: appBeautify = dash.Dash(assets_folder=“static” ,external_stylesheets=[dbc.themes.LUMEN, fontawesome], server=server)
    • Here you can see that we have added the flask server to our dash app.
  • change: appBeautify.run_server(debug=True, port=“8091”)
    • to: appBeautify.run_server(debug=True)
    • This is done since we will define in the dockerfile what port the container will run on, and since we are also running it with the wsgi gunicorn, which is a production ready http server.

The dockerfile

# Use slim python image with no fuzz
FROM python:3.8-slim-buster

ENV PYTHONUNBUFFERED 1

# Create a working directory.
RUN mkdir wd
WORKDIR wd

# Install Python dependencies.
COPY requirements.txt .
RUN pip3 install -r requirements.txt

# Copy the rest of the codebase into the image
COPY . ./

# Finally, run gunicorn.
CMD [ "gunicorn", "--workers=5", "--threads=1", "-b 0.0.0.0:9501", "app:server"]

To build the image, get the path of directory where the image is hosted and run: docker build “path/to/your/image”

When the build has finished, you want to run the image, but first you need to map the port of the image, in this case 9501 to a port on your computer(localhost).

docker run -d -p 9501:9501 nameOrIDofYourImage

You should now be able to access your container on http://127.0.0.1:9501/

Great, we are almost there. But we want to launch it with nginx as the reverse proxy that can handle static content. So we will need to do some changes to the docker file, create a nginx dockerfile and a docker-compose file to organize the communication between the two container instances.

#First we update the dockerfile to: 
# Use slim python image with no fuzz
FROM python:3.8-slim-buster

ENV PYTHONUNBUFFERED 1

# Create a working directory.
RUN mkdir wd
WORKDIR wd

# Install Python dependencies.
COPY requirements.txt .
RUN pip3 install -r requirements.txt

# Copy the rest of the codebase into the image
COPY . ./

We do this update since we will perform the python command in the docker-compose file.

Second we add get the nginx dockerfile file:

FROM nginx:1.13.3

RUN rm /etc/nginx/nginx.conf
COPY nginx.conf /etc/nginx/

RUN rm /etc/nginx/conf.d/default.conf
COPY project.conf /etc/nginx/conf.d/

COPY ./static ./static

I will not go in to detail about the nginx file, but have a look at the project.conf file, as it is from this file we set up the connection to the static files and decide what is going to be served.

As we want nginx to serve our static files, we add the static folder to the nginx folder.

Finally we have the docker-compose file

version: '3'

services:
  #The app container
  app:
    container_name: app
    restart: always
    build: ./plotly
    ports:
      - "8000:8000"
    command: gunicorn --workers=5 --threads=1 -b 0.0.0.0:8000 app:server

  #The nginx container
  nginx:
    container_name: nginx
    restart: always
    build: ./nginx
    ports:
      - "8085:80"
    depends_on:
      - app

We push these files and folders to our repository with git.

DigitalOcean

Create a digitalocean account, configure ssh and launch a droplet. The cheapest one will do for this project.

SSH in to the droplet and follow this link to setup a user. To check if your user works, open another terminal and try to ssh in again with: ssh youruser@ip-address

Then install docker, and docker-compose. Make sure to use Sudo. If not you will have problems in the intallation phase.

Finally install git.

From here on you need to download the git repository in the directory you may choose on the server: git clone https://github.com/vinwinter/Production-ready-dash

Make sure you download the https based link above and not the ssh based link(git), as you will need to set up ssh on your droplet.

An issue I encountered was that apache was running on port 80 on the server, so please use this command to stop it: sudo /etc/init.d/apache2 stop

Finally you can use the docker compose command: sudo docker-compose up –build -d

And voila, the server with your dashboard is up and running.

When you went to remove the containers, please use: sudo docker-compose rm -fs

Conclusion

We have finally managed to setup a dashboard, which can be accessed by everyone. However, an important thing to keep in mind is that if you add a domain your site will look more presentable, and also to add encryption for security. To attach a domain to your digitalocean account, you can have a look at this link. I have also added a link where the author is launching a node.js application with nginx as a reverse proxy and encryption through lets encrypt.

If you feel like you do not need nginx, feel free to move the static folder to the plotly folder and use the image under the dockerfile heading above. Then you only need to expose the container port to your local or remote host, as we did above.

Finally, there are easier ways to launch this dashboard, where you can use Heroku, which is brilliant for one of projects, but the benefit of containerizing is that it can easily be scaled, such as deploying it to Kubernetes. In fact, Docker-compose is an easy way to test if the containers are operating as they should in development, before you push it to Kubernetes.