Multi-Dataset Dashboard
Extend the KPI dashboard to combine data from multiple datasets, with filters applied in a single server route.
What You'll Build
A dashboard that:
- Reads two datasets (orders and customers) through dependency slots
- Combines their data in one server route 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 read a customers dataset through a second dependency slot named
customers. Show a separate metric card for active customer count. Query both datasets in the server route."
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 data. Pass the filters to the server route as query params and apply them with 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, two dependency slots, and a server route that queries both datasets. Here's what the generated code looks like.
Data Access Configuration
Declare a slot per dataset in informer.yaml — the frontend never references a dataset ID:
dependencies:
orders:
target: dataset
# UUID of your orders dataset — look up via `GET /api/datasets-list`.
defaultBinding: 7d5a9b1e-0c83-4bde-9e2a-3a4b5c6d7e8f
customers:
target: dataset
defaultBinding: 9a8b7c6d-5e4f-3a2b-1c0d-fedcba987654
See the informer.yaml reference for the full slot model.
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; }
}
Server Route
Both dataset queries run in one server route via context.orders and context.customers. Filters arrive as query params; the route returns the aggregations plus the category list for the dropdown:
// server/dashboard.js
export async function GET({ context, request }) {
const filters = buildFilters(request.query);
const [orders, customers, cats] = await Promise.all([
context.orders.search({
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' } } }
}
}
}),
context.customers.search({
query: { bool: { filter: [{ term: { status: 'active' } }] } },
size: 0,
aggs: { customerCount: { value_count: { field: 'id' } } }
}),
context.orders.search({
query: { match_all: {} },
size: 0,
aggs: { categories: { terms: { field: 'category', size: 50 } } }
})
]);
return {
orders: orders.aggregations,
customers: customers.aggregations,
categories: cats.aggregations.categories.buckets.map(b => b.key)
};
}
function buildFilters(query) {
const filters = [];
if (query.dateRange && query.dateRange !== 'all') {
const days = parseInt(query.dateRange, 10);
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
filters.push({ range: { orderDate: { gte: startDate.toISOString().split('T')[0] } } });
}
if (query.category) {
filters.push({ term: { category: query.category } });
}
return filters;
}
Frontend Logic
The frontend just calls the server route with the current filters — no dataset IDs, no Elasticsearch in the browser:
window.informerReady = false;
const theme = window.__INFORMER__?.theme || 'light';
document.documentElement.setAttribute('data-theme', theme);
let currentFilters = { dateRange: '30', category: '' };
async function init() {
try {
const data = await loadData(); // first load also returns the category list
populateCategories(data.categories);
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 loadData() {
const params = new URLSearchParams({ dateRange: currentFilters.dateRange });
if (currentFilters.category) params.set('category', currentFilters.category);
const response = await fetch(`/api/_server/dashboard?${params}`);
if (!response.ok) throw new Error(`API error: ${response.status}`);
const data = await response.json();
updateMetrics(data.orders, data.customers);
renderChart(data.orders.byCategory.buckets);
return data;
}
function populateCategories(categories) {
const select = document.getElementById('category');
categories.forEach((cat) => {
const option = document.createElement('option');
option.value = cat;
option.textContent = cat;
select.appendChild(option);
});
}
function updateMetrics(ordersAggs, customersAggs) {
document.getElementById('totalRevenue').textContent = formatCurrency(ordersAggs.totalRevenue.value);
document.getElementById('orderCount').textContent = ordersAggs.orderCount.value.toLocaleString();
document.getElementById('avgOrder').textContent = formatCurrency(ordersAggs.avgOrder.value);
document.getElementById('customerCount').textContent = customersAggs.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
These shapes go into the context.<slot>.search() calls in your server route.
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 the data
- 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.