Building a Real‑Time Metrics Dashboard with Elasticsearch, Flask, and Vue + Chart.js
A hands‑on, end‑to‑end tutorial: store time‑series product metrics in Elasticsearch, expose them through a Flask API, and visualize them in…
Building a Real‑Time Metrics Dashboard with Elasticsearch, Flask, and Vue + Chart.js
A hands‑on, end‑to‑end tutorial: store time‑series product metrics in Elasticsearch, expose them through a Flask API, and visualize them in a clean Vue 3 dashboard with Chart.js.

What we’re building
By the end of this tutorial, you’ll have a fully containerized dashboard that:
- Stores time‑series product metrics in Elasticsearch (one index per month).
- Uses an Elasticsearch index template so every monthly index gets the exact same field mappings automatically.
- Ships a CLI command that simulates a month of fake data (great for demos and local testing).
- Exposes a Flask REST API that aggregates the data by day.
- Renders an interactive Vue 3 + Chart.js line chart with month/year filters and metric groups.
Here’s the architecture:
┌──────────────┐ ┌──────────────┐ ┌────────────────────┐
│ Vue 3 + │ │ Flask │ │ Elasticsearch │
│ Chart.js │─────▶│ REST API │─────▶│ metric-products-* │
│ (Nginx :80) │ HTTP │ (:5000) │ ES │ (:9200) │
└──────────────┘ └──────────────┘ └────────────────────┘
Everything runs in Docker, so you don’t need Python, Node, or Elasticsearch installed locally — just Docker.
Tech stack: Python 3.11 / Flask 3 · Elasticsearch 8 · Vue 3 / Vite · Chart.js 4 · Docker Compose
Prerequisites
- Docker & Docker Compose
- A running Elasticsearch + Kibana stack. If you don’t have one, the Appendix at the end shows a minimal
docker-composesetup to get you started.
Part 1 — Project layout
metric_elastic_search_vue_chart/
├── docker-compose.yml
├── backend/
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── app.py # Flask API
│ ├── cli.py # Flask CLI commands
│ ├── create_template.py # Creates the ES index template
│ └── simulate_product_data.py# Generates fake monthly data
└── frontend/
├── Dockerfile
├── nginx.conf
├── package.json
└── src/
├── App.vue
├── use/useChart.js # Chart.js composable
└── components/ProductMetricsChart.vue
Part 2 — Wiring the containers
The whole system is orchestrated by docker-compose.yml. The key idea: our frontend and backend join the same Docker network as our existing Elasticsearch stack, allowing the backend to reach Elasticsearch by its service name.
services:
# Frontend - Vue.js served by Nginx
frontend:
build: ./frontend
container_name: metric_frontend
ports:
- "80:80"
depends_on:
- backend
networks:
- metrics_core
- existing_elk_network
restart: unless-stopped
# Backend - Python Flask API
backend:
build: ./backend
container_name: metric_backend
ports:
- "5000:5000"
environment:
- FLASK_ENV=development
- FLASK_DEBUG=1
- ELASTICSEARCH_HOST=elasticsearch
- ELASTICSEARCH_PORT=9200
- ELASTICSEARCH_URL=http://elasticsearch:9200
- ELASTICSEARCH_USER=elastic
- ELASTICSEARCH_PASSWORD=changeme
volumes:
- ./backend:/app # live code reload in dev
- ./logs:/app/logs
networks:
- metrics_core
- existing_elk_network
restart: unless-stopped
networks:
metrics_core:
driver: bridge
existing_elk_network:
external: true # the network the ES stack already created
Two things worth highlighting:
**external: true** — We attach to a network that another stack (Elasticsearch + Kibana) already created. That's why the backend can safely usehttp://elasticsearch:9200as a hostname.- Security Credentials — Elasticsearch 8 has security enabled by default. Requests without credentials will return a
401 Unauthorized. We pass these in via environment variables.
Part 3 — The Elasticsearch index template (create it once)
Time‑series data in Elasticsearch is usually split into many indices (e.g., one per month). To ensure every index shares the same field types, you register an index template using a wildcard pattern. Any index whose name matches the pattern inherits the template’s settings and mappings automatically.
import os
from elasticsearch import Elasticsearch
class ElasticsearchTemplateManager:
def __init__(self, host='elasticsearch', port=9200, user=None, password=None):
self.es_url = f'http://{host}:{port}'
user = user if user is not None else os.environ.get('ELASTICSEARCH_USER', 'elastic')
password = password if password is not None else os.environ.get('ELASTICSEARCH_PASSWORD')
auth = (user, password) if password else None
self.client = Elasticsearch([self.es_url], basic_auth=auth)
def create_metric_products_template(self):
template_name = 'metric-products-template'
index_pattern = 'metric-products*' # 👈 the magic wildcard
settings = {'number_of_shards': 1, 'number_of_replicas': 1}
mappings = {
'properties': {
'timestamp': {'type': 'date'},
'id': {'type': 'keyword'},
'count_products': {'type': 'integer'},
'count_views': {'type': 'integer'},
'count_product_average': {'type': 'double'},
'count_product_sum': {'type': 'double'},
'user': {
'properties': {
'id': {'type': 'keyword'},
'name': {'type': 'text'},
}
},
}
}
self.client.indices.put_template(
name=template_name,
body={
'index_patterns': [index_pattern],
'settings': settings,
'mappings': mappings,
},
)
The most important concept here: The pattern metric-products* means that an index created as metric-products-2026.06 will automatically map fields like timestamp and count_products correctly. You create the template once and forget about it.
⚠️ Note: We use the legacy
_templateAPI here. Modern Elasticsearch prefers composable templates (_index_template), but the legacy version is simpler to learn and highly effective. Just remember: to inspect it later, useGET _template/..., notGET _index_template/....
Part 4 — Exposing commands through the Flask CLI
Flask allows you to register custom CLI commands. We can wrap our template manager and data simulator in friendly commands for easy execution.
import click
from datetime import datetime
from flask import Flask
from create_template import ElasticsearchTemplateManager
from simulate_product_data import ProductDataSimulator
def register_cli(app: Flask):
@app.cli.command('create-template')
def store_template_metric_products():
"""Create the metric-products index template."""
manager = ElasticsearchTemplateManager()
manager.create_metric_products_template()
@app.cli.command('simulate-product-data')
@click.option('--month', type=int, required=True, help='Month number (1-12)')
@click.option('--year', type=int, default=lambda: datetime.utcnow().year)
def simulate_product_data(month, year):
"""Populate one monthly index with fake data."""
simulator = ProductDataSimulator()
simulator.simulate_month(month=month, year=year)
Register it in app.py:
from cli import register_cli
register_cli(app)
Now we can run these commands directly from our host machine:
docker exec metric_backend flask create-template
docker exec metric_backend flask simulate-product-data --month 6 --year 2026
Part 5 — Generating realistic fake data
For learning and demonstrations, we want a month of believable data. simulate_product_data.py creates one monthly index and inserts one document per user per day.
import random
import uuid
import calendar
from datetime import datetime
from elasticsearch.helpers import bulk
FAKE_USERS = [
{'id': '1', 'name': 'Alice Johnson'},
{'id': '2', 'name': 'Bob Smith'},
{'id': '3', 'name': 'Carol Williams'},
{'id': '4', 'name': 'David Brown'},
{'id': '5', 'name': 'Eve Davis'},
]
class ProductDataSimulator:
# Initialization and client setup omitted for brevity...
def _index_name(self, year, month):
return f"metric-products-{year:04d}.{month:02d}"
def _ensure_index(self, index_name):
if not self.client.indices.exists(index=index_name):
self.client.indices.create(index=index_name)
def _fake_doc(self, day, user):
count_products = random.randint(10, 500)
average = round(random.uniform(1.0, 100.0), 2)
return {
'timestamp': day.replace(
hour=random.randint(0, 23),
minute=random.randint(0, 59)
).isoformat(),
'id': str(uuid.uuid4()),
'count_products': count_products,
'count_views': random.randint(count_products, count_products * 20),
'count_product_average': average,
'count_product_sum': round(average * count_products, 2),
'user': user,
}
def simulate_month(self, month, year):
days_in_month = calendar.monthrange(year, month)[1]
index_name = self._index_name(year, month)
self._ensure_index(index_name)
actions = []
for day_num in range(1, days_in_month + 1):
day = datetime(year, month, day_num)
for user in FAKE_USERS:
actions.append({'_index': index_name, '_source': self._fake_doc(day, user)})
bulk(self.client, actions)
self.client.indices.refresh(index=index_name)
Key design choices:
- One index per month: Daily indices explode your index count fast. Monthly is the sweet spot for this volume.
**bulk()helper:** Sending 150 separate requests is slow; thebulkhelper batches them into a single efficient call.**refresh()at the end:** Elasticsearch isn't real‑time by default. Refreshing makes the new docs immediately searchable for the dashboard.
⚠️ Gotcha — re‑running appends! Because we use auto-generated UUIDs, running the simulator twice will give you duplicate documents. To start fresh, delete the index first via Kibana or the Python client.
Part 6 — The aggregation API in Flask
The frontend doesn’t want raw documents — it wants one summary per day. Elasticsearch’s date_histogram aggregation buckets documents by calendar day, allowing us to add sub‑aggregations to sum or average the metrics.
@app.route('/api/products/metrics', methods=['GET'])
def get_product_metrics():
month = request.args.get('month', datetime.utcnow().month, type=int)
year = request.args.get('year', datetime.utcnow().year, type=int)
index = f'metric-products-{year:04d}.{month:02d}'
try:
response = es.search(
index=index,
size=0, # We only want aggregations, not raw hits
aggs={
'by_day': {
'date_histogram': {
'field': 'timestamp',
'calendar_interval': 'day',
'format': 'yyyy-MM-dd',
'min_doc_count': 0,
},
'aggs': {
'count_products': {'sum': {'field': 'count_products'}},
'count_views': {'sum': {'field': 'count_views'}},
'count_product_average': {'avg': {'field': 'count_product_average'}},
'count_product_sum': {'sum': {'field': 'count_product_sum'}},
},
}
},
)
except Exception:
return jsonify({'status': 'error', 'error': f'No data for {index}', 'days': []}), 404
days = []
for bucket in response['aggregations']['by_day']['buckets']:
dt = datetime.strptime(bucket['key_as_string'], '%Y-%m-%d')
days.append({
'date': bucket['key_as_string'],
'day_number': dt.strftime('%d'),
'day_name': dt.strftime('%a'),
'doc_count': bucket['doc_count'],
'count_products': round(bucket['count_products']['value'] or 0),
'count_views': round(bucket['count_views']['value'] or 0),
'count_product_average': round(bucket['count_product_average']['value'] or 0, 2),
'count_product_sum': round(bucket['count_product_sum']['value'] or 0, 2),
})
return jsonify({'status': 'success', 'index': index, 'days': days})
Why size=0? Setting size: 0 tells Elasticsearch to skip returning the actual documents, returning only the computed buckets. It's much faster and saves bandwidth.
Part 7 — The chart composable (Vue 3 + Chart.js)
On the frontend, isolating the Chart.js logic into a reusable composable (src/use/useChart.js) keeps the component clean.
import { ref } from 'vue'
import Chart from 'chart.js/auto'
export default function () {
const labels = ref([])
const chartData = ref({})
const selectedGroup = ref('products')
const metricFields = [
{ name: 'count_products', label: 'Products', color: 'rgb(54, 162, 235)', group: 'products' },
{ name: 'count_views', label: 'Views', color: 'rgb(255, 206, 86)', group: 'products' },
{ name: 'count_product_average', label: 'Average Price', color: 'rgb(83, 171, 29)', group: 'pricing' },
{ name: 'count_product_sum', label: 'Sum of Prices', color: 'rgb(15, 68, 141)', group: 'pricing' },
]
const chartGroups = {
products: {
title: 'Products',
fields: metricFields.filter(f => f.group === 'products'),
rightAxisFields: [],
},
pricing: {
title: 'Prices',
fields: metricFields.filter(f => f.group === 'pricing'),
rightAxisFields: ['count_product_average', 'count_product_sum'],
},
}
// Formatting and drawing logic...
const setChart = () => {
const ctx = document.getElementById('my-chart')
if (!ctx) return
const group = chartGroups[selectedGroup.value]
const datasets = group.fields.map(field => ({
label: field.label,
data: chartData.value[field.name] || [],
borderColor: field.color,
fill: false,
tension: 0.2, // Adds a slight curve for a cleaner, modern look
yAxisID: group.rightAxisFields.includes(field.name) ? 'y-price' : 'y-count',
}))
new Chart(ctx, {
type: 'line',
data: { labels: labels.value, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
'y-count': { type: 'linear', position: 'left', beginAtZero: true },
'y-price': {
type: 'linear', position: 'right', beginAtZero: true,
display: group.rightAxisFields.length > 0,
ticks: {
callback: v => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(v),
},
},
},
},
})
}
const destroyChart = () => {
const ctx = document.getElementById('my-chart')
const existing = ctx && Chart.getChart(ctx)
if (existing) existing.destroy()
}
return { chartGroups, selectedGroup, /* ... */ setChart, destroyChart }
}
Concepts to take away:
- Dual Y‑axes: Counts and prices live on very different scales. Putting prices on a separate right axis (
y-price) keeps both lines readable. **destroyChart():** Chart.js will throw a "Canvas is already in use" error if you mount a new chart over an existing one. Always destroy the old instance first.
Part 8 — The chart component
src/components/ProductMetricsChart.vue connects the dots: fetch data → feed the composable → draw. Keeping your CSS minimalist here—using simple dropdowns, rounded corners, and flat colors—will make the resulting dashboard look highly professional.
<template>
<section class="section">
<h2>📦 Product Metrics</h2>
<div class="filters">
<select v-model.number="month" @change="loadData">
<option v-for="(name, i) in monthNames" :key="i" :value="i + 1">{{ name }}</option>
</select>
<input v-model.number="year" type="number" @change="loadData" />
</div>
<div class="group-radios">
<label v-for="(group, key) in chartGroups" :key="key">
<input type="radio" :value="key" v-model="chartGroupSelection" @change="onGroupToggle" />
{{ group.title }}
</label>
</div>
<div class="chart-wrapper">
<canvas id="my-chart"></canvas>
</div>
</section>
</template>
<script setup>
import { ref, nextTick, onMounted } from 'vue'
import axios from 'axios'
import useChart from '../use/useChart'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000'
const { chartGroups, setSelectedGroup, setChartData, setChart, destroyChart } = useChart()
const month = ref(6)
const year = ref(2026)
const chartGroupSelection = ref('products')
const days = ref([])
const renderChart = () => {
setSelectedGroup(chartGroupSelection.value)
setChartData(days.value)
destroyChart()
nextTick(() => setChart()) // Wait for the canvas to exist, then draw
}
const onGroupToggle = () => renderChart()
const loadData = async () => {
const res = await axios.get(`${API_URL}/api/products/metrics`, {
params: { month: month.value, year: year.value },
})
days.value = res.data.days || []
await nextTick()
renderChart()
}
onMounted(loadData)
</script>
The nextTick() pattern is critical. Vue updates the DOM asynchronously. If you call setChart() immediately after changing data, the <canvas> might not be rendered yet. nextTick() waits until Vue flushes the DOM so the canvas is guaranteed to exist.
Part 9 — Run the whole thing
# 1. Start everything
docker compose up -d --build
# 2. Create the index template (once)
docker exec metric_backend flask create-template
# 3. Generate a month of data
docker exec metric_backend flask simulate-product-data --month 6 --year 2026
# 4. Open the dashboard at http://localhost
Part 10 — Hard‑won debugging lessons
These are the real bumps you’ll hit — and how to fix them.
1. ModuleNotFoundError: No module named 'elasticsearch' The container was built before elasticsearch was added to requirements.txt. A stale image won't pick up new dependencies. Fix: Rebuild via docker compose up -d --build backend.
2. Elasticsearch returns 401 Unauthorized ES 8 has security on by default. Fix: Pass basic_auth=(user, password) to every client instance and feed the credentials via environment variables.
3. “Wildcard expressions or all indices are not allowed” ES blocks wildcard deletes by default as a safety feature. Fix: List the indices first, then delete them by exact name:
for idx in es.indices.get(index='metric-products-2026.06*'):
es.indices.delete(index=idx)
4. The template doesn’t show up under GET _index_template/... Because we used the legacy _template API, it must be inspected via GET _template/metric-products-template.
Result

