The Dead Service That Stayed "Online" for a Year: Turning a Hardcoded Dashboard into a DB-Driven App Launcher
Side projects pile up. One day I counted nine web apps and three mobile apps, all fronted by a personal portal. Opening its dashboard after a long while, I found the card for a trading bot I had retired a year ago — still wearing a bright green "Online" badge. This is the story of replacing a fully hardcoded dashboard with an app launcher that generates its cards from an inventory database and actually probes each app's liveness.
1. The Problem: Cards Are Yesterday's Truth
The dashboard was built the way many personal dashboards are: every app card hand-written into an HTML template — an SVG icon, a description, feature tags, and a status badge that literally said "Online" as static text. At 30–40 lines per card, ten cards meant a 340-line template.
Three things were wrong with it.
- Dead cards. The retired trading bot and a decommissioned web terminal still had cards. Clicking them returned 502 — under a green "Online" badge, because the badge was a string, not a measurement.
- A tax on every new app. Shipping a new web app meant routing, process registration, and "write the card HTML, add the CSS gradient." Friction like that gets skipped, and the dashboard drifts away from reality.
- Duplicated truth. I already maintained an app inventory database for operations — which app runs where, on which port, in what state. The dashboard ignored it entirely and kept its own stale list. When the same fact lives in two places, one of them is always lying.
2. The Design: Fix the Data, Not the Screen
A pure visual refresh would have left the root cause — "edit HTML for every new app" — untouched. So the direction changed: move the cards from code into data.
The operational inventory table already had the runtime fields (name, path, port, status). It only needed a few display columns:
ALTER TABLE app_inventory ADD COLUMN title TEXT; -- card title
ALTER TABLE app_inventory ADD COLUMN icon TEXT; -- emoji icon
ALTER TABLE app_inventory ADD COLUMN blurb TEXT; -- one-line description
ALTER TABLE app_inventory ADD COLUMN category TEXT; -- apps/tools/external/mobile
ALTER TABLE app_inventory ADD COLUMN portal INTEGER DEFAULT 0; -- show on portal
ALTER TABLE app_inventory ADD COLUMN sort INTEGER DEFAULT 100; -- ordering
On the portal side, a small registry module reads rows where portal=1, assembles URLs (external domains open in a new tab), and checks liveness by actually attempting a TCP connection to each app's port. Probing every port on every page load would be wasteful, so results are cached for 30 seconds — and if a probe pass fails temporarily, the previous cache is kept so the dashboard never renders empty.
def portal_apps(force=False):
# 30s cache + keep last-good cache on failure
rows = "SELECT * FROM app_inventory WHERE portal=1 AND status != 'retired'"
for r in rows:
alive = tcp_connect("127.0.0.1", r.port, timeout=0.3) # measured, not asserted
The template is now a 20-line loop over category sections. Icons became emoji instead of inline SVG, shrinking each card from ~30 lines of markup to 8. Dead apps get dimmed automatically with a "not responding" label. The search box filters cards client-side via a data-search attribute — no server round trip.
3. The Result: Adding an App Is One CLI Line
Putting a new web app on the portal now looks like this:
inv set my-new-app kind=web path=/newapp/ port=8123 \
portal=1 title="New App" icon=🎨 blurb="One-line description" category=apps
No HTML, no CSS, no redeploy. The dashboard picks up the new card on the next request. Retire an app (status=retired) and its card disappears. A dead card surviving for a year is now structurally impossible.
Two bonuses came almost for free:
- Mobile app cards. Three Android apps distributed through store internal testing got install-link rows in the same table. External domain → automatic new-tab handling. A whole "Mobile" section appeared with zero code changes.
- On-demand desktop packaging. The desktop app has no store, so a download route packs the current source into a tarball at request time (dependency folders excluded — 0.02s). Pre-built archives that quietly go stale are no longer a thing.
4. Lessons
- The less a screen changes, the more it belongs in data. "Occasionally edited" screens are where hardcoding is most tempting — and where it rots longest, precisely because edits are rare.
- Measure status; never assert it. Writing "Online" and connecting to the port right now are entirely different pieces of information. A probe plus a short cache costs almost nothing.
- Use the operational data you already have as the UI's source. When the runbook and the screen read the same table, documentation becomes deployment. Not writing the same fact twice is the cheapest synchronization there is.
FAQ
Q. If the database goes down, doesn't the dashboard go down with it?
The registry keeps the last successful list in memory. If the DB is momentarily locked or a read fails, the previous list is served. Only a cold start with a dead DB yields an empty dashboard — at which point you have bigger problems than the dashboard.
Q. Doesn't port probing burden the services?
It is a single TCP connection attempt with a 0.3-second timeout, cached for 30 seconds — at most two probes per minute. That is noise compared to what a health-check cron already does.
Q. How do I reorder or hide cards?
Change the sort number or set portal=0. One CLI line; takes effect on the next page load.
Q. Why not a static site generator or a frontend framework?
This portal is a server-rendered Flask app showing a dozen cards. A 20-line template loop is enough; adding a build pipeline there solves a problem I don't have. I scale the tooling when the scale creates the problem.


