My dashboard looked great. Total assets, systems, environments. Counts broken
down by resource type, by category, by provider. Little badges everywhere. It
felt like a control room.
Then I noticed something about my own behavior: I never actually did anything
from it. I'd glance at the numbers, come away none the wiser, and then click into
a sub-page to find out whether I should care. The dashboard was the thing I passed
through on the way to the thing I needed.
That's the tell. A dashboard that you skim and leave isn't a dashboard — it's a
table of contents with nicer fonts.
The three layers, and where mine stopped
A useful dashboard answers three questions in order:
- What's happening? — the signal. "Drift: 5."
- Is that urgent? — the context. 5 up from 0 last scan is an incident; 5 down from 12 is progress. Same number, opposite meaning.
- What do I do about it? — the action. A link straight into the workflow.
Mine was 100% layer 1. Here's literally what fed it:
return {
'total_assets': sum(by_type.values()),
'total_systems': sys_qs.count(),
'total_envs': env_qs.count(),
'by_type': by_type,
'by_category': by_category,
'by_provider': by_provider,
}
Raw counts. Not one of them tells you whether today is better or worse than
yesterday, and not one of them is a thing you can click and act on.
The embarrassing part: I had already built layer 2
This is the bit that stung. The context I was missing wasn't actually missing. I'd
already built it — it was just sitting in pages nobody lands on:
- a
DriftSnapshothistory table that records drift after every scan (the trend) - end-of-life data for every tracked runtime and dependency
- a
ScanJobrow withfinished_atfor every scan (the freshness)
All the materials for "is it urgent?" existed. I'd just never wired them into the
one screen where someone decides what to do next. The fix wasn't new data. It was
moving signal to where the decision happens.
The hero band
So I added a persistent row of three tiles above everything else — the first thing
you see, answering all three questions:
| Tile | Signal | Context | Action |
|---|---|---|---|
| Drift | open drift across envs | ▲/▼ vs the previous snapshot | → drift history of the worst env |
| End of life | dependencies past EOL | +N nearing EOL |
→ runtimes |
| Freshness | last completed scan | turns amber after 24h | — |
The interesting work is the context column. Take the drift delta. I want it to say
"5 drifted (▲2 since last scan)". That means: for each environment, compare its
newest snapshot to the one before it. The naive version is a query per environment.
But you can do it in one, by ordering and grouping in Python:
rows = (
DriftSnapshot.objects
.filter(environment__system__organization=org)
.order_by('environment_id', '-detected_at')
.values('environment_id', 'changed_count', 'added_count')
)
latest_by_env, prev_by_env = {}, {}
for r in rows:
env_id = r['environment_id']
total = r['changed_count'] + r['added_count']
if env_id not in latest_by_env: # first seen = newest (ordered desc)
latest_by_env[env_id] = total
elif env_id not in prev_by_env: # second seen = the one before it
prev_by_env[env_id] = total
One ordered scan, two dicts. (It's one query, but it does lean on a sort across all
snapshots — at serious scale that's the cost to watch, not the round-trips.) The
delta only compares environments that actually have a previous snapshot. A
brand-new environment shows its count with no misleading "▲5" — there was no prior
scan to compare against:
drift_current = sum(latest_by_env.values())
has_history = bool(prev_by_env)
drift_delta = None
if has_history:
cur = sum(latest_by_env[e] for e in prev_by_env)
prev = sum(prev_by_env.values())
drift_delta = cur - prev
Note that cur re-sums only the environments that have history, so the delta
compares like with like — it deliberately isn't the same number as drift_current.
The layout gotcha: the hero kept disappearing
My app is server-rendered Django with htmx. The sidebar doesn't navigate pages — it
swaps the contents of #main-content in place. My first attempt put the hero band
inside that container, and it worked beautifully until I clicked "All Resources"
and watched my brand-new dashboard evaporate. Of course it did: I'd just told htmx
to replace the element it lived in.
The fix is a one-line placement decision — the band has to live outside the
swap target:
</header>
{% include '_dashboard_hero.html' %} {# persistent — survives htmx swaps #}
<main id="main-content" class="p-6">
{% include '_system_list.html' %}
</main>
If you build a "shell + swappable body" UI, anything that's supposed to be
always-on — global signals, freshness, alerts — belongs above the swap boundary,
not in it. Obvious in hindsight; invisible until you click the wrong button.
Freshness and the empty state
Two details that decide whether people trust a dashboard.
Freshness, because a confident number from stale data is worse than no number.
Anything older than a day gets an amber tint:
last_scan_stale = (
last_scan is None
or (timezone.now() - last_scan).total_seconds() > 86400
)
The empty state, because a fresh install has zero of everything, and zero
should read as calm, not broken. When there's no drift, the tile isn't a stark
"0" — it's a green "No drift detected." A clean account should feel reassuring, not
like something failed to load.
What I deliberately left undone
Two honest edges, because the point is to ship the decision layer, not to gold-plate:
- The signals are still computed live on every request. Fine at my scale; the right next step is a periodic job writing a summary row the dashboard just reads. Until aggregation actually hurts, precompute is speculative.
- The new tiles are English-only for now — the translations are a follow-up.
Takeaways
- If you skim your own dashboard and then leave to go find out if you should care, it's all signal and no decision. Start from "what is the user trying to decide here?", not "what data can I show?"
- A raw count is layer 1. The same count versus yesterday is what makes it actionable. The delta is usually cheaper than you fear — one ordered query and a pass in Python.
- In a shell/swappable-body UI, always-on signals must live outside the swap target, or they vanish on the first navigation.
- Show data freshness, and make the empty state feel calm. Both are trust.
This is the dashboard of a self-hosted tool that tracks how your live AWS drifts
from Terraform, plus runtime EOL — open source (MIT), one docker compose up:
syncvey.com. When you design a dashboard, where do you draw
the line on context — just a number, a delta vs last run, or a full sparkline?

Top comments (0)