Selecting by Product

Selecting by Price
👉 Git Hub Code -
https://github.com/murilolivorato/metrics_elasticsearch_dashboard_with_vue_chart
Conclusion
Building a metrics dashboard doesn’t mean you have to sacrifice performance for visual appeal. The true strength of this stack lies in its separation of concerns: it perfectly balances a heavy-lifting backend with a sleek, minimalist frontend.
By pushing the heavy data aggregation down to Elasticsearch (using date_histogram and sub-aggregations), we keep our Flask API incredibly lightweight. It only fetches exactly what the client needs—nothing more. This allows Vue and Chart.js to do what they do best: render a clean, uncluttered, and highly responsive user interface without getting bogged down by massive data payloads.
Whether you are tracking simple product views or scaling up to millions of complex time-series events, this architecture provides a rock-solid foundation. You now have a fully containerized environment, an automated way to seed realistic data, and a flexible UI component that can easily be adapted for different metric types.
Thanks a lot for reading till end. Follow or contact me via:
Github:https://github.com/murilolivorato LinkedIn: https://www.linkedin.com/in/murilo-livorato-80985a4a/
메타데이터
- post_id
- bb888018df37
- slug
- building-a-real-time-metrics-dashboard-with-elasticsearch-flask-and-vue-chart-js-bb888018df37
- url
- https://medium.com/@murilolivorato/building-a-real-time-metrics-dashboard-with-elasticsearch-flask-and-vue-chart-js-bb888018df37
- canonical_url
- https://medium.com/@murilolivorato/building-a-real-time-metrics-dashboard-with-elasticsearch-flask-and-vue-chart-js-bb888018df37
- author_url
- https://medium.com/@murilolivorato
- status
- ok
- fetched_at
- 2026-06-15 20:49:13