DEV Community

Cover image for Build a Live Site Monitoring Dashboard in 100 Lines of Python
Aaron VanSledright
Aaron VanSledright

Posted on

Build a Live Site Monitoring Dashboard in 100 Lines of Python

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
Enter fullscreen mode Exit fullscreen mode

The architecture in three pieces

  1. A shared state dict mapping URL → latest result, guarded by a lock.
  2. A background polling thread that walks the URL list, calls the API, and writes into the state dict.
  3. 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()
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Two things make this work:

  1. viewBox="0 0 100 30" plus preserveAspectRatio="none" lets the SVG stretch to whatever width the card is, without distorting the line in any meaningful way.
  2. The y-coordinate 30 - score * 0.3 works 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">
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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']}"
    })
Enter fullscreen mode Exit fullscreen mode

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},
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Persist state to SQLite. The current dict gets wiped on restart. SQLite adds maybe 10 lines and means the dashboard remembers history across deploys.
  2. Switch sequential polling to a thread pool. For 100+ URLs, sequential is too slow.
  3. 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)