Embedding Metabase Dashboards In Frappe

2023-07-19

Since we collect a lot of operational data, it's inevitable that we do a lot of analysis and visualisation too. Our preferred tool for such analyses is Metabase.

A typical IoTReady workflow solution, e.g. for Warehouse Traceability, works as described below:

While we could leave it at this, asking users to go to a separate Metabase instance just for the couple of reports that role might need seems unnecessary. Besides, filtering questions and dashboards inside Metabase based on roles in Frappe would need mapping of all the roles and all the users.

Metabase In Frappe

<iframe>s & Filters

There's a simpler way to approach this:

The Metabase setup is quite simple and well documented. The important things to note are the Editable filters and enabling embeds in your global configuration. Once you have done this, you need to note your dashboard ID (shown in the URL as well as the Code tab).

Metabase Dashboard Setup

On the Frappe side, we create a Page that calls some Python code upon loading.

// Page
frappe.pages['warehouse-summary'].on_page_load = function (wrapper) {
	frappe.call({
		method: "app.utils.get_warehouse_dashboard",
		type: "GET",
		args: {},
		callback: (r) => {
			// console.log("result", r);
			if (r.exc) {
				console.error("error", r.exc);
			} else {
				$(wrapper).html(r.message);
			}
		},
		freeze: false,
		freeze_message: "",
		async: true,
	});
}

The Python function (get_warehouse_dashboard in this case) is page specific. This function's job is map to the right dashboard, generate the filters and call a more general function get_metabase_dashboard. This function generates the JWT token needed to load a dashboard from our Metabase instance, creates a custom <iframe> url and renders some HTML with this <iframe>.

# Controller code
import frappe
import jwt
import time

@frappe.whitelist()
def get_warehouse_dashboard():
    dashboard_id = 3
    roles = frappe.get_roles(frappe.session.user)
    if (
        "BIG BOSS" in roles
    ):
        params = {}
    else:
        # get_list filters Warehouse by user specific permissions.
        warehouses = [r[0] for r in frappe.get_list("Warehouse", as_list=True)]
        params = {"warehouse": warehouses}
    return get_metabase_dashboard(dashboard_id, params)

def get_metabase_dashboard(dashboard_id: int, params={}):
    METABASE_SITE_URL = frappe.conf["metabase_site_url"]
    METABASE_SECRET_KEY = frappe.conf["metabase_secret_key"]
    payload = {
        "resource": {"dashboard": dashboard_id},
        "params": params,
        "exp": round(time.time()) + (60 * 10),  # 10 minute expiration
    }
    token = jwt.encode(payload, METABASE_SECRET_KEY, algorithm="HS256")

    iframeUrl = (
        METABASE_SITE_URL + "/embed/dashboard/" + token + "#bordered=false&titled=false"
    )
    html = frappe.render_template(
        "templates/includes/metabase_dashboard.html",
        {"iframeUrl": iframeUrl, "title": "Dashboard"},
    )
    return html

And here's the HTML template. The embedded script makes the iframe responsive.

<!-- template -->
<iframe src="{{iframeUrl}}" style="width: 100%;" frameborder="0" width="1280" height="800" allowtransparency
    id="Iframe"></iframe>

<script>
    // Selecting the iframe element
    var frame = document.getElementById("Iframe");
    // Adjusting the iframe height onload event
    frame.onload = function ()
    // function execute while load the iframe
    {
        // set the height of the iframe as 
        // the height of the iframe content
        frame.style.height =
            frame.contentWindow.document.body.scrollHeight + 'px';
        // set the width of the iframe as the 
        // width of the iframe content
        frame.style.width =
            frame.contentWindow.document.body.scrollWidth + 'px';
    }
</script>

Notes