Skip to main content
Builder’s Journal

Why Your Monitoring Dashboard Shows Zero Clients After Auto-Discovery

· · 4 min read

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.

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:

  1. Delete the bogus integrations. Immediate relief — with only the real unifi integration left, nothing overwrites it.
  1. 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.

  1. 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/403 instead of swallowing them with validateStatus: () => 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 tryWhy it doesn’t help
Re-enter the controller credentialsThe real integration was never broken; a duplicate is overwriting it.
Restart the pollerThe collision happens every cycle; a restart just repeats it.
Blame the controllerIt’s returning data fine — the dashboard is discarding it.

A reusable checklist

  1. A working integration goes to zero right after discovery? Look for duplicate integrations of the same type, not broken credentials.
  2. Check how poll results are stored — if it’s results[type], two instances of a type will collide. Key by instance id.
  3. Make service fingerprinting strict: verify the response body, and don’t treat 401/403 (or validateStatus: () => true) as a positive match.
  4. Authenticate real integrations properly; never let an unauthenticated probe become a stored integration.
  5. 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.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *