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:
Paul Nothaft
2026-06-23 17:37:41 +02:00
parent a1f68bc081
commit 7534447b6c
9 changed files with 471 additions and 47 deletions
@@ -50,6 +50,12 @@ export interface AnalyticsSettings {
umami_url: string;
umami_website_id: string;
umami_share_url: string;
// API key for Umami's v2 metrics API. Required ONLY for the device
// breakdown (#661 Bug C); the rest of the integration (embedded iframe,
// tracker script) still works without it. Server masks as `••••••••`
// on GET when a value is stored — submit the masked sentinel unchanged
// to keep the stored value, or a real key to replace it.
umami_api_key: string;
}
export interface EventSettings {
@@ -129,7 +135,8 @@ export function useSettingsState() {
umami_enabled: false,
umami_url: '',
umami_website_id: '',
umami_share_url: ''
umami_share_url: '',
umami_api_key: ''
});
// Event creation settings state
@@ -219,7 +226,8 @@ export function useSettingsState() {
umami_enabled: toBoolean(settings.analytics_umami_enabled, false),
umami_url: settings.analytics_umami_url || '',
umami_website_id: settings.analytics_umami_website_id || '',
umami_share_url: settings.analytics_umami_share_url || ''
umami_share_url: settings.analytics_umami_share_url || '',
umami_api_key: settings.analytics_umami_api_key || ''
});
setEventSettings({
@@ -315,6 +323,10 @@ export function useSettingsState() {
mutationFn: async () => {
const settingsData: Record<string, unknown> = {};
Object.entries(analyticsSettings).forEach(([key, value]) => {
// The Umami API key (#661 Bug C) is returned masked as `••••••••`
// on GET so it doesn't leak in the response body. Don't re-save
// that sentinel — silently preserve whatever's already stored.
if (key === 'umami_api_key' && value === '••••••••') return;
settingsData[`analytics_${key}`] = value;
});
return settingsService.updateSettings(settingsData);
@@ -85,6 +85,29 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
{t('settings.analytics.shareUrlHelp')}
</p>
</div>
{/* Umami API key — only used by the dashboard's device
breakdown today (#661 Bug C). Optional; the rest of the
integration works without it. Masked as •••••••• on GET. */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.umamiApiKey', 'API key')}
</label>
<Input
type="password"
value={analyticsSettings.umami_api_key}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_api_key: e.target.value }))}
placeholder="api_xxx…"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
autoComplete="off"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t(
'settings.analytics.umamiApiKeyHelp',
'Optional. Required only for the device-breakdown chart on the Analytics dashboard. Generate in Umami → Settings → Profile → API Keys. Stored masked as •••••••• once saved — leave the masked value to keep the existing key.',
)}
</p>
</div>
</>
)}
+18 -16
View File
@@ -82,21 +82,19 @@ export const AnalyticsPage: React.FC = () => {
useEffect(() => {
const fetchUmamiConfig = async () => {
try {
// Use admin API endpoint with auth token since we're in admin area
// `/admin/settings` returns a key/value object (see backend
// `adminSettings.js:108`), NOT an array (#661 Bug B). The old
// `.reduce()` path threw `data.reduce is not a function` and the
// catch block silently rendered the "Umami Not Configured" banner
// even on perfectly-configured installs. Read keys directly.
const response = await api.get('/admin/settings');
const settings = response.data;
// Transform the settings array to object
const settingsMap = settings.reduce((acc: any, setting: any) => {
acc[setting.key] = setting.value;
return acc;
}, {});
const settings = response.data ?? {};
// Check if Umami is enabled in admin settings
if (settingsMap.analytics_umami_enabled && settingsMap.analytics_umami_url && settingsMap.analytics_umami_website_id) {
if (settings.analytics_umami_enabled && settings.analytics_umami_url && settings.analytics_umami_website_id) {
setUmamiConfig({
url: settingsMap.analytics_umami_url,
shareUrl: settingsMap.analytics_umami_share_url,
url: settings.analytics_umami_url,
shareUrl: settings.analytics_umami_share_url,
enabled: true
});
} else {
@@ -139,10 +137,14 @@ export const AnalyticsPage: React.FC = () => {
const analytics: ComponentAnalyticsData | undefined = React.useMemo(() => {
if (!apiData) return undefined;
// Calculate totals from chart data
const totalViews = apiData.chartData.reduce((sum, day) => sum + day.views, 0);
const totalVisitors = apiData.chartData.reduce((sum, day) => sum + day.uniqueVisitors, 0);
const totalDownloads = apiData.chartData.reduce((sum, day) => sum + day.downloads, 0);
// Headline totals come from the dedicated `totals` object that the
// backend computes via separate COUNT queries (#661 Bug A). The old
// sum-the-chartData path returned 0 on Postgres installs because the
// backend's date-string merge into chartData failed there. Postgres'
// pg driver returns COUNT(...) as strings, so coerce via Number().
const totalViews = Number(apiData.totals?.views ?? 0);
const totalVisitors = Number(apiData.totals?.uniqueVisitors ?? 0);
const totalDownloads = Number(apiData.totals?.downloads ?? 0);
// Calculate trends (comparing last half to first half)
const halfPoint = Math.floor(apiData.chartData.length / 2);
+12
View File
@@ -327,6 +327,18 @@ export interface AnalyticsData {
mobile: number;
tablet: number;
};
// Period totals computed via dedicated COUNT queries on the backend
// (#661 Bug A). Postgres returns these as strings, so callers should
// coerce via Number() before display. Optional on the type because
// older backends (pre-#661) didn't always emit it.
totals?: {
views: number | string;
downloads: number | string;
uniqueVisitors: number | string;
};
// Source of the device breakdown — `umami` when API-key auth succeeded,
// `access_logs` for the local user-agent heuristic fallback (#661 Bug C).
devicesSource?: 'umami' | 'access_logs';
}
export const adminService = {