-
Notifications
You must be signed in to change notification settings - Fork 13
API contract update & DB query mechanism to fetch Device Stats
Goal: Return activatedCount and discoveredCount (alongside the existing
totalCount) from the server so they are:
-
Consistent with
totalCount(which is already computed server-side). - Correct across all pages — today the UI computes activated/discovered by filtering only the current page of devices, so the numbers are wrong past page 1.
- Available to any consumer — headless/API/scripts, not just the web UI.
Counting rules:
-
activatedCount— devices whosecurrentModeis set and is not"not activated". -
discoveredCount— devices flaggeddiscovered = trueAND not activated (an activated device drops out of the "discovered" bucket).
Related UI PR: device-management-toolkit/sample-web-ui#3417 — the web UI change that consumes these counts.
The total device count is already computed server-side and exposed two ways.
Both are backed by the same GetCount repository method (a plain
SELECT COUNT(*) ... WHERE tenantid = ?).
1. Dedicated stats endpoint (currently returns only the total):
curl -s http://localhost:8181/api/v1/devices/stats \
-H "Authorization: Bearer $TOKEN"2. Device list with the OData $count flag (returns the count alongside the
page of data):
curl -s "http://localhost:8181/api/v1/devices?\$top=25&\$skip=0&\$count=true" \
-H "Authorization: Bearer $TOKEN"// 200 OK
{
"totalCount": 42,
"data": [ /* ...page of devices... */ ]
}
$top/$skipcontrol paging;$count=trueasks for the total. This is the call the web UI uses to render the "All (N)" tab today. Auth is a JWT bearer token fromPOST /api/v1/authorize(skipped whenauth.disabledis set for local single-user runs).
The devices table stores most device telemetry inside a single JSON string
column called deviceinfo. The activation/discovery fields we need
(currentMode, discovered) exist only inside that JSON blob — they are not
queryable columns.
CREATE TABLE IF NOT EXISTS devices
(
guid TEXT NOT NULL,
tags TEXT,
hostname TEXT,
mpsinstance TEXT,
connectionstatus BOOLEAN NOT NULL,
mpsusername TEXT,
tenantid TEXT NOT NULL,
friendlyname TEXT,
dnssuffix TEXT,
lastconnected TEXT,
lastseen TEXT,
lastdisconnected TEXT,
deviceinfo TEXT, -- <-- JSON blob; currentMode + discovered live in here
username TEXT,
password TEXT,
usetls BOOLEAN NOT NULL,
allowselfsigned BOOLEAN NOT NULL,
certhash TEXT,
PRIMARY KEY (guid, tenantid),
UNIQUE (guid)
);{
"fwVersion": "...",
"discovered": true, // <-- boolean we need
"firstDiscovered": "2026-...",
"currentMode": "not activated" // <-- string we need ("" | "not activated" | admin/client control modes)
// ...many other fields
}In Go this blob is stored as an opaque string on the entity and only becomes typed after the DTO layer unmarshals it:
// entity (stored form) — both SQL and Mongo
DeviceInfo string `bson:"deviceinfo"`
// dto (after unmarshal)
type DeviceInfo struct {
Discovered *bool `json:"discovered,omitempty"`
CurrentMode string `json:"currentMode"`
// ...
}The SQL layer (internal/usecase/sqldb) is written once and runs against
both Postgres and SQLite via the squirrel query builder + Go's
database/sql. The DB is chosen at startup by connection URL
(postgres:// → pgx driver, otherwise embedded SQLite). MongoDB is a separate
implementation in internal/usecase/nosqldb/mongo.
So any solution must work for three backends across two code packages:
| Backend | Package | Notes |
|---|---|---|
| Postgres | internal/usecase/sqldb |
shares code with SQLite |
| SQLite | internal/usecase/sqldb |
shares code with Postgres |
| MongoDB | internal/usecase/nosqldb |
deviceinfo stored as a string field |
Standard SQL (SELECT, COUNT, CASE, WHERE) is portable across
Postgres+SQLite. Engine-specific JSON functions are not.
The design has two orthogonal axes. Keeping them apart avoids conflating "what the API looks like" with "how the database answers it":
- API contract (Section 1) — how the counts are exposed to clients (the HTTP surface). Independent of which database is mounted.
- DB fetch logic (Section 2) — how the activated/discovered determination is actually produced/stored in the repository layer.
You pick one option from each section; they compose (e.g. API Option 1 backed by DB Option A). A short combination guide follows Section 2.
Add server-side filters to the existing device-list endpoint — exactly the way it
already filters by hostname / friendlyName today — so each tab issues its own
filtered, paged request and gets its count for free from the existing
$count=true response. This also fixes a real bug: today the tabs filter only the
current page client-side, so pagination past page 1 is wrong per tab.
GET /api/v1/devices?$count=true # All -> totalCount + rows
GET /api/v1/devices?activated=true&$count=true # Activated -> count + correct rows
GET /api/v1/devices?discovered=true&$count=true # Discovered -> count + correct rowsHow the tab counts work: each tab's total comes from the $count=true
metadata of its own list call — no new counting code, and the rows shown are
correctly the filtered, paginated set.
Pros
- Fixes per-tab server-side pagination (current client-side page-only filter is buggy past page 1).
- Per-tab count is free from the existing
$count=true— no separate counting logic. - Gives API/headless consumers a real, reusable capability ("list all activated devices").
Cons
- Populating all three tab labels at once costs 3 requests (one per filter), refetched on tab change — see API Option 2 for the one-call alternative.
- The filter predicate must live in a SQL
WHERE, so it depends on DB Option A (real columns) to paginate/count correctly and portably.
Instead of three filtered list calls, return every count from a single endpoint. Best when you need all tab labels (All / Activated / Discovered, plus connected/disconnected) at once, or for dashboards / non-UI consumers.
GET /api/v1/devices/stats
# -> { totalCount, connectedCount, disconnectedCount, activatedCount, discoveredCount }Pros
- All counts in one round trip; three tab labels populated from a single call.
- Counts are consistent with each other (one query); decoupled from paging.
Cons
- Doesn't itself fix per-tab row pagination — it only returns numbers (pair with API Option 1's filter for correct rows).
API Options 1 and 2 are complementary, not exclusive. Recommended shape: use API Option 1's filter for correct per-tab rows + the active tab's count, and optionally expose API Option 2's
/devices/statsto fill all three labels in one call.
Independent of the API shape above. This is where the
currentMode/discovereddetermination actually comes from in the repository layer, and it must work for all three backends (Postgres, SQLite, Mongo).
Promote the two fields out of the JSON blob into real, queryable columns,
populated from deviceInfo on every insert/update. The blob stays the source of
truth for the API; the columns are a queryable mirror. (The existing hostname /
friendlyName filters already work on real columns via GetByColumn.)
Schema change:
ALTER TABLE devices ADD COLUMN currentmode TEXT;
ALTER TABLE devices ADD COLUMN discovered BOOLEAN;Resulting devices table (new columns highlighted at the bottom):
CREATE TABLE IF NOT EXISTS devices
(
guid TEXT NOT NULL,
tags TEXT,
hostname TEXT,
mpsinstance TEXT,
connectionstatus BOOLEAN NOT NULL,
mpsusername TEXT,
tenantid TEXT NOT NULL,
friendlyname TEXT,
dnssuffix TEXT,
lastconnected TEXT,
lastseen TEXT,
lastdisconnected TEXT,
deviceinfo TEXT, -- JSON blob; remains the source of truth for the API
username TEXT,
password TEXT,
usetls BOOLEAN NOT NULL,
allowselfsigned BOOLEAN NOT NULL,
certhash TEXT,
currentmode TEXT, -- NEW: queryable mirror of deviceinfo.currentMode
discovered BOOLEAN, -- NEW: queryable mirror of deviceinfo.discovered
PRIMARY KEY (guid, tenantid),
UNIQUE (guid)
);The two new columns are a queryable mirror of values already inside the
deviceinfoJSON blob. They are nullable so existing rows migrate cleanly.
Pros
- Portable standard SQL — no per-engine JSON functions; works on all three backends; Mongo uses real fields, not a string.
- Indexable → scales to large fleets; O(1)-ish per query.
- Enables both API options (filter + aggregate) from the same groundwork.
Cons / cost
- Requires a migration + write-path sync (populate the columns from
deviceInfoon insert/update) + backfill for existing rows (they readNULLuntil their next sync).
Use each engine's native JSON functions to read fields out of the deviceinfo
string with no schema change.
-- Postgres
COUNT(CASE WHEN (deviceinfo::jsonb ->> 'currentMode') <> 'not activated' THEN 1 END)
-- SQLite (different syntax!)
COUNT(CASE WHEN json_extract(deviceinfo, '$.currentMode') <> 'not activated' THEN 1 END)Why it does not fit this codebase
-
Not portable: Postgres (
::jsonb ->>) and SQLite (json_extract) use different syntax, but they share one code path. This forces per-engine branching — exactly what the current design avoids. -
Mongo can't do it:
deviceinfois stored as a string, not a sub-document, so there is no native accessor for inner fields. It would need in-DB JS/$functionparsing or a schema change — so this technique can't give one consistent implementation across all three backends. - No index → full scan on every stats call (unless per-engine expression indexes are added, defeating the "no migration" appeal).
Load devices, unmarshal deviceInfo (reusing existing entityToDTO), and tally
in a loop. Backend-agnostic — identical logic for all three DBs.
for _, d := range devices {
activated := d.DeviceInfo != nil &&
d.DeviceInfo.CurrentMode != "" &&
d.DeviceInfo.CurrentMode != "not activated"
switch {
case activated:
stats.ActivatedCount++
case d.DeviceInfo != nil && d.DeviceInfo.Discovered != nil && *d.DeviceInfo.Discovered:
stats.DiscoveredCount++
}
}Pros
- Zero schema/migration change; no write-path change.
- One implementation, identical across all three backends.
- Trivial to match the exact rules and to unit-test.
Cons
- O(n) load into memory per stats call — fine for typical fleets, not ideal for very large fleets or frequent polling.