There's a class of internal tool that's perpetually under-built: the "look at this metric across our things" dashboard. Status of the deploy. Score of the SEO audit. Latency of the regional endpoints. We treat these like full-stack projects when they should be one Python file.
This post walks through a 95-line single-file Flask dashboard I built to monitor SEO scores across a list of URLs. The same architecture works for any periodic JSON endpoint — score cards with deltas and sparklines, auto-refresh in the browser, runs anywhere Flask runs.
You can use the live version right now without signing up for anything: https://seoscoreapi.com/demo/dashboard. And the entire source is one downloadable file: /downloads/dashboard.py.
What we're building
A page that:
- Polls a list of URLs every N minutes through an external API
- Renders one card per URL with score, grade, delta vs. previous poll, category breakdown, and a 30-day sparkline
- Highlights cards green/yellow/red based on score movement
- Auto-refreshes in the browser via meta tag (no JS framework)
- Caches results in memory so the page renders instantly even when the API call is mid-flight
No build step. No webpack. No Redis. No celery. One Python file.
The data source
I'm using SEO Score API because it's what I work on, but the pattern is identical for any JSON API that returns a numeric score per URL plus a timeseries endpoint for history. If you're following along with a different API, swap the two SDK calls (audit() and history()) for whatever your data source provides.
Install:
pip install flask seoscoreapi
The architecture in three pieces
- A shared state dict mapping URL → latest result, guarded by a lock.
- A background polling thread that walks the URL list, calls the API, and writes into the state dict.
- A Flask route that reads the state dict and renders an HTML template.
Every other piece — the sparkline math, the category breakdown, the auto-refresh — is template stuff. The architecture is just three things.
The state dict
state = {url: {"loading": True} for url in URLS}
state_lock = threading.Lock()
That's the entire data model. The dict is keyed by URL; values are whatever the latest poll produced. Initial state is {"loading": True} for each URL, which the template can render as a loading state until the first real poll completes.
The lock is necessary because the background thread writes while the Flask route reads. Both happen on different threads.
The polling function
def refresh_one(url):
"""Run an audit + fetch history for one URL, store result in `state`."""
try:
result = audit(url, api_key=API_KEY)
hist = history(url, api_key=API_KEY, limit=30).get("series", [])
prev = state.get(url, {}).get("score")
delta = (result["score"] - prev) if prev is not None else None
with state_lock:
state[url] = {
"score": result["score"],
"grade": result["grade"],
"categories": {k: v.get("score") for k, v in result.get("audit", {}).items()},
"delta": delta,
"status": status_for(delta),
"spark": [h["score"] for h in hist[-30:]],
"fetched_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
"error": None,
}
except Exception as e:
with state_lock:
state[url] = {**state.get(url, {}), "error": str(e), "status": "error"}
Two API calls per URL. audit() returns the current score and category breakdown; history() returns the timeseries for the sparkline. The delta is computed by subtracting the previous score (held in the state dict from the last poll) from the new one.
The try/except is intentional. Exceptions during a poll shouldn't kill the thread — they should mark the card as error and let the next poll retry.
The polling loop
def poll_loop():
while True:
for url in URLS:
refresh_one(url)
time.sleep(POLL_INTERVAL)
threading.Thread(target=poll_loop, daemon=True).start()
A daemon thread that walks the URL list serially and sleeps between cycles. Sequential, not concurrent, on purpose: it's polite to the API and the dashboard's job isn't to hammer.
For a small URL list (< 25 URLs polled every 15 min) one thread is plenty. If you need to monitor hundreds of URLs, swap the inner loop for concurrent.futures.ThreadPoolExecutor(max_workers=10). But check your API's rate limit first.
The Flask route
@app.route("/")
def home():
with state_lock:
return render_template_string(TEMPLATE, state=dict(state))
That's it. The route copies the state dict (cheap, ~hundreds of small dicts) under the lock and renders. The page never waits on the API — it always returns whatever the last poll produced. If a poll is in flight, the user sees yesterday's number with yesterday's timestamp; if it's fresh, they see the latest.
The status colors
def status_for(delta):
if delta is None:
return "neutral"
if delta <= -5:
return "critical"
if delta < 0:
return "warning"
return "ok"
A single function decides the card color. -5 or worse is red, any negative is yellow, anything else is green. The thresholds are arbitrary; tune to your data.
The sparkline
The sparkline is an inline SVG <polyline> with points computed in the Jinja template:
<svg viewBox="0 0 100 30" preserveAspectRatio="none" width="100%">
<polyline fill="none" stroke="#60a5fa" stroke-width="1.5"
points="{% for s in data.spark %}{{ loop.index0 * (100 / (data.spark|length - 1)) }},{{ 30 - (s * 0.3) }} {% endfor %}" />
</svg>
Two things make this work:
-
viewBox="0 0 100 30"pluspreserveAspectRatio="none"lets the SVG stretch to whatever width the card is, without distorting the line in any meaningful way. - The y-coordinate
30 - score * 0.3works because scores are 0–100 and we want them on a 0–30 viewBox. For a different range, normalize the values first or change the multiplier.
That's the whole sparkline. No Chart.js, no D3, no canvas tricks. Inline SVG is underrated for this kind of single-purpose visualization.
The auto-refresh
<meta http-equiv="refresh" content="60">
Yes, a <meta refresh> tag. It works perfectly for this use case and requires zero JavaScript. The browser refreshes the page every 60 seconds; the route reads the latest state dict; you see fresh data.
If you need finer control (e.g., update without losing scroll position) you'd swap this for a small fetch() polling loop. For a hallway-TV dashboard, the meta refresh is fine.
The full file
About 95 lines including the template:
import os
import threading
import time
from datetime import datetime
from flask import Flask, render_template_string
from seoscoreapi import audit, history
API_KEY = os.environ["SEOSCORE_API_KEY"]
URLS = [u.strip() for u in os.environ["DASHBOARD_URLS"].split(",") if u.strip()]
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL_SECONDS", "900"))
app = Flask(__name__)
state = {url: {"loading": True} for url in URLS}
state_lock = threading.Lock()
def status_for(delta):
if delta is None: return "neutral"
if delta <= -5: return "critical"
if delta < 0: return "warning"
return "ok"
def refresh_one(url):
try:
result = audit(url, api_key=API_KEY)
hist = history(url, api_key=API_KEY, limit=30).get("series", [])
prev = state.get(url, {}).get("score")
delta = (result["score"] - prev) if prev is not None else None
with state_lock:
state[url] = {
"score": result["score"],
"grade": result["grade"],
"categories": {k: v.get("score") for k, v in result.get("audit", {}).items()},
"delta": delta,
"status": status_for(delta),
"spark": [h["score"] for h in hist[-30:]],
"fetched_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
"error": None,
}
except Exception as e:
with state_lock:
state[url] = {**state.get(url, {}), "error": str(e), "status": "error"}
def poll_loop():
while True:
for url in URLS:
refresh_one(url)
time.sleep(POLL_INTERVAL)
threading.Thread(target=poll_loop, daemon=True).start()
TEMPLATE = """...""" # see full source for the HTML
@app.route("/")
def home():
with state_lock:
return render_template_string(TEMPLATE, state=dict(state))
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
The HTML template is roughly 50 lines of Jinja + CSS — readable, no framework, no build step. Grab the full file here.
Running it
export SEOSCORE_API_KEY=sk_your_key_here
export DASHBOARD_URLS="https://yoursite.com,https://yoursite.com/pricing"
python dashboard.py
Open http://localhost:8080. The first poll runs immediately, so within a few seconds the cards populate.
Deploying it
Three lines of Dockerfile:
FROM python:3.12-slim
RUN pip install flask seoscoreapi
COPY dashboard.py /app/dashboard.py
ENV SEOSCORE_API_KEY="" DASHBOARD_URLS=""
CMD python /app/dashboard.py
Drop on Fly.io, Railway, a Pi, a $5 VPS, or a Kubernetes pod. Stateless — just provision env vars and go.
Where to extend it
Three extensions I get asked about, all in 5–10 lines on top of the baseline:
1. Webhook alerts on regression
Add to refresh_one:
if delta is not None and delta <= -5:
requests.post(SLACK_WEBHOOK, json={
"text": f":warning: {url} dropped {delta} points to {result['score']}"
})
2. Per-URL alert thresholds
Replace URLS with a config dict:
URLS = {
"https://yoursite.com": {"critical_drop": 3, "warning_drop": 1},
"https://yourblog.com": {"critical_drop": 8, "warning_drop": 4},
}
Then status_for(delta, thresholds) checks the per-URL values.
3. Multi-team views
Add a ?team=growth query param. URLs gain a team field. The route filters:
team = request.args.get("team")
filtered = {u: d for u, d in state.items() if not team or d.get("team") == team}
Same dashboard instance serves every team's view.
What I'd do differently for production
If this dashboard graduated from "office hallway TV" to "monitoring our customer-facing thing", I'd change three things:
- Persist state to SQLite. The current dict gets wiped on restart. SQLite adds maybe 10 lines and means the dashboard remembers history across deploys.
- Switch sequential polling to a thread pool. For 100+ URLs, sequential is too slow.
-
Move the meta refresh to a
fetch()loop. Avoids losing scroll position on long pages and lets us update without a full reload.
None of these are necessary for the v0. Premature complexity is its own problem.
The takeaway
You don't need a SaaS observability product to know when one of your metrics regresses. A Flask file, an external API, and 30 minutes is enough for most internal monitoring needs. The interesting bits — the polling pattern, the inline SVG sparkline, the meta refresh — are decades-old techniques that still work fine.
The full source: https://seoscoreapi.com/downloads/dashboard.py
The live demo: https://seoscoreapi.com/demo/dashboard
If you build something interesting on top of this, I'd love to see it.
Top comments (0)