Creating the Dashboard

Practically learn how to create your own dashboards.

Just like for serialization in custom APIs, do not rely on i128 for your dashboard due to a bug we're still investigating related to out client-side integration with the soroban-sdk.

Now that we've understood what Mercury dashboards are and how they can be used, let's finally learn how to create them with some practical examples.

Workflow

The process of creating a dashboard involves three phases:

  1. Retrieving Data: You can retrieve data by creating your ingestion logic and storing it in tables or by accessing other publicly indexed tables. This process is already familiar to us.

  2. Aggregating Data: Create data structures with the data pre-aggregated for easy reading and iteration, facilitating the charting functions we will use later.

  3. Plotting Data: Using the aggregated data, you can now create dashboards and plot it in tables or charts. With our integration with Charming and ApacheCharts, you can generate a wide variety of beautiful and customizable interactive charts.

To build your dashboards and examine the various steps in detail, we recommend using the Blend Dashboard example as a reference and following along with it. In this chapter, we will provide the fundamentals for creating your own dashboards by covering the most crucial steps and showcasing examples from that repository. If you feel something is missing, refer to the complete example or ask for help on our Discord server.

1. Retrieve the Data

We already covered this step in the previous chapters and explored the different ways in which this can be done. Reference the previous parts of this documentation to perform this.

2. Aggregate Data

Also in this case you have various approaches you could follow, depending on the specific need. But as a general rule, we need an iterator where the different data is categorized, aggregated, and stored. Let’s look at an example:

pub fn aggregate_data<'a>(
    timestamp: i64,
    supplies: &'a Vec<Supply>,
    collaterals: &'a Vec<Collateral>,
    borroweds: &'a Vec<Borrowed>,
) -> HashMap<&'a str, HashMap<&'a str, AggregatedData>> {
    let env = EnvClient::empty();
    let mut aggregated_data: HashMap<&'a str, HashMap<&'a str, AggregatedData>> = HashMap::new();

    env.log().debug("hashmaps", None);

    for supply in supplies {
        let pool = &supply.pool;  // Convert pool to string for hashmap key
        let asset = &supply.asset;  // Convert asset to string for hashmap key
        let supply_value = supply.supply;
        let ledger =supply.ledger;

        aggregated_data
        .entry(&pool)
            .or_insert_with(HashMap::new)
            .entry(&asset)
            .or_insert_with(AggregatedData::new)
            .add_supply(ledger, supply_value);
    }
    aggregated_data
}

What we see here is a aggregate_data function where we perform the aggregation logic. Here we are analyzing just the aggregation of token supply data for each Blend liquidity pool taken into account. The function takes as argument a Vec<Supply> (a vector of objects of custom type Supply), that is the data we have previously indexed and we now have to aggregate.

First of all, we have the Declaration of aggregated_data:

let mut aggregated_data: HashMap<&'a str, HashMap<&'a str, AggregatedData>> = HashMap::new();
  • This line declares a mutable HashMap named aggregated_data.

  • The outer HashMap uses a string slice (&'a str) as the key and another HashMap as the value.

  • The inner HashMap also uses a string slice (&'a str) as the key and a custom struct or type AggregatedData as the value.

Then, for each element in Vec<Supply>, we update aggregated_data:

aggregated_data
    .entry(&pool)
    .or_insert_with(HashMap::new)
    .entry(&asset)
    .or_insert_with(AggregatedData::new)
    .add_supply(ledger, supply_value);
}

Let's break it down further:

aggregated_data.entry(&pool):

  • This accesses the entry in the outer HashMap corresponding to the key &pool.

  • If the key &pool does not exist, or_insert_with(HashMap::new) inserts a new HashMap as the value for this key.

.entry(&asset):

  • This accesses the entry in the inner HashMap (which is the value of the key &pool) corresponding to the key &asset.

  • If the key &asset does not exist in this inner HashMap, or_insert_with(AggregatedData::new) inserts a new AggregatedData instance as the value for this key.

.add_supply(ledger, supply_value):

  • This calls the add_supply method on the AggregatedData instance that is the value of the key &asset in the inner HashMap.

  • The add_supply method simply sets the new total_supply.

Finally, we return the aggregated_data HashMap. To summarize we have created an iterator (a map) that for each pool and each asset in that pool, points to an AggregateData object that stores all the needed aggregated data. The object's structure looks like this:

#[derive(Default, Serialize)]
pub struct AggregatedData {
    pub borrowed: Vec<(u32, i128)>,
    pub supplied: Vec<(u32, i128)>,
    pub collateral: Vec<(u32, i128)>,
    pub total_borrowed: i128,
    pub total_supply: i128,
    pub total_collateral: i128,
    pub volume_24hrs: i128,
    pub volume_week: i128,
    pub volume_month: i128,
}

Of course, now we're only working with total_supply, but this can give you an idea of all the different metrics, associated in this case with a pool, that you can aggregate data for.

3. Plot Data

The zephyr-sdk offers various helpers in the SDK to construct dashboard objects that can be plotted with the widely-utilized Apache Echarts frontend library. The SDK contains two helpers:

  • (Recommended) Higher-level API wrapper: the simplest and most straightforward way to plot the data. Learn more at Plotting: Simple.

  • Lower-level API: full chart customization, learn more at Complex Plotting.

Last Step: Creating the Dashboard Function

Now that we have everything ready, we can put it all together. The final step is to create the dashboard function, a serverless function that is called on demand and returns the dashboard with up-to-date data.

#[no_mangle]
pub extern "C" fn dashboard() {
    let env = EnvClient::empty();
    env.log().debug("Starting program", None);
    let dasboard = {
        let supplies = env.read();
        let collaterals = env.read();
        let borroweds = env.read();
        env.log().debug("Aggregating data", None);
        let timestamp = env.soroban().ledger().timestamp();
        env.log().debug(format!("Timestamp is {}", timestamp), None);
        let aggregated = aggregate_data(timestamp as i64, &supplies, &collaterals, &borroweds);
        env.log().debug("Data aggregated", None);
        let dashboard = build_dashboard(&env, aggregated, &collaterals, &borroweds);

        env.log().debug("chart built", None);
        dashboard
    };

    env.conclude(&dasboard)
}

The function does the following:

  1. Retrieve the supply, collateral, and borrow data indexed in our tables by calling the read() method.

  2. Aggregate this data using the aggregate_data() function we defined earlier.

  3. Build the dashboard using our build_dashboard() method.


Now we have the basic knowledge to build any type of dashboard. For a complete understanding of a more complex workflow and to see how to plot different types of interactive charts, you can refer to the full example.

Last updated