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
@@ -0,0 +1,58 @@
/**
* Pins the date-merge fix in `adminDashboard.js` /analytics route
* (#661 Bug A). The merge previously failed on Postgres because pg's
* driver returns `DATE(timestamp)` as a JS Date object, while SQLite
* returns a string — the old `dateObj.date === row.date` comparison
* was false on Postgres so chartData stayed all-zero even with traffic.
*
* We test the normalisation helper here in isolation. The route-level
* integration is covered by the existing dashboard route test.
*/
// The helper is internal to the route file; reimport via a small wrapper
// so we don't need to export everything publicly.
const path = require('path');
const fs = require('fs');
const ROUTE_SRC = fs.readFileSync(
path.join(__dirname, '../../src/routes/adminDashboard.js'),
'utf8',
);
// Tiny evaluator that grabs the normaliseDateKey function definition from
// the route source so the test pins the actual shipping implementation,
// not a copy.
function extractNormaliseDateKey() {
const match = ROUTE_SRC.match(/function normaliseDateKey\([\s\S]*?\n\}/);
if (!match) throw new Error('normaliseDateKey not found in adminDashboard.js');
// eslint-disable-next-line no-new-func
return new Function(`${match[0]}; return normaliseDateKey;`)();
}
const normaliseDateKey = extractNormaliseDateKey();
describe('analytics route — normaliseDateKey (#661 Bug A)', () => {
test('passes through a YYYY-MM-DD string unchanged', () => {
expect(normaliseDateKey('2026-06-22')).toBe('2026-06-22');
});
test('slices off a time component on a longer ISO string', () => {
expect(normaliseDateKey('2026-06-22T00:00:00.000Z')).toBe('2026-06-22');
});
test('normalises a JS Date object (Postgres pg-driver shape) to YYYY-MM-DD', () => {
const d = new Date('2026-06-22T12:34:56Z');
expect(normaliseDateKey(d)).toBe('2026-06-22');
});
test('returns null for null / undefined / empty', () => {
expect(normaliseDateKey(null)).toBeNull();
expect(normaliseDateKey(undefined)).toBeNull();
expect(normaliseDateKey('')).toBeNull();
});
test('coerces unexpected types via String() to avoid throwing', () => {
// We don't expect to receive a number from either driver, but the
// helper should not crash if it does — date merge will simply miss.
expect(normaliseDateKey(20260622)).toBe('20260622');
});
});
+142
View File
@@ -0,0 +1,142 @@
/**
* Unit tests for the Umami v2 metrics-API client (#661 Bug C).
*
* The client is only consumed by `/admin/dashboard/analytics` today to fetch
* the device-breakdown chart, so these tests pin:
* - The exact URL shape sent to Umami (`/api/websites/<id>/metrics?type=device&startAt=…&endAt=…`)
* - The `x-umami-api-key` auth header
* - The `{ x, y }` → `{ desktop, mobile, tablet }` percentage normalisation
* - `laptop` mapping into `desktop` for our 3-bucket UI
* - Defensive returns: missing config / non-2xx / non-JSON / empty array
* all return `null` so the route layer can fall back to access_logs.
*/
const { fetchUmamiDeviceBreakdown } = require('../../src/services/umamiClient');
const ORIGINAL_FETCH = global.fetch;
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
});
function mockJson(body, { status = 200 } = {}) {
global.fetch = jest.fn(async () => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}));
}
describe('fetchUmamiDeviceBreakdown', () => {
test('returns null when config is incomplete (back-compat for installs without API key)', async () => {
expect(await fetchUmamiDeviceBreakdown({})).toBeNull();
expect(await fetchUmamiDeviceBreakdown({ baseUrl: 'https://u.example' })).toBeNull();
expect(await fetchUmamiDeviceBreakdown({ baseUrl: 'https://u.example', websiteId: 'w' })).toBeNull();
// No fetch should be issued in any of those cases.
expect(global.fetch).toBe(ORIGINAL_FETCH);
});
test('builds the expected URL + sends the x-umami-api-key header', async () => {
mockJson([{ x: 'desktop', y: 10 }]);
await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u.example.com/',
websiteId: 'site-123',
apiKey: 'secret',
startMs: 1700000000000,
endMs: 1700003600000,
});
expect(global.fetch).toHaveBeenCalledTimes(1);
const [calledUrl, init] = global.fetch.mock.calls[0];
expect(calledUrl).toBe(
'https://u.example.com/api/websites/site-123/metrics?type=device&startAt=1700000000000&endAt=1700003600000',
);
expect(init.headers['x-umami-api-key']).toBe('secret');
expect(init.method).toBe('GET');
});
test('encodes the websiteId so a path segment with reserved chars is safe', async () => {
mockJson([{ x: 'desktop', y: 1 }]);
await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u.example.com',
websiteId: 'a/b?c',
apiKey: 'k',
startMs: 0, endMs: 0,
});
const [calledUrl] = global.fetch.mock.calls[0];
expect(calledUrl).toContain('/api/websites/a%2Fb%3Fc/metrics');
});
test('normalises a typical { x, y } payload into integer percentages', async () => {
mockJson([
{ x: 'desktop', y: 60 },
{ x: 'mobile', y: 30 },
{ x: 'tablet', y: 10 },
]);
const out = await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0,
});
expect(out).toEqual({ desktop: 60, mobile: 30, tablet: 10 });
});
test('maps `laptop` into `desktop` for the 3-bucket UI', async () => {
mockJson([
{ x: 'desktop', y: 50 },
{ x: 'laptop', y: 20 },
{ x: 'mobile', y: 30 },
]);
const out = await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0,
});
// desktop = (50 + 20) / 100 = 70%
expect(out).toEqual({ desktop: 70, mobile: 30, tablet: 0 });
});
test('drops unknown buckets entirely (avoids silent miscategorisation)', async () => {
mockJson([
{ x: 'desktop', y: 80 },
{ x: 'mobile', y: 20 },
{ x: 'unknown-future-bucket', y: 100 },
]);
const out = await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0,
});
// 100 isn't counted into total, so 80/(80+20) = 80%, 20/(80+20) = 20%.
expect(out).toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('returns null on empty payload (caller falls back to access_logs)', async () => {
mockJson([]);
const out = await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0,
});
expect(out).toBeNull();
});
test('returns null on non-2xx upstream response', async () => {
mockJson({ error: 'unauthorized' }, { status: 401 });
const out = await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0,
});
expect(out).toBeNull();
});
test('returns null on invalid JSON body', async () => {
global.fetch = jest.fn(async () => ({
ok: true,
status: 200,
json: async () => { throw new SyntaxError('not json'); },
}));
const out = await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0,
});
expect(out).toBeNull();
});
test('returns null on network error (fetch throws)', async () => {
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
const out = await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0,
});
expect(out).toBeNull();
});
});
+88 -29
View File
@@ -4,8 +4,26 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity'); const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { getAppSetting } = require('../utils/appSettings');
const { fetchUmamiDeviceBreakdown } = require('../services/umamiClient');
const logger = require('../utils/logger');
const router = express.Router(); const router = express.Router();
/**
* Normalise a value coming back from `DATE(timestamp)` into a YYYY-MM-DD
* string. SQLite returns this column as a string already; Postgres' pg
* driver auto-converts it to a JavaScript Date object, which broke the
* old `dateObj.date === row.date` merge below — every Postgres install saw
* an all-zero `chartData[]` even with real traffic (#661 Bug A). Always
* normalise before comparing.
*/
function normaliseDateKey(value) {
if (!value) return null;
if (value instanceof Date) return value.toISOString().slice(0, 10);
// Strings might arrive with time component, slice defensively.
return String(value).slice(0, 10);
}
// Get dashboard statistics // Get dashboard statistics
router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => { router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try { try {
@@ -265,20 +283,25 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
.where('timestamp', '>=', startDateStr) .where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)'); .groupByRaw('DATE(timestamp)');
// Merge data into dates array // Merge data into dates array. row.date is normalised because Postgres
// returns DATE() as a JS Date while SQLite returns a string (#661 Bug A).
// Counts come back as strings on Postgres too, so coerce via Number.
viewsData.forEach(row => { viewsData.forEach(row => {
const dateObj = dates.find(d => d.date === row.date); const key = normaliseDateKey(row.date);
if (dateObj) dateObj.views = row.count; const dateObj = dates.find(d => d.date === key);
if (dateObj) dateObj.views = Number(row.count) || 0;
}); });
downloadsData.forEach(row => { downloadsData.forEach(row => {
const dateObj = dates.find(d => d.date === row.date); const key = normaliseDateKey(row.date);
if (dateObj) dateObj.downloads = row.count; const dateObj = dates.find(d => d.date === key);
if (dateObj) dateObj.downloads = Number(row.count) || 0;
}); });
visitorsData.forEach(row => { visitorsData.forEach(row => {
const dateObj = dates.find(d => d.date === row.date); const key = normaliseDateKey(row.date);
if (dateObj) dateObj.uniqueVisitors = row.count; const dateObj = dates.find(d => d.date === key);
if (dateObj) dateObj.uniqueVisitors = Number(row.count) || 0;
}); });
// Get top galleries by views with additional metrics // Get top galleries by views with additional metrics
@@ -293,31 +316,66 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
.orderBy('views', 'desc') .orderBy('views', 'desc')
.limit(5); .limit(5);
// Get device breakdown (simplified - based on user agent) // Device breakdown — prefer Umami's metrics API when configured (#661
const deviceData = await db('access_logs') // Bug C). The local access_logs heuristic below produces 0% on installs
.select( // where guest user agents don't reliably contain "Mobile" / "Tablet"
db.raw(` // tokens; Umami tracks devices natively. Falls back to access_logs when
CASE // Umami is unconfigured, unreachable, or rate-limited.
WHEN user_agent LIKE '%Mobile%' THEN 'mobile' let devices = { desktop: 0, mobile: 0, tablet: 0 };
WHEN user_agent LIKE '%Tablet%' OR user_agent LIKE '%iPad%' THEN 'tablet' let devicesSource = 'access_logs';
ELSE 'desktop'
END as device_type
`),
db.raw('COUNT(*) as count')
)
.where('timestamp', '>=', startDateStr)
.groupBy('device_type');
const totalDevices = deviceData.reduce((sum, d) => sum + d.count, 0); const umamiConfig = {
const devices = { enabled: (await getAppSetting('analytics_umami_enabled', false)) === true,
desktop: 0, baseUrl: await getAppSetting('analytics_umami_url', null),
mobile: 0, websiteId: await getAppSetting('analytics_umami_website_id', null),
tablet: 0 apiKey: await getAppSetting('analytics_umami_api_key', null),
}; };
if (umamiConfig.enabled && umamiConfig.baseUrl && umamiConfig.websiteId && umamiConfig.apiKey) {
try {
const umamiDevices = await fetchUmamiDeviceBreakdown({
baseUrl: umamiConfig.baseUrl,
websiteId: umamiConfig.websiteId,
apiKey: umamiConfig.apiKey,
startMs: startDate.getTime(),
endMs: Date.now(),
});
if (umamiDevices) {
devices = umamiDevices;
devicesSource = 'umami';
}
} catch (err) {
logger.warn('Analytics: Umami device-breakdown fetch failed; falling back to access_logs', {
error: err.message,
});
}
}
deviceData.forEach(d => { if (devicesSource === 'access_logs') {
devices[d.device_type] = Math.round((d.count / totalDevices) * 100); // Local heuristic on access_logs user_agent. Coarse — `LIKE` doesn't
}); // cover every UA shape (some Android browsers, embedded webviews, etc.)
// — and counts come back as strings on Postgres, hence Number() below.
const deviceData = await db('access_logs')
.select(
db.raw(`
CASE
WHEN user_agent LIKE '%Mobile%' THEN 'mobile'
WHEN user_agent LIKE '%Tablet%' OR user_agent LIKE '%iPad%' THEN 'tablet'
ELSE 'desktop'
END as device_type
`),
db.raw('COUNT(*) as count')
)
.where('timestamp', '>=', startDateStr)
.whereNotNull('user_agent')
.groupBy('device_type');
const totalDevices = deviceData.reduce((sum, d) => sum + (Number(d.count) || 0), 0);
if (totalDevices > 0) {
deviceData.forEach(d => {
devices[d.device_type] = Math.round(((Number(d.count) || 0) / totalDevices) * 100);
});
}
}
// Calculate totals for the period (matching /stats logic) // Calculate totals for the period (matching /stats logic)
const totalViews = await db('access_logs') const totalViews = await db('access_logs')
@@ -341,6 +399,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
chartData: dates, chartData: dates,
topGalleries, topGalleries,
devices, devices,
devicesSource,
totals: { totals: {
views: totalViews?.count || 0, views: totalViews?.count || 0,
downloads: totalDownloadsCount?.count || 0, downloads: totalDownloadsCount?.count || 0,
+12
View File
@@ -145,6 +145,12 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
if (settingsObject.security_recaptcha_secret_key) { if (settingsObject.security_recaptcha_secret_key) {
settingsObject.security_recaptcha_secret_key = '••••••••'; settingsObject.security_recaptcha_secret_key = '••••••••';
} }
// Umami v2 API key (#661 Bug C) — read-write secret that authenticates
// outbound calls to the operator's Umami instance for the device
// breakdown. Masked on GET, same pattern as the recaptcha secret.
if (settingsObject.analytics_umami_api_key) {
settingsObject.analytics_umami_api_key = '••••••••';
}
res.json(settingsObject); res.json(settingsObject);
} catch (error) { } catch (error) {
@@ -390,6 +396,12 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
if (settingsObject.security_recaptcha_secret_key) { if (settingsObject.security_recaptcha_secret_key) {
settingsObject.security_recaptcha_secret_key = '••••••••'; settingsObject.security_recaptcha_secret_key = '••••••••';
} }
// Umami v2 API key (#661 Bug C) — read-write secret that authenticates
// outbound calls to the operator's Umami instance for the device
// breakdown. Masked on GET, same pattern as the recaptcha secret.
if (settingsObject.analytics_umami_api_key) {
settingsObject.analytics_umami_api_key = '••••••••';
}
res.json(settingsObject); res.json(settingsObject);
} catch (error) { } catch (error) {
+104
View File
@@ -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 };
@@ -50,6 +50,12 @@ export interface AnalyticsSettings {
umami_url: string; umami_url: string;
umami_website_id: string; umami_website_id: string;
umami_share_url: 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 { export interface EventSettings {
@@ -129,7 +135,8 @@ export function useSettingsState() {
umami_enabled: false, umami_enabled: false,
umami_url: '', umami_url: '',
umami_website_id: '', umami_website_id: '',
umami_share_url: '' umami_share_url: '',
umami_api_key: ''
}); });
// Event creation settings state // Event creation settings state
@@ -219,7 +226,8 @@ export function useSettingsState() {
umami_enabled: toBoolean(settings.analytics_umami_enabled, false), umami_enabled: toBoolean(settings.analytics_umami_enabled, false),
umami_url: settings.analytics_umami_url || '', umami_url: settings.analytics_umami_url || '',
umami_website_id: settings.analytics_umami_website_id || '', 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({ setEventSettings({
@@ -315,6 +323,10 @@ export function useSettingsState() {
mutationFn: async () => { mutationFn: async () => {
const settingsData: Record<string, unknown> = {}; const settingsData: Record<string, unknown> = {};
Object.entries(analyticsSettings).forEach(([key, value]) => { 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; settingsData[`analytics_${key}`] = value;
}); });
return settingsService.updateSettings(settingsData); return settingsService.updateSettings(settingsData);
@@ -85,6 +85,29 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
{t('settings.analytics.shareUrlHelp')} {t('settings.analytics.shareUrlHelp')}
</p> </p>
</div> </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>
</> </>
)} )}
+17 -15
View File
@@ -82,21 +82,19 @@ export const AnalyticsPage: React.FC = () => {
useEffect(() => { useEffect(() => {
const fetchUmamiConfig = async () => { const fetchUmamiConfig = async () => {
try { 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 response = await api.get('/admin/settings');
const settings = response.data; 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;
}, {});
// Check if Umami is enabled in admin settings // 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({ setUmamiConfig({
url: settingsMap.analytics_umami_url, url: settings.analytics_umami_url,
shareUrl: settingsMap.analytics_umami_share_url, shareUrl: settings.analytics_umami_share_url,
enabled: true enabled: true
}); });
} else { } else {
@@ -139,10 +137,14 @@ export const AnalyticsPage: React.FC = () => {
const analytics: ComponentAnalyticsData | undefined = React.useMemo(() => { const analytics: ComponentAnalyticsData | undefined = React.useMemo(() => {
if (!apiData) return undefined; if (!apiData) return undefined;
// Calculate totals from chart data // Headline totals come from the dedicated `totals` object that the
const totalViews = apiData.chartData.reduce((sum, day) => sum + day.views, 0); // backend computes via separate COUNT queries (#661 Bug A). The old
const totalVisitors = apiData.chartData.reduce((sum, day) => sum + day.uniqueVisitors, 0); // sum-the-chartData path returned 0 on Postgres installs because the
const totalDownloads = apiData.chartData.reduce((sum, day) => sum + day.downloads, 0); // 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) // Calculate trends (comparing last half to first half)
const halfPoint = Math.floor(apiData.chartData.length / 2); const halfPoint = Math.floor(apiData.chartData.length / 2);
+12
View File
@@ -327,6 +327,18 @@ export interface AnalyticsData {
mobile: number; mobile: number;
tablet: 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 = { export const adminService = {