Why Your Monitoring Dashboard Shows Zero Clients After Auto-Discovery
**If a working integration on your self-hosted monitoring dashboard — a UniFi controller, a NAS, anything polled over HTTPS — suddenly reads "0 clients" or shows an error right after you ran a network scan or auto-discovery, the integration probably didn’t break. A duplicate of it did.** In my case, auto-discovery invented a second "UniFi" integration pointing at a completely different box, and because poll results were stored keyed by service type rather than by instance, that failing duplicate overwrote the real controller’s data on every poll cycle.
What you’ll learn: two failure modes that bite almost every DIY polling/monitoring platform — over-eager service fingerprinting, and result collisions from keying by type instead of instance — and how to fix both.
Evidence note: from my own infrastructure-monitoring backend. The root cause is verified in the live code; hostnames and IPs are replaced with placeholders.
The symptom
The UniFi tile on my dashboard had been showing real client counts for weeks. Then, after I ran the "discover devices" routine, it flipped to an error state — 0 clients, a connection error — even though the controller itself was completely healthy and reachable. Nothing about the controller had changed.
The false lead
The obvious assumption: the controller’s credentials or API key had expired. They hadn’t — I could hit the controller’s API directly and get clients back instantly. So the data existed; something on the dashboard side was throwing it away.
Root cause: two bugs that compound
Bug 1 — discovery flags anything on port 443 as the service
The discovery routine probes each host’s open HTTPS ports and guesses what’s running. The UniFi fingerprint looked like this:
{
type: 'unifi',
name: 'UniFi Controller',
ports: [443, 8443],
detect: async (host, port) => {
const res = await axios.get(
`https://${host}:${port}/api/s/default/stat/health`,
{ timeout: 3000, httpsAgent: insecureAgent, validateStatus: () => true }
);
// ...treated a response as "this is UniFi"
}
}
The killer is validateStatus: () => true. That tells axios to treat every HTTP status — 200, 401, 403, 404 — as a successful response instead of throwing. Combined with a check that wasn’t strict about the body, any host answering on 443 (my NAS, my gateway) looked enough like a UniFi controller to get its own auto-created unifi integration — with no valid auth.
The gear I run for this
Hardware from my own homelab, relevant to this guide — direct Amazon links.
Affiliate links — I earn a small commission at no extra cost to you. Browse my full homelab store →
So after discovery I had three integrations of type unifi: the real one, plus two bogus ones pointing at unrelated boxes.
Bug 2 — poll results are keyed by type, so duplicates collide
Here’s the aggregator that polls every enabled integration each cycle:
async function pollAll() {
for (const int of getEnabledIntegrations()) {
const poller = POLLERS[int.type];
if (!poller) continue;
try {
const data = await poller(int);
latestData[int.type] = { data, status: 'ok' }; // keyed by TYPE
} catch (err) {
latestData[int.type] = { error: err.message, status: 'error' }; // overwrites!
}
}
}
Every result is stored at latestData[int.type]. With one integration per type that’s fine. With three unifi integrations, all three write to latestData['unifi'] in the same loop — and the last one wins. The real controller polls successfully and writes its clients; then a bogus duplicate polls, gets a 401/405, and overwrites latestData['unifi'] with { status: 'error' }. The dashboard reads latestData['unifi'], sees the error, and shows 0 clients. Every cycle, the good data was being clobbered by a phantom.
The fix
Three parts, in order of impact:
- Delete the bogus integrations. Immediate relief — with only the real
unifiintegration left, nothing overwrites it.
- Key results by instance, not type. The structural fix. Store per integration id (and, if you must group by type, keep an array or an object keyed by id):
“js latestData[int.id] = { data, status: 'ok' }; // one slot per instance “
Now two integrations of the same type can’t clobber each other, and the dashboard resolves the specific instance it wants.
- Tighten the fingerprint. Don’t accept "the host answered" as "the host is this service." Check the response actually looks like the service — the expected JSON shape, an expected field — and reject
401/403instead of swallowing them withvalidateStatus: () => true. And authenticate real integrations with the service’s proper API key rather than an unauthenticated probe.
Why the obvious fixes fail
| What you’d try | Why it doesn’t help |
|---|---|
| Re-enter the controller credentials | The real integration was never broken; a duplicate is overwriting it. |
| Restart the poller | The collision happens every cycle; a restart just repeats it. |
| Blame the controller | It’s returning data fine — the dashboard is discarding it. |
A reusable checklist
- A working integration goes to zero right after discovery? Look for duplicate integrations of the same type, not broken credentials.
- Check how poll results are stored — if it’s
results[type], two instances of a type will collide. Key by instance id. - Make service fingerprinting strict: verify the response body, and don’t treat
401/403(orvalidateStatus: () => true) as a positive match. - Authenticate real integrations properly; never let an unauthenticated probe become a stored integration.
- After any auto-discovery run, audit the integration list before trusting the dashboard.
Takeaway
Two ordinary shortcuts combined into an invisible data-loss bug: a fingerprint that trusted any HTTP response, and a store that keyed results by type instead of instance. Either alone is survivable; together, a phantom integration silently overwrote real data every poll. If a healthy service reads as "0" right after you scanned your network, don’t re-check its credentials — go count how many integrations of that type you now have, and check whether your polling layer can tell two of them apart.
AI assisted with drafting; the discovery and aggregator code, the collision, and the fix are from my own monitoring backend.
Leave a Reply