Fixes#1888.
## The mismatch
`/api/stats.totalObservers` and `/api/observers` counted different sets:
| Source | Predicate |
|---|---|
| `cmd/server/store.go:2089` (store path) | `SELECT COUNT(*) FROM
observers` — every row |
| `cmd/server/db.go:336` (DB fallback) | `WHERE inactive IS NULL OR
inactive = 0` |
| `db.GetObservers()` → `/api/observers` | `WHERE inactive IS NULL OR
inactive = 0` |
`handleStats` uses the store path whenever a `PacketStore` exists
(`routes.go:785`), which is every normal deployment. So the header count
came from the unfiltered query while the Observers page listed the
filtered set. The two stats implementations also disagreed with each
other for the same database, which is a bug on its own.
## Reproduction
The gap is exactly the observers the `observerDays` retention sweep has
soft-deleted. On the instance I reproduced against:
```
GET /api/stats → totalObservers: 79
GET /api/observers → observers.length == 51
```
```sql
SELECT 'all', COUNT(*) FROM observers -- 79
UNION ALL SELECT 'active', COUNT(*) FROM observers WHERE inactive IS NULL OR inactive = 0 -- 51
UNION ALL SELECT 'inactive', COUNT(*) FROM observers WHERE inactive = 1; -- 28
```
79 − 28 = 51. Same shape as the 82 vs 51 in the issue.
## The change
One line: the store's stats query gets the same predicate the other two
already use, so all three agree.
## Deliberately out of scope
Two things the issue raises that this does **not** fix, called out so
they are not mistaken for done:
- **Config blacklist.** `buildObserversDefaultResponse` drops
blacklisted observers in the handler loop (`routes.go:2752`), which no
SQL count can see. A deployment with a non-empty `observerBlacklist`
will still show a stats count higher than the list, by the number of
blacklisted-but-live observers. Closing that needs config plumbing into
the count and is a separate change — happy to follow up if wanted.
- **Map controls.** The third surface named in the issue derives its
count from node role aggregates (`roleCounts`), not from the observer
set at all. That is a frontend concern and untouched here.
## Tests
`cmd/server/observer_count_1888_test.go`, three cases, each watched fail
first:
1. `TestStoreStatsTotalObserversExcludesSoftDeleted` — `TotalObservers =
5, want 4`
2. `TestStoreStatsTotalObserversMatchesObserverList` — `stats
totalObservers = 5 but /api/observers lists 4`
3. `TestStoreAndDBStatsAgreeOnTotalObservers` — `store path reports 5
observers, DB fallback reports 4`
The fixture includes a row with `inactive = NULL` alongside `inactive =
0` and `inactive = 1`, since `GetObservers` treats NULL as live and only
the `1` may be excluded.
`cd cmd/server && go test ./...` → ok (87s).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>