Skip to main content

Multi-Dataset Dashboard

Extend the KPI dashboard to combine data from multiple datasets with client-side filtering.

What You'll Build

A dashboard that:

  • Queries two datasets (orders and customers)
  • Combines data client-side for unified metrics
  • Applies date range and category filters across both datasets
  • Shows Elasticsearch query patterns (bool filters, range, terms)

Prerequisites

  • Completed KPI Dashboard
  • Two datasets in Informer (e.g., orders and customers)

Example Prompts for Claude Code

Add multi-dataset support:

"Update the dashboard to also query the admin:customers dataset. Show a separate metric card for active customer count. Update informer.yaml to grant access to both admin:orders and admin:customers."

Add filters:

"Add filter controls above the metrics: a date range dropdown (Last 7 days, Last 30 days, Last 90 days, All time) and a category dropdown populated from the dataset. When the user clicks Refresh, re-query both datasets with the new filters. Use Elasticsearch bool/filter queries."

Improve the filters:

"Make the filters apply automatically when changed — no need for a Refresh button."

"Add a 'Clear Filters' button that resets everything."

What Claude Will Generate

Claude will update your project with filter UI and multi-dataset query logic. Here's what the generated code looks like:

Data Access Configuration

datasets:
- admin:orders
- admin:customers

Filter Controls

Claude will add filter controls to the HTML:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sales Dashboard</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="dashboard">
<header>
<h1>Sales Dashboard</h1>
<p class="subtitle">Revenue and orders overview</p>
</header>

<div class="filters">
<div class="filter-group">
<label for="dateRange">Date Range</label>
<select id="dateRange">
<option value="7">Last 7 days</option>
<option value="30" selected>Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="365">Last year</option>
<option value="all">All time</option>
</select>
</div>
<div class="filter-group">
<label for="category">Category</label>
<select id="category">
<option value="">All Categories</option>
</select>
</div>
<button id="refresh" class="btn-primary">Refresh</button>
</div>

<div class="metrics">
<div class="metric-card">
<span class="metric-value" id="totalRevenue">--</span>
<span class="metric-label">Total Revenue</span>
</div>
<div class="metric-card">
<span class="metric-value" id="orderCount">--</span>
<span class="metric-label">Orders</span>
</div>
<div class="metric-card">
<span class="metric-value" id="avgOrder">--</span>
<span class="metric-label">Avg Order Value</span>
</div>
<div class="metric-card">
<span class="metric-value" id="customerCount">--</span>
<span class="metric-label">Active Customers</span>
</div>
</div>

<div class="content">
<h2>Revenue by Category</h2>
<div id="chart" style="width: 100%; height: 400px;"></div>
</div>
</div>

<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<script type="module" src="main.js"></script>
</body>
</html>

Filter Styles

Claude will add filter styling:

/* ... previous styles ... */

.filters {
display: flex;
gap: 16px;
align-items: flex-end;
margin-bottom: 24px;
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
}

.filter-group {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}

.filter-group label {
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
}

.filter-group select {
padding: 8px 12px;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}

.btn-primary {
padding: 8px 20px;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}

.btn-primary:hover {
opacity: 0.9;
}

.btn-primary:active {
opacity: 0.8;
}

@media (max-width: 768px) {
.filters {
flex-direction: column;
align-items: stretch;
}
}

/* Print: hide filters */
@media print {
.filters { display: none; }
}

Multi-Dataset Query Logic

Claude will update the JavaScript to query both datasets with filter support:

window.informerReady = false;

const theme = window.__INFORMER__?.theme || 'light';
document.documentElement.setAttribute('data-theme', theme);

// Dataset IDs
const ORDERS_DATASET = 'admin:orders';
const CUSTOMERS_DATASET = 'admin:customers';

// State
let categories = [];
let currentFilters = {
dateRange: '30',
category: ''
};

async function init() {
try {
// Load categories for filter dropdown
await loadCategories();

// Initial data load
await loadData();

// Wire up filter controls
document.getElementById('dateRange').addEventListener('change', (e) => {
currentFilters.dateRange = e.target.value;
});

document.getElementById('category').addEventListener('change', (e) => {
currentFilters.category = e.target.value;
});

document.getElementById('refresh').addEventListener('click', loadData);

window.informerReady = true;
} catch (err) {
console.error('Failed to load data:', err);
showError(err.message);
}
}

async function loadCategories() {
const response = await fetch(`/api/datasets/${ORDERS_DATASET}/_search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: { match_all: {} },
size: 0,
aggs: {
categories: {
terms: { field: 'category', size: 50 }
}
}
})
});

const result = await response.json();
categories = result.aggregations.categories.buckets.map(b => b.key);

// Populate category dropdown
const select = document.getElementById('category');
categories.forEach(cat => {
const option = document.createElement('option');
option.value = cat;
option.textContent = cat;
select.appendChild(option);
});
}

async function loadData() {
// Build Elasticsearch query filters
const filters = buildFilters();

// Query both datasets in parallel
const [ordersResult, customersResult] = await Promise.all([
queryOrders(filters),
queryCustomers(filters)
]);

// Update UI
updateMetrics(ordersResult, customersResult);
renderChart(ordersResult.aggregations.byCategory.buckets);
}

function buildFilters() {
const filters = [];

// Date range filter
if (currentFilters.dateRange !== 'all') {
const days = parseInt(currentFilters.dateRange);
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);

filters.push({
range: {
orderDate: {
gte: startDate.toISOString().split('T')[0]
}
}
});
}

// Category filter
if (currentFilters.category) {
filters.push({
term: { category: currentFilters.category }
});
}

return filters;
}

async function queryOrders(filters) {
const response = await fetch(`/api/datasets/${ORDERS_DATASET}/_search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: {
bool: { filter: filters }
},
size: 0,
aggs: {
totalRevenue: { sum: { field: 'amount' } },
orderCount: { value_count: { field: 'id' } },
avgOrder: { avg: { field: 'amount' } },
uniqueCustomers: { cardinality: { field: 'customerId' } },
byCategory: {
terms: { field: 'category', size: 10 },
aggs: {
revenue: { sum: { field: 'amount' } }
}
}
}
})
});

if (!response.ok) {
throw new Error(`Orders query failed: ${response.status}`);
}

return response.json();
}

async function queryCustomers(filters) {
// Build customer IDs filter from order results
// For simplicity, just query all active customers
// In a real scenario, you'd pass customer IDs from the orders query

const response = await fetch(`/api/datasets/${CUSTOMERS_DATASET}/_search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: {
bool: {
filter: [
{ term: { status: 'active' } }
]
}
},
size: 0,
aggs: {
customerCount: { value_count: { field: 'id' } }
}
})
});

if (!response.ok) {
throw new Error(`Customers query failed: ${response.status}`);
}

return response.json();
}

function updateMetrics(ordersResult, customersResult) {
const aggs = ordersResult.aggregations;

document.getElementById('totalRevenue').textContent =
formatCurrency(aggs.totalRevenue.value);
document.getElementById('orderCount').textContent =
aggs.orderCount.value.toLocaleString();
document.getElementById('avgOrder').textContent =
formatCurrency(aggs.avgOrder.value);
document.getElementById('customerCount').textContent =
customersResult.aggregations.customerCount.value.toLocaleString();
}

function renderChart(buckets) {
const chart = echarts.init(document.getElementById('chart'), theme === 'dark' ? 'dark' : null);

const categories = buckets.map(b => b.key);
const revenues = buckets.map(b => b.revenue.value);

chart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params) => {
const item = params[0];
return `${item.name}<br/>${formatCurrency(item.value)}`;
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: categories,
axisLabel: { rotate: 45 }
},
yAxis: {
type: 'value',
axisLabel: {
formatter: (value) => {
if (value >= 1000000) return `$${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `$${(value / 1000).toFixed(0)}K`;
return `$${value}`;
}
}
},
series: [{
name: 'Revenue',
type: 'bar',
data: revenues,
itemStyle: {
color: theme === 'dark' ? '#6366f1' : '#4f46e5'
}
}]
});

window.addEventListener('resize', () => chart.resize());
}

function formatCurrency(val) {
if (isNaN(val)) return '--';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0
}).format(val);
}

function showError(message) {
document.querySelector('.dashboard').innerHTML =
`<div style="padding: 40px; text-align: center; color: #ef4444;">
Error: ${message}
</div>`;
}

init();

Elasticsearch Query Patterns

Bool Query with Multiple Filters (AND)

{
query: {
bool: {
filter: [
{ range: { orderDate: { gte: '2024-01-01' } } },
{ term: { category: 'Electronics' } }
]
}
}
}

All filters in the array must match (AND logic).

Term Filter (Exact Match)

{ term: { status: 'active' } }
{ term: { category: 'Electronics' } }

Range Filter (Numeric or Date)

{ range: { amount: { gte: 100, lte: 1000 } } }
{ range: { orderDate: { gte: '2024-01-01', lte: '2024-12-31' } } }

Terms Filter (Match Any)

{ terms: { category: ['Electronics', 'Clothing', 'Books'] } }

Combining Filters

{
query: {
bool: {
filter: [
{ term: { status: 'active' } },
{ range: { amount: { gte: 100 } } }
],
must_not: [
{ term: { category: 'Archive' } }
]
}
}
}

Test Locally

npm run dev

Verify:

  • Category dropdown populates from dataset
  • Date range filter updates metrics
  • Category filter narrows results
  • Both datasets are queried correctly

Deploy

npm run deploy

Next Steps

Continue to Integration Dashboard to learn how to pull data from external APIs via integrations.