Status: pending server PR #212,
not yet deployed. The existing /observers endpoint remains unchanged.
GET /api/v1/observers/directory?iata=YVR&sort=traffic&limit=50
| Parameter | Contract |
|---|---|
sort |
traffic (default) or name. |
limit |
Positive integer, default 50, capped at 200 per page. No directory-wide row cap. |
cursor |
Nonnegative integer offset, default 0. Pass the returned nextCursor. |
since, until |
Positive epoch milliseconds, inclusive start and exclusive end, rounded down to UTC hours. Window must be 1 hour through 31 days; until cannot be in the future. Required when cursor is nonzero. |
iata, iatas |
Case-insensitive IATA or comma-separated IATAs. Nonempty iatas takes precedence. |
regionId, region |
Expand region ID or slug and union with explicit IATAs. ID takes precedence. An empty region without explicit IATAs matches nothing. |
name |
Case-insensitive display-name substring using SQL ILIKE semantics (% and _ are wildcards). |
type, broker, scope |
Exact observer type, broker membership or transport scope name. URL-encode # in scopes. |
status |
online or offline. |
All filters apply before ordering and pagination. Type, broker, name and scope are limited to 200 UTF-8 bytes each. At most 200 IATAs are accepted after region expansion and before deduplication. Unknown/repeated parameters return 400.
The first page defaults to until = floorToUtcHour(now - 95 minutes) + 1 hour,
and since = until - 24 hours. This excludes the latest 35 to 95 minutes;
these are hourly analytics counts, not live packet counters.
For every continuation, repeat all filters and sort, pass the first response's
windowStart as since and windowEnd as until, and use nextCursor:
/api/v1/observers/directory?iata=YVR&sort=traffic&since=1767225600000&until=1767312000000&cursor=50&limit=50
Items contain the existing ObserverSummary fields plus required nullable
observationCount. Optional names, type, radio and empty scopes may be omitted.
{
"items": [
{"id": "00000000-0000-0000-0000-000000000001", "displayName": "North receiver", "iata": "YVR", "status": "online", "observationCount": 1200}
],
"nextCursor": 1,
"hasMore": true,
"generatedAt": 1767314100000,
"windowStart": 1767225600000,
"windowEnd": 1767312000000,
"sort": "traffic",
"effectiveSort": "traffic",
"coverage": {"status": "complete", "expectedHours": 24, "completeHours": 24, "partialHours": 0, "missingHours": 0},
"maxObservationCount": 1200,
"observerTypes": ["meshcore-ha", "meshcoretomqtt"]
}Timestamps are epoch milliseconds. items and observerTypes are always arrays.
A terminal page has hasMore: false and no nextCursor. A cursor past the end
returns an empty terminal page.
Counts sum analytics_hourly_observer_identity for the requested window. They
represent stored hearings per observer, deduplicated across brokers, not MQTT
messages or the observer detail's cumulative presence counter. No raw observation
scan is needed. Directory membership uses current observer IATA; counts include
only observations in selected IATAs, or all IATAs without a location filter.
Broker and scope filter membership, not the source of counted observations.
When every requested hour is complete, missing observer analytics mean 0.
If any hour is partial or missing, every count and maxObservationCount is
null, and effectiveSort is name. Coverage is complete when all hours are
complete, partial when some are complete or partial but not all complete, and
unavailable when all are missing. Absent rollup records count as missing.
Never render null as zero.
Traffic order is count descending, then lowercase display name ascending using
PostgreSQL C collation, then UUID ascending. Name order uses the last two keys.
Missing names sort as empty strings. Online status means last status or last
seen within five minutes at request time.
maxObservationCount covers the whole filtered result, including in name order.
It is 0 for an empty complete result. observerTypes includes all distinct
nonempty types matching every filter except the type filter itself, independent
of pagination.
Each request reads current data. There is no stored snapshot, expiry, new table or snapshot capacity limit. Keeping the window fixed avoids hourly window drift, but analytics corrections and metadata changes can still move rows between pages, causing repeats or skips. Counts, coverage, type choices and maximum can also change. This endpoint is a browsing list, not a consistent export.
Use one infinite query keyed by server, filters, window and sort. A query change
starts from page one and discards stale responses. Preserve server order, allow
one continuation in flight, deduplicate by observer UUID, and stop on
hasMore=false or a missing/repeated cursor. Fetch again near the scroll end,
including when the viewport is not filled. No per-row count requests or separate
top-observers request is needed.
For stable bars while scrolling, retain the first page's maximum and clamp
count/max to [0,1]; reset on refresh. Render unavailable when count or maximum is
null, and zero width when the maximum is zero. If effectiveSort changes between
pages as coverage changes, restart from page one rather than mixing orderings.
Live status badges can update separately. Refresh the list to update membership
or order; selection and deep links continue to use UUIDs.
Invalid parameters and unknown regions return 400. Database failures return 500;
retain loaded rows and offer retry. Successful pages and store errors send
Cache-Control: no-store; database reads have a five-second operation budget.
Probe /api/v1/observers/directory?limit=1 for compatibility. A supported server
returns the response above. Older servers may return 404, or the legacy detail
route's 400 with message failed to parse observer UUID. Other errors do not
prove lack of support. Use an explicitly limited legacy fallback or request a
server upgrade; do not assume the old /observers route honors new parameters.