fix(analytics): admin dashboard reads correct fields + Umami device API (#661)
Reporter @alexvaltchev hit three independent bugs on the Analytics
Dashboard. All three fixed in one PR; pluggable-tracker support
(Rybbit, Plausible, etc.) left for a separate discussion.
## Bug A — Summary cards showed 0
Two layers, both fixed.
**Frontend** (`AnalyticsPage.tsx:142-149`): the cards summed
`chartData[].views/uniqueVisitors/downloads`. The backend now (and
already) emits a dedicated `totals` object computed via separate
COUNT queries, which is what the cards should read. Postgres returns
counts as strings, so coerce via `Number()`.
**Backend** (`adminDashboard.js:268-282`): the chartData merge used
`dateObj.date === row.date`. On Postgres, pg's driver auto-converts
`DATE(timestamp)` to a JS Date object — the string-equality match
failed silently and `chartData` stayed all-zero on every Postgres
install with traffic. Added a `normaliseDateKey()` helper that
returns YYYY-MM-DD regardless of driver shape, plus `Number()`
coercion on the counts. SQLite path unchanged.
## Bug B — "Umami Not Configured" banner despite valid config
`AnalyticsPage.tsx:90` did `settings.reduce(...)` on the
`/admin/settings` response. That endpoint returns a
key/value **object** (verified at `adminSettings.js:108-149`), not
an array, so `.reduce` threw `data.reduce is not a function` and
the catch silently rendered the "Not Configured" banner even on
perfectly-configured installs. Read the umami keys directly off the
response object.
## Bug C — Device breakdown 0/0/0
Two-pronged fix.
**Primary path — Umami device API** (`services/umamiClient.js`,
wired into `adminDashboard.js`). When the admin provides an Umami
v2 API key (new setting `analytics_umami_api_key`), the backend
fetches the per-period device breakdown from Umami's
`/api/websites/:id/metrics?type=device` endpoint. Umami tracks
devices natively — far more accurate than our coarse user-agent
heuristic. The new `devicesSource` field in the response lets the
UI hint at where the numbers came from.
**Fallback hardening — local heuristic** (`adminDashboard.js:296-320`).
The existing access_logs `LIKE '%Mobile%' / '%Tablet%'` query stays
in place as a fallback for installs without Umami. Hardened with:
`whereNotNull('user_agent')` skips rows we never captured a UA on,
`Number()` coercion on COUNT results (pg returns strings), and a
guard against divide-by-zero when access_logs is empty.
## API key handling
Mirrors the existing recaptcha-secret pattern: stored plaintext in
`app_settings`, masked as `••••••••` on every GET via the existing
`adminSettings.js` GET handlers, and the frontend save mutation
silently drops the masked sentinel so re-saving without typing a
new key preserves the stored value.
## End-to-end smoke (dockerised backend with my fixes applied)
```
chartData total views: 27 ← previously 0 (date merge broken on PG)
totals: {'views': '27', 'downloads': '3', 'uniqueVisitors': '1'}
devices: {'desktop': 100, 'mobile': 0, 'tablet': 0} ← was 0/0/0
devicesSource: access_logs ← falls back correctly
analytics_umami_api_key (GET /settings/analytics): ••••••••
```
## Tests
**Backend** (15 new cases):
- `umamiClient.test.js` (10): missing-config → null, URL shape +
`x-umami-api-key` header, websiteId URL-encoding, `{x,y}` →
percentages, `laptop` → `desktop` mapping, unknown buckets
dropped, empty payload → null, non-2xx → null, invalid JSON →
null, network error → null.
- `analyticsDateMerge.test.js` (5): YYYY-MM-DD string pass-through,
ISO timestamp slice, JS Date (pg shape) → YYYY-MM-DD, null/empty
→ null, coercion for unexpected types.
**Frontend**: full 84-case vitest suite still green (no analytics
unit tests existed before; not adding any here — the changes are
narrow and the unit-level confidence comes from the type system +
the backend smoke above).
Closes #661 (bugs A + B + C). Rybbit / pluggable tracker support is
the next conversation per the issue author's follow-up.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Thin Umami v2 API client (#661 Bug C).
|
||||
*
|
||||
* We use this in exactly one place today — `GET /admin/dashboard/analytics`
|
||||
* pulls device-breakdown stats from Umami when an admin has provided an
|
||||
* API key, because our own user-agent heuristic on `access_logs` returns
|
||||
* 0/0/0 on installs where guest UAs don't reliably contain "Mobile" /
|
||||
* "Tablet" tokens. Umami tracks devices natively.
|
||||
*
|
||||
* Auth: Umami v2 supports per-account API keys generated in the Umami UI
|
||||
* (Settings → Profile → API Keys). We send them via the `x-umami-api-key`
|
||||
* header. Older session-cookie-based auth is intentionally NOT supported
|
||||
* here — operators should generate an API key rather than embedding their
|
||||
* Umami account password in PicPeak.
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* Fetch the device breakdown for a Umami website in [startMs, endMs]. Returns
|
||||
* `{ desktop, mobile, tablet }` as integer percentages summing to ~100, or
|
||||
* `null` when the upstream call fails / returns no data — callers should
|
||||
* treat null as "fall back to the local heuristic".
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.baseUrl — Umami instance root, e.g. https://analytics.example.com
|
||||
* @param {string} opts.websiteId — UUID from the Umami website settings
|
||||
* @param {string} opts.apiKey — Umami API key (x-umami-api-key)
|
||||
* @param {number} opts.startMs — epoch ms, start of window
|
||||
* @param {number} opts.endMs — epoch ms, end of window
|
||||
*/
|
||||
async function fetchUmamiDeviceBreakdown({ baseUrl, websiteId, apiKey, startMs, endMs }) {
|
||||
if (!baseUrl || !websiteId || !apiKey) return null;
|
||||
|
||||
const trimmedBase = String(baseUrl).replace(/\/+$/, '');
|
||||
const url = `${trimmedBase}/api/websites/${encodeURIComponent(websiteId)}/metrics`
|
||||
+ `?type=device&startAt=${encodeURIComponent(startMs)}&endAt=${encodeURIComponent(endMs)}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-umami-api-key': apiKey,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
if (err.name === 'AbortError') {
|
||||
logger.warn('Umami device fetch: timeout', { url });
|
||||
return null;
|
||||
}
|
||||
logger.warn('Umami device fetch: network error', { error: err.message });
|
||||
return null;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Umami device fetch: non-2xx', { status: response.status });
|
||||
return null;
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (err) {
|
||||
logger.warn('Umami device fetch: invalid JSON', { error: err.message });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Array.isArray(data)) return null;
|
||||
|
||||
// Umami buckets device types into these strings: `desktop`, `mobile`,
|
||||
// `tablet`, `laptop`. Map `laptop` → `desktop` for our 3-bucket UI; drop
|
||||
// anything we don't recognise (vs. silently miscategorising).
|
||||
const counts = { desktop: 0, mobile: 0, tablet: 0 };
|
||||
let total = 0;
|
||||
for (const entry of data) {
|
||||
if (!entry || typeof entry.x !== 'string') continue;
|
||||
const n = Number(entry.y);
|
||||
if (!Number.isFinite(n) || n <= 0) continue;
|
||||
const key = entry.x === 'laptop' ? 'desktop' : entry.x;
|
||||
if (key in counts) {
|
||||
counts[key] += n;
|
||||
total += n;
|
||||
}
|
||||
}
|
||||
|
||||
if (total === 0) return null;
|
||||
|
||||
return {
|
||||
desktop: Math.round((counts.desktop / total) * 100),
|
||||
mobile: Math.round((counts.mobile / total) * 100),
|
||||
tablet: Math.round((counts.tablet / total) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { fetchUmamiDeviceBreakdown };
|
||||
Reference in New Issue
Block a user