KPI tiles, Chart.js integration, and a caching strategy for expensive queries
Magento's standard dashboard shows revenue, bestsellers, and returning customers, but there's no ready-made tile for business KPIs like stock alerts or open RMA requests. Understanding the dashboard architecture, data aggregation, and a clean caching strategy lets you build custom widgets that don't hit the full database on every single dashboard load, even at scale.
Table of Contents
- 1. How the Magento dashboard is structured internally
- 2. Registering a custom widget via layout XML
- 3. Data aggregation for the stock alert widget
- 4. Open RMA requests as a second KPI tile
- 5. Integrating Chart.js in the admin context
- 6. Caching strategy for expensive dashboard queries
- 7. AJAX refresh pattern for individual tiles
- 8. ACL and permissions for custom dashboard widgets
- 9. Testing and performance monitoring
- 10. Summary
- 11. FAQ
1. How the Magento dashboard is structured internally
The admin dashboard is built on the Magento_Dashboard module and renders its tabs and tiles through classic blocks, not UI components. The main page, Magento\Dashboard\Block\Grids, acts as a container that iterates and renders individual tabs, the chart area, bestsellers, and recently ordered products via getChildNames. For a custom widget that means: it's not enough to just register another block somewhere, the widget has to be explicitly hooked into the existing dashboard layout structure as a child block.
In layout terms that means extending adminhtml_dashboard_index.xml with a custom referenceBlock on dashboard.grids, containing a block element pointing to the custom widget class. That class usually extends Magento\Backend\Block\Template, or in more complex cases with custom data aggregation, Magento\Backend\Block\Dashboard\AbstractDashboardWidget, which already provides some conventions for period filters and chart data.
2. Registering a custom widget via layout XML
The registration itself is pure layout configuration and requires no plugins or preferences, in line with the principle of extending additively rather than invasively. A custom template renders the tile with the metric, a trend arrow, and optionally a small sparkline chart, while the actual data lookup lives entirely in the PHP block and isn't computed inside the template itself.
Correct positioning via sortOrder within the dashboard container matters, otherwise new tiles appear unpredictably between existing standard widgets. For a stock alert widget, placing it right next to the bestsellers tile makes sense, since both are closely tied to the current assortment.
<!-- app/code/Mironsoft/DashboardWidgets/view/adminhtml/layout/adminhtml_dashboard_index.xml -->
<referenceBlock name="dashboard.grids">
<block class="Mironsoft\DashboardWidgets\Block\Widget\StockAlerts"
name="dashboard.widget.stock_alerts"
template="Mironsoft_DashboardWidgets::widget/stock_alerts.phtml"
after="dashboard.bestsellers"/>
</referenceBlock>
3. Data aggregation for the stock alert widget
The actual metric, how many products fall below the defined minimum stock level, isn't computed by iterating a live collection in the block, but through a targeted aggregation query against the inventory tables. With the Magento_InventorySalesApi module in place, that means querying the source item tables instead of relying solely on the older, now legacy cataloginventory_stock_item structure, since multiple warehouses otherwise wouldn't be accounted for correctly.
The block itself stays lean and delegates the actual aggregation to a dedicated, injectable service class that can be reused independently of the dashboard, in a CLI command or a notification. This separation pays off as soon as the same metric is later also needed as an email report or a dedicated admin notification.
<?php
declare(strict_types=1);
namespace Mironsoft\DashboardWidgets\Model;
use Magento\Framework\App\ResourceConnection;
/**
* Aggregates the number of products whose stock quantity
* is below the configured minimum threshold.
*/
class StockAlertAggregator
{
/**
* @param ResourceConnection $resourceConnection Database connection access
*/
public function __construct(
private readonly ResourceConnection $resourceConnection,
) {
}
/**
* Counts products below the minimum stock threshold across sources.
*
* @param int $threshold Minimum stock threshold
* @return int Number of affected products
*/
public function countBelowThreshold(int $threshold): int
{
$connection = $this->resourceConnection->getConnection();
$select = $connection->select()
->from(
['source_item' => $this->resourceConnection->getTableName('inventory_source_item')],
[new \Zend_Db_Expr('COUNT(DISTINCT source_item.sku)')]
)
->where('source_item.quantity < ?', $threshold)
->where('source_item.status = 1');
return (int) $connection->fetchOne($select);
}
}
4. Open RMA requests as a second KPI tile
If the shop runs a custom RMA module with its own entity, counting open return requests is closer to a classic collection query with addFieldToFilter on the status value, since there's no multi-warehouse concern here, just a simple status per record. Here too it matters not to count via getSize on a fully loaded collection, but through a pure count query, to avoid unnecessarily large result sets.
A raw count rarely tells the full story for a meaningful tile, comparing it against the value from a week ago is what actually delivers the relevant business trend. The same aggregation logic gets called twice with an additional time window parameter, once for the current and once for the previous period, and the difference is rendered as a trend arrow in the template.
5. Integrating Chart.js in the admin context
Magento already ships Chart.js as a bundled dependency for the standard sales chart, and a custom widget can reference that existing library via requirejs-config.js instead of bundling a second copy. The reference goes through a map alias pointing to the existing Chart.js instance, avoiding an extra load of the same library and unnecessary bloat in the admin JavaScript bundle.
Actually rendering the sparkline chart happens through a small Knockout or plain RequireJS component that receives the number series passed from the server via a data attribute and initializes on the DOM-ready event. Per project convention, registerInlineScript on the HyväCsp view model needs to be called after every embedded inline script block, and a comparable CSP hardening applies in the admin context too, so inline scripts should go through the proper mechanisms rather than a raw script tag.
// app/code/Mironsoft/DashboardWidgets/view/adminhtml/web/js/stock-alerts-sparkline.js
define(['jquery', 'chartjsAdmin'], function ($, Chart) {
'use strict';
return function (config, element) {
var ctx = element.getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: config.labels,
datasets: [{
data: config.values,
borderColor: '#f26322',
fill: false,
tension: 0.3
}]
},
options: {
plugins: { legend: { display: false } },
scales: { x: { display: false }, y: { display: false } }
}
});
};
});
6. Caching strategy for expensive dashboard queries
Running an aggregation query over several million inventory rows on every single dashboard load is noticeably slow even with a good index, as soon as several admin users open the dashboard at the same time. So the block doesn't read the metric directly from the aggregation class, but through an intermediate layer that holds the result under a dedicated cache tag in the configured cache backend, usually Redis, for a limited time.
A TTL of five to fifteen minutes is a good compromise between freshness and database load for most KPI tiles, since admin users rarely need second-precise values. A dedicated, descriptive cache tag instead of reusing an existing Magento tag also matters, so the tile can be invalidated specifically and without side effects on other cache areas, for example right after a manual stock import.
<?php
declare(strict_types=1);
namespace Mironsoft\DashboardWidgets\Model;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\Serialize\SerializerInterface;
/**
* Cached wrapper around expensive dashboard aggregations with a short TTL.
*/
class CachedStockAlertProvider
{
private const CACHE_TAG = 'mironsoft_dashboard_stock_alerts';
private const CACHE_LIFETIME = 600;
/**
* @param StockAlertAggregator $aggregator Performs the actual aggregation
* @param CacheInterface $cache Configured cache backend
* @param SerializerInterface $serializer Serializes the cached result
*/
public function __construct(
private readonly StockAlertAggregator $aggregator,
private readonly CacheInterface $cache,
private readonly SerializerInterface $serializer,
) {
}
/**
* Returns the number of affected products, from cache if present.
*
* @param int $threshold Minimum stock threshold
* @return int
*/
public function getCount(int $threshold): int
{
$cacheKey = self::CACHE_TAG . '_' . $threshold;
$cached = $this->cache->load($cacheKey);
if ($cached !== false) {
return (int) $this->serializer->unserialize($cached);
}
$count = $this->aggregator->countBelowThreshold($threshold);
$this->cache->save(
$this->serializer->serialize($count),
$cacheKey,
[self::CACHE_TAG],
self::CACHE_LIFETIME
);
return $count;
}
}
7. AJAX refresh pattern for individual tiles
Instead of reloading the whole dashboard page on every refresh, it's worth building a dedicated, lean controller for individual tiles that returns only the current metric as JSON. A small JavaScript interval based on setInterval, or more cleanly an Alpine-style polling mechanism, queries this endpoint at a configurable interval and updates only the number and the trend arrow in the DOM without re-rendering the whole page.
The controller itself reads from the same cached provider as the initial server-side rendering, so the caching strategy and data source are maintained in exactly one place. A polling interval that's too short, say every five seconds, makes little sense against a ten-minute cache TTL and just generates unnecessary requests, so the interval should be matched to the actual cache lifetime.
8. ACL and permissions for custom dashboard widgets
Not every admin user should automatically see every KPI tile, especially when RMA numbers or stock details are business-sensitive. So the block checks the current admin role against the matching ACL resource via AuthorizationInterface before rendering, and simply returns no content if the permission is missing, rather than showing an empty tile or an error message.
The ACL resource itself is declared in acl.xml like for any new module, and referenced in the system.xml menu as a dedicated configuration section for widget settings, such as the thresholds for the stock alert. This coupling ensures that both the widget's visibility and access to its configuration are controlled consistently through the same permission structure.
9. Testing and performance monitoring
For the aggregation class itself, an integration test that checks the generated SQL query against a known test data set is worth more than just testing the template rendering. That catches regressions in the counting logic early, for example after a change to the inventory structure, before they show up as a wrong number on the live dashboard.
For production, it's also worth checking the MySQL slow query log after deploying a new widget, especially when the underlying table has several million rows. A missing index on the filter column often goes unnoticed on a local test system with a few thousand test rows, but quickly becomes a noticeable performance problem on a production catalog for every admin user logged in at the same time.
| Widget Type | Data Source | Refresh | Recommended Cache TTL |
|---|---|---|---|
| Stock alert | inventory_source_item aggregation | AJAX polling | 5-10 minutes |
| Open RMA requests | custom RMA collection with status filter | AJAX polling | 2-5 minutes |
| Revenue sparkline (custom period) | sales_order aggregation | on page load | 10-15 minutes |
| Failed bulk operations | OperationRepositoryInterface | AJAX polling | 1-2 minutes |
| New customer registrations | customer_entity aggregation | on page load | 10-15 minutes |
Mironsoft
Magento development, module consulting, and system architecture
A Magento project that needs a second opinion or experienced execution?
We build custom Magento modules, advise on architecture decisions, and take on complex implementations, from service contract planning to production-ready deployment.
Architecture Consulting
Have module and system architecture thought through properly before you build.
Custom Module Development
Build custom Magento modules cleanly, following best practices.
Code Review & Audit
Have existing modules reviewed for performance, security, and maintainability.
10. Summary
Custom Dashboard Widgets in Magento 2: The Essentials
Architecture
Hook custom blocks into dashboard.grids via referenceBlock in adminhtml_dashboard_index.xml.
Data aggregation
Use targeted count and aggregation queries instead of fully loaded collections.
Visualization
Reference the existing Chart.js instance via requirejs-config.js instead of bundling a new one.
Operations
Short cache TTL with a dedicated tag, AJAX refresh instead of a full page reload, ACL-checked rendering.