domma-cms 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/admin/css/dashboard.css +1 -0
  2. package/admin/dist/domma/domma-tools.css +3 -3
  3. package/admin/dist/domma/domma-tools.min.js +4 -4
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +1 -1
  7. package/admin/js/templates/dashboard/activity-feed.html +3 -0
  8. package/admin/js/templates/dashboard/health-detail.html +2 -0
  9. package/admin/js/templates/dashboard/journeys.html +17 -0
  10. package/admin/js/templates/dashboard/kpi-strip.html +34 -0
  11. package/admin/js/templates/dashboard/spike-feed.html +3 -0
  12. package/admin/js/templates/dashboard/top-pages.html +3 -0
  13. package/admin/js/templates/dashboard/traffic-chart.html +3 -0
  14. package/admin/js/templates/dashboard.html +22 -44
  15. package/admin/js/views/dashboard/lib/escape.js +1 -0
  16. package/admin/js/views/dashboard/widgets/activity-feed.js +1 -0
  17. package/admin/js/views/dashboard/widgets/health-detail.js +1 -0
  18. package/admin/js/views/dashboard/widgets/journeys.js +1 -0
  19. package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -0
  20. package/admin/js/views/dashboard/widgets/spike-feed.js +6 -0
  21. package/admin/js/views/dashboard/widgets/top-pages.js +1 -0
  22. package/admin/js/views/dashboard/widgets/traffic-chart.js +1 -0
  23. package/admin/js/views/dashboard.js +1 -1
  24. package/admin/js/views/form-editor.js +7 -7
  25. package/admin/js/views/index.js +1 -1
  26. package/config/plugins.json +3 -0
  27. package/package.json +2 -2
  28. package/plugins/analytics/daily.json +5 -0
  29. package/plugins/analytics/journeys.json +10 -0
  30. package/plugins/analytics/lifetime.json +25 -0
  31. package/plugins/analytics/plugin.js +231 -16
  32. package/plugins/analytics/public/inject-body.html +26 -2
  33. package/server/routes/api/dashboard.js +239 -0
  34. package/server/server.js +2 -0
  35. package/server/services/email.js +60 -20
  36. package/server/services/health.js +282 -0
  37. package/server/services/plugins.js +37 -5
@@ -22,6 +22,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
22
  const LIFETIME_FILE = path.join(__dirname, 'lifetime.json');
23
23
  const DAILY_FILE = path.join(__dirname, 'daily.json');
24
24
  const LEGACY_FILE = path.join(__dirname, 'stats.json');
25
+ const JOURNEYS_FILE = path.join(__dirname, 'journeys.json');
26
+ const JOURNEY_DAILY_CAP = 5000;
27
+ const REALTIME_WINDOW_MS = 5 * 60 * 1000;
25
28
 
26
29
  async function readJson(file, fallback) {
27
30
  try {
@@ -40,23 +43,30 @@ async function writeJson(file, data) {
40
43
  /**
41
44
  * One-shot migration from the legacy flat stats.json.
42
45
  * Preserves existing totals as lifetime numbers; daily history starts empty.
46
+ * Also seeds journeys.json if missing.
43
47
  */
44
48
  async function migrateLegacy() {
49
+ let lifetimeExists = false;
45
50
  try {
46
51
  await fs.access(LIFETIME_FILE);
47
- return; // already migrated
48
- } catch {
49
- // fall through
50
- }
51
- const legacy = await readJson(LEGACY_FILE, null);
52
- if (legacy && typeof legacy === 'object') {
53
- await writeJson(LIFETIME_FILE, legacy);
54
- await writeJson(DAILY_FILE, {});
55
- // keep stats.json on disk as a backup; the next reset will wipe it
56
- } else {
57
- await writeJson(LIFETIME_FILE, {});
52
+ lifetimeExists = true;
53
+ } catch { /* not migrated */ }
54
+
55
+ if (!lifetimeExists) {
56
+ const legacy = await readJson(LEGACY_FILE, null);
57
+ if (legacy && typeof legacy === 'object') {
58
+ await writeJson(LIFETIME_FILE, legacy);
59
+ } else {
60
+ await writeJson(LIFETIME_FILE, {});
61
+ }
58
62
  await writeJson(DAILY_FILE, {});
59
63
  }
64
+
65
+ try {
66
+ await fs.access(JOURNEYS_FILE);
67
+ } catch {
68
+ await writeJson(JOURNEYS_FILE, {});
69
+ }
60
70
  }
61
71
 
62
72
  function todayKey(date = new Date()) {
@@ -131,14 +141,164 @@ function escapeCsvCell(value) {
131
141
  return str;
132
142
  }
133
143
 
144
+ function eventsInRange(journeys, start, end) {
145
+ const startMs = start.getTime();
146
+ const endMs = end.getTime();
147
+ const out = [];
148
+ for (const [day, events] of Object.entries(journeys)) {
149
+ const dayMs = new Date(day + 'T00:00:00Z').getTime();
150
+ if (dayMs < startMs || dayMs > endMs) continue;
151
+ for (const ev of events) out.push(ev);
152
+ }
153
+ return out;
154
+ }
155
+
156
+ function groupBySession(events) {
157
+ const sessions = new Map();
158
+ for (const ev of events) {
159
+ if (!sessions.has(ev.sid)) sessions.set(ev.sid, []);
160
+ sessions.get(ev.sid).push(ev);
161
+ }
162
+ for (const arr of sessions.values()) arr.sort((a, b) => a.t - b.t);
163
+ return sessions;
164
+ }
165
+
166
+ function aggregateJourneys(events) {
167
+ const sessions = groupBySession(events);
168
+ const entry = new Map();
169
+ const exit = new Map();
170
+ const paths = new Map(); // key: '\x00'-joined, value: { from, to, count }
171
+ let bouncedSessions = 0;
172
+
173
+ for (const arr of sessions.values()) {
174
+ if (arr.length === 0) continue;
175
+ entry.set(arr[0].url, (entry.get(arr[0].url) || 0) + 1);
176
+ exit.set(arr[arr.length - 1].url, (exit.get(arr[arr.length - 1].url) || 0) + 1);
177
+ if (arr.length === 1) bouncedSessions += 1;
178
+ for (let i = 0; i < arr.length - 1; i += 1) {
179
+ const from = arr[i].url;
180
+ const to = arr[i + 1].url;
181
+ const key = from + '\x00' + to; // NUL is invalid in URL pathnames, so unambiguous
182
+ const existing = paths.get(key);
183
+ if (existing) {
184
+ existing.count += 1;
185
+ } else {
186
+ paths.set(key, { from, to, count: 1 });
187
+ }
188
+ }
189
+ }
190
+
191
+ const toSorted = (m) => Array.from(m.entries())
192
+ .map(([url, count]) => ({ url, count }))
193
+ .sort((a, b) => b.count - a.count)
194
+ .slice(0, 10);
195
+
196
+ const pathsSorted = Array.from(paths.values())
197
+ .sort((a, b) => b.count - a.count)
198
+ .slice(0, 10);
199
+
200
+ const totalSessions = sessions.size;
201
+ return {
202
+ entry: toSorted(entry),
203
+ exit: toSorted(exit),
204
+ paths: pathsSorted,
205
+ bounceRate: totalSessions === 0 ? 0 : +(bouncedSessions / totalSessions).toFixed(3),
206
+ totalSessions
207
+ };
208
+ }
209
+
210
+ function detectSpikes(daily, now = new Date()) {
211
+ const todayStr = todayKey(now);
212
+ const currentHour = now.getUTCHours();
213
+
214
+ // Approximation: distribute today's hits evenly across elapsed hours.
215
+ const todayUrls = daily[todayStr] || {};
216
+ const elapsedHours = Math.max(1, currentHour + 1);
217
+
218
+ const flagged = [];
219
+ for (const [url, totalToday] of Object.entries(todayUrls)) {
220
+ const currentHits = totalToday / elapsedHours;
221
+
222
+ // Baseline: previous 7 days' same-hour share (assume 24-hour spread)
223
+ const samples = [];
224
+ for (let i = 1; i <= 7; i += 1) {
225
+ const prev = new Date(now);
226
+ prev.setUTCDate(prev.getUTCDate() - i);
227
+ const key = todayKey(prev);
228
+ const sample = (daily[key] && daily[key][url]) ? daily[key][url] / 24 : 0;
229
+ samples.push(sample);
230
+ }
231
+ const mean = samples.reduce((a, b) => a + b, 0) / samples.length;
232
+ const variance = samples.reduce((acc, x) => acc + (x - mean) * (x - mean), 0) / samples.length;
233
+ const stddev = Math.sqrt(variance);
234
+
235
+ if (currentHits >= 5 && currentHits > mean + 2 * stddev && mean > 0) {
236
+ flagged.push({
237
+ url,
238
+ hits: Math.round(currentHits),
239
+ baseline: Math.round(mean),
240
+ ratio: +(currentHits / Math.max(mean, 0.5)).toFixed(2)
241
+ });
242
+ }
243
+ }
244
+
245
+ return flagged.sort((a, b) => b.ratio - a.ratio).slice(0, 5);
246
+ }
247
+
248
+ function shouldRecordJourney(dayEntries, cap = JOURNEY_DAILY_CAP) {
249
+ return Array.isArray(dayEntries) && dayEntries.length < cap;
250
+ }
251
+
134
252
  export default async function analyticsPlugin(fastify, options) {
135
253
  const { authenticate, requireAdmin } = options.auth;
136
254
  const settings = options.settings || {};
137
255
 
138
256
  await migrateLegacy();
139
257
 
258
+ fastify.decorate('analytics', {
259
+ async getStats({ range = '7d', from, to } = {}) {
260
+ const dates = rangeToDates(range, from, to);
261
+ const [lifetime, daily] = await Promise.all([
262
+ readJson(LIFETIME_FILE, {}),
263
+ readJson(DAILY_FILE, {})
264
+ ]);
265
+ let totals; let total;
266
+ if (!dates) {
267
+ totals = lifetime;
268
+ total = Object.values(lifetime).reduce((a, n) => a + n, 0);
269
+ } else {
270
+ const agg = aggregateDaily(daily, dates.start, dates.end);
271
+ totals = agg.totals;
272
+ total = agg.total;
273
+ }
274
+ const top = Object.entries(totals)
275
+ .map(([url, hits]) => ({ url, hits }))
276
+ .sort((a, b) => b.hits - a.hits);
277
+ return { total, top, lifetimeTotal: Object.values(lifetime).reduce((a, n) => a + n, 0) };
278
+ },
279
+ async getDaily() { return readJson(DAILY_FILE, {}); },
280
+ async getJourneys({ range = '7d', from, to } = {}) {
281
+ const dates = rangeToDates(range === 'all' ? '30d' : range, from, to);
282
+ const journeys = await readJson(JOURNEYS_FILE, {});
283
+ return aggregateJourneys(eventsInRange(journeys, dates.start, dates.end));
284
+ },
285
+ async getRealtime() {
286
+ const journeys = await readJson(JOURNEYS_FILE, {});
287
+ const today = todayKey();
288
+ const events = journeys[today] || [];
289
+ const cutoff = Date.now() - REALTIME_WINDOW_MS;
290
+ const sids = new Set();
291
+ for (const ev of events) if (ev.t >= cutoff) sids.add(ev.sid);
292
+ return { activeSessions: sids.size, windowMinutes: REALTIME_WINDOW_MS / 60000 };
293
+ },
294
+ async getSpikes() {
295
+ const daily = await readJson(DAILY_FILE, {});
296
+ return { items: detectSpikes(daily) };
297
+ }
298
+ });
299
+
140
300
  fastify.post('/hit', async (request, reply) => {
141
- const { url, dnt } = request.body || {};
301
+ const { url, dnt, sid, ref } = request.body || {};
142
302
  if (!url || typeof url !== 'string') {
143
303
  return reply.status(400).send({ error: 'url is required' });
144
304
  }
@@ -159,14 +319,31 @@ export default async function analyticsPlugin(fastify, options) {
159
319
  }
160
320
 
161
321
  const today = todayKey();
162
- const [lifetime, daily] = await Promise.all([
322
+ const [lifetime, daily, journeys] = await Promise.all([
163
323
  readJson(LIFETIME_FILE, {}),
164
- readJson(DAILY_FILE, {})
324
+ readJson(DAILY_FILE, {}),
325
+ readJson(JOURNEYS_FILE, {})
165
326
  ]);
327
+
166
328
  lifetime[normalised] = (lifetime[normalised] || 0) + 1;
167
329
  if (!daily[today]) daily[today] = {};
168
330
  daily[today][normalised] = (daily[today][normalised] || 0) + 1;
169
- await Promise.all([writeJson(LIFETIME_FILE, lifetime), writeJson(DAILY_FILE, daily)]);
331
+
332
+ // Journey event — only if sid is a string of reasonable shape, and under cap
333
+ const safeSid = typeof sid === 'string' && sid.length > 0 && sid.length <= 64 ? sid : null;
334
+ const safeRef = typeof ref === 'string' && ref.length <= 512 ? ref : '';
335
+ if (safeSid) {
336
+ if (!journeys[today]) journeys[today] = [];
337
+ if (shouldRecordJourney(journeys[today])) {
338
+ journeys[today].push({ sid: safeSid, t: Date.now(), url: normalised, ref: safeRef });
339
+ }
340
+ }
341
+
342
+ await Promise.all([
343
+ writeJson(LIFETIME_FILE, lifetime),
344
+ writeJson(DAILY_FILE, daily),
345
+ writeJson(JOURNEYS_FILE, journeys)
346
+ ]);
170
347
  return { ok: true };
171
348
  });
172
349
 
@@ -212,6 +389,37 @@ export default async function analyticsPlugin(fastify, options) {
212
389
  return { series: buildSeries(daily, dates.start, dates.end) };
213
390
  });
214
391
 
392
+ fastify.get('/journeys', { preHandler: [authenticate, requireAdmin] }, async (request) => {
393
+ const { range = '7d', from, to } = request.query || {};
394
+ const dates = rangeToDates(range === 'all' ? '30d' : range, from, to);
395
+ const journeys = await readJson(JOURNEYS_FILE, {});
396
+ const events = eventsInRange(journeys, dates.start, dates.end);
397
+ const agg = aggregateJourneys(events);
398
+ return {
399
+ range,
400
+ from: todayKey(dates.start),
401
+ to: todayKey(dates.end),
402
+ ...agg
403
+ };
404
+ });
405
+
406
+ fastify.get('/realtime', { preHandler: [authenticate, requireAdmin] }, async () => {
407
+ const journeys = await readJson(JOURNEYS_FILE, {});
408
+ const today = todayKey();
409
+ const events = journeys[today] || [];
410
+ const cutoff = Date.now() - REALTIME_WINDOW_MS;
411
+ const sids = new Set();
412
+ for (const ev of events) {
413
+ if (ev.t >= cutoff) sids.add(ev.sid);
414
+ }
415
+ return { activeSessions: sids.size, windowMinutes: REALTIME_WINDOW_MS / 60000 };
416
+ });
417
+
418
+ fastify.get('/spikes', { preHandler: [authenticate, requireAdmin] }, async () => {
419
+ const daily = await readJson(DAILY_FILE, {});
420
+ return { items: detectSpikes(daily) };
421
+ });
422
+
215
423
  fastify.get('/export.csv', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
216
424
  const { range = '30d', from, to } = request.query || {};
217
425
  const dates = rangeToDates(range, from, to);
@@ -236,7 +444,11 @@ export default async function analyticsPlugin(fastify, options) {
236
444
  });
237
445
 
238
446
  fastify.delete('/stats', { preHandler: [authenticate, requireAdmin] }, async () => {
239
- await Promise.all([writeJson(LIFETIME_FILE, {}), writeJson(DAILY_FILE, {})]);
447
+ await Promise.all([
448
+ writeJson(LIFETIME_FILE, {}),
449
+ writeJson(DAILY_FILE, {}),
450
+ writeJson(JOURNEYS_FILE, {})
451
+ ]);
240
452
  try {
241
453
  await fs.unlink(LEGACY_FILE);
242
454
  } catch {
@@ -245,3 +457,6 @@ export default async function analyticsPlugin(fastify, options) {
245
457
  return { ok: true };
246
458
  });
247
459
  }
460
+
461
+ export { aggregateJourneys, detectSpikes, shouldRecordJourney };
462
+
@@ -10,6 +10,21 @@
10
10
 
11
11
  var url = window.location.pathname;
12
12
 
13
+ // Session-scoped anonymous ID — clears with the tab. Cookie-less.
14
+ var sid = '';
15
+ try {
16
+ sid = sessionStorage.getItem('dm_sid') || '';
17
+ if (!sid) {
18
+ sid = (crypto && crypto.randomUUID)
19
+ ? crypto.randomUUID()
20
+ : (Date.now().toString(36) + Math.random().toString(36).slice(2, 10));
21
+ sessionStorage.setItem('dm_sid', sid);
22
+ }
23
+ } catch (e) {
24
+ // sessionStorage unavailable — proceed without sid
25
+ }
26
+
27
+ // Session dedup (one hit per URL per session)
13
28
  try {
14
29
  var key = 'dm_analytics_seen';
15
30
  var seen = sessionStorage.getItem(key);
@@ -18,14 +33,23 @@
18
33
  list.push(url);
19
34
  sessionStorage.setItem(key, list.join('|'));
20
35
  } catch (e) {
21
- // sessionStorage unavailable (e.g. private mode) — record anyway
36
+ // proceed anyway
22
37
  }
23
38
 
39
+ // Strip query/fragment from referrer; only keep same-origin or external origin+path
40
+ var ref = '';
41
+ try {
42
+ if (document.referrer) {
43
+ var r = new URL(document.referrer);
44
+ ref = r.origin === window.location.origin ? r.pathname : (r.origin + r.pathname);
45
+ }
46
+ } catch (e) { /* ignore */ }
47
+
24
48
  fetch('/api/plugins/analytics/hit', {
25
49
  method: 'POST',
26
50
  headers: { 'Content-Type': 'application/json' },
27
51
  keepalive: true,
28
- body: JSON.stringify({ url: url })
52
+ body: JSON.stringify({ url: url, sid: sid, ref: ref })
29
53
  }).catch(function () { /* silent */ });
30
54
  })();
31
55
  </script>
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Dashboard API
3
+ * Aggregates traffic, journeys, spikes, health, and activity for the admin dashboard.
4
+ *
5
+ * GET /api/dashboard/summary full payload
6
+ * GET /api/dashboard/summary?lite=1 KPI strip + spikes + health.status only
7
+ */
8
+ import fs from 'fs/promises';
9
+ import path from 'path';
10
+ import {fileURLToPath} from 'url';
11
+ import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin} from '../../middleware/auth.js';
12
+ import {getHealth} from '../../services/health.js';
13
+ import {listVersions} from '../../services/versions.js';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
17
+
18
+ const ACTIVITY_LIMIT = 10;
19
+ const VERSIONS_PER_PAGE = 3;
20
+ const ENTRIES_PER_COLLECTION = 3;
21
+
22
+ /**
23
+ * @param {Date} [d]
24
+ * @returns {string}
25
+ */
26
+ function todayKey(d = new Date()) {
27
+ return d.toISOString().slice(0, 10);
28
+ }
29
+
30
+ /**
31
+ * @param {number} n
32
+ * @returns {string}
33
+ */
34
+ function isoDaysAgo(n) {
35
+ const d = new Date();
36
+ d.setUTCHours(0, 0, 0, 0);
37
+ d.setUTCDate(d.getUTCDate() - n);
38
+ return todayKey(d);
39
+ }
40
+
41
+ /**
42
+ * @param {object} daily
43
+ * @param {string} key
44
+ * @returns {number}
45
+ */
46
+ function sumDay(daily, key) {
47
+ return Object.values(daily[key] || {}).reduce((a, n) => a + n, 0);
48
+ }
49
+
50
+ /**
51
+ * @param {object} daily
52
+ * @param {string} fromKey
53
+ * @param {string} toKey
54
+ * @returns {number}
55
+ */
56
+ function sumRange(daily, fromKey, toKey) {
57
+ let total = 0;
58
+ for (const [day, urls] of Object.entries(daily)) {
59
+ if (day < fromKey || day > toKey) continue;
60
+ for (const n of Object.values(urls)) total += n;
61
+ }
62
+ return total;
63
+ }
64
+
65
+ /**
66
+ * Build the top-pages widget data, including a per-day sparkline and a
67
+ * percentage delta versus the previous comparable window.
68
+ *
69
+ * @param {object} daily
70
+ * @param {number} [days]
71
+ * @returns {Array<{url: string, hits: number, deltaPct: number|null, spark: number[]}>}
72
+ */
73
+ function buildTopPages(daily, days = 7) {
74
+ const totals = {};
75
+ const perDay = {};
76
+ for (let i = days - 1; i >= 0; i -= 1) {
77
+ const k = isoDaysAgo(i);
78
+ for (const [url, n] of Object.entries(daily[k] || {})) {
79
+ totals[url] = (totals[url] || 0) + n;
80
+ if (!perDay[url]) perDay[url] = new Array(days).fill(0);
81
+ perDay[url][days - 1 - i] = n;
82
+ }
83
+ }
84
+ const prevTotals = {};
85
+ for (let i = 2 * days - 1; i >= days; i -= 1) {
86
+ const k = isoDaysAgo(i);
87
+ for (const [url, n] of Object.entries(daily[k] || {})) {
88
+ prevTotals[url] = (prevTotals[url] || 0) + n;
89
+ }
90
+ }
91
+ return Object.entries(totals)
92
+ .map(([url, hits]) => {
93
+ const prev = prevTotals[url] || 0;
94
+ const deltaPct = prev === 0 ? null : +(((hits - prev) / prev) * 100).toFixed(1);
95
+ return { url, hits, deltaPct, spark: perDay[url] };
96
+ })
97
+ .sort((a, b) => b.hits - a.hits)
98
+ .slice(0, 5);
99
+ }
100
+
101
+ /**
102
+ * Recent activity feed — combines page version events with recent collection
103
+ * entries (form submissions). Returns at most 10 items, newest first.
104
+ *
105
+ * @returns {Promise<Array<object>>}
106
+ */
107
+ async function buildActivity() {
108
+ const items = [];
109
+
110
+ // Versions — read each page's _meta.json via the service so the schema
111
+ // stays in one place. listVersions returns newest-first.
112
+ const versionsDir = path.join(ROOT, 'content', 'versions');
113
+ try {
114
+ const dirs = await fs.readdir(versionsDir, { withFileTypes: true });
115
+ for (const d of dirs) {
116
+ if (!d.isDirectory()) continue;
117
+ const urlPath = '/' + (d.name === '_index' ? '' : d.name);
118
+ let versions = [];
119
+ try { versions = await listVersions(urlPath); }
120
+ catch { continue; }
121
+ for (const v of versions.slice(0, VERSIONS_PER_PAGE)) {
122
+ items.push({
123
+ type: v.type === 'manual' ? 'publish' : 'edit',
124
+ at: v.createdAt,
125
+ actor: v.author || 'unknown',
126
+ target: urlPath
127
+ });
128
+ }
129
+ }
130
+ } catch { /* no versions dir */ }
131
+
132
+ // Collection entries — each slug has a single data.json with an array of
133
+ // entries. Take the newest ENTRIES_PER_COLLECTION by meta.createdAt.
134
+ const collectionsDir = path.join(ROOT, 'content', 'collections');
135
+ try {
136
+ const slugs = await fs.readdir(collectionsDir, { withFileTypes: true });
137
+ for (const slug of slugs) {
138
+ if (!slug.isDirectory()) continue;
139
+ const dataFile = path.join(collectionsDir, slug.name, 'data.json');
140
+ let entries = [];
141
+ try { entries = JSON.parse(await fs.readFile(dataFile, 'utf8')); }
142
+ catch { continue; }
143
+ if (!Array.isArray(entries)) continue;
144
+
145
+ const sorted = entries
146
+ .filter(e => e && e.meta && e.meta.createdAt)
147
+ .sort((a, b) => (b.meta.createdAt || '').localeCompare(a.meta.createdAt || ''))
148
+ .slice(0, ENTRIES_PER_COLLECTION);
149
+
150
+ for (const e of sorted) {
151
+ items.push({
152
+ type: 'form',
153
+ at: e.meta.createdAt,
154
+ form: slug.name
155
+ });
156
+ }
157
+ }
158
+ } catch { /* no collections dir */ }
159
+
160
+ return items
161
+ .filter(i => i.at)
162
+ .sort((a, b) => (b.at || '').localeCompare(a.at || ''))
163
+ .slice(0, ACTIVITY_LIMIT);
164
+ }
165
+
166
+ /**
167
+ * Register the dashboard routes.
168
+ *
169
+ * Auth middlewares are accepted as options so tests can supply no-ops without
170
+ * needing to register `@fastify/jwt` on the test instance. In production the
171
+ * defaults are the real `authenticate` / `requireAdmin` from middleware/auth.js.
172
+ *
173
+ * @param {import('fastify').FastifyInstance} fastify
174
+ * @param {{authenticate?: Function, requireAdmin?: Function}} [opts]
175
+ * @returns {Promise<void>}
176
+ */
177
+ export async function dashboardRoutes(fastify, opts = {}) {
178
+ const authenticate = opts.authenticate || defaultAuthenticate;
179
+ const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
180
+ const guard = { preHandler: [authenticate, requireAdmin] };
181
+
182
+ fastify.get('/dashboard/summary', guard, async (request) => {
183
+ const lite = request.query.lite === '1' || request.query.lite === 'true';
184
+ const warnings = [];
185
+ const safe = async (label, fn) => {
186
+ try { return await fn(); }
187
+ catch (e) {
188
+ // Strip absolute paths from error messages so we don't disclose deployment layout.
189
+ const msg = String(e.message || e).replace(/(\/|[A-Za-z]:\\)[^\s'"]+/g, '<path>');
190
+ warnings.push(`${label}: ${msg}`);
191
+ return null;
192
+ }
193
+ };
194
+
195
+ const analytics = fastify.analytics;
196
+ const daily = analytics ? (await safe('analytics.daily', () => analytics.getDaily())) || {} : {};
197
+
198
+ const today = todayKey();
199
+ const yesterday = isoDaysAgo(1);
200
+ const todayHits = sumDay(daily, today);
201
+ const yesterdayHits = sumDay(daily, yesterday);
202
+ const deltaPct = yesterdayHits === 0
203
+ ? null
204
+ : +(((todayHits - yesterdayHits) / yesterdayHits) * 100).toFixed(1);
205
+ const traffic = {
206
+ today: todayHits,
207
+ yesterday: yesterdayHits,
208
+ deltaPct,
209
+ weekToDate: sumRange(daily, isoDaysAgo(6), today),
210
+ previousWeek: sumRange(daily, isoDaysAgo(13), isoDaysAgo(7))
211
+ };
212
+
213
+ const realtime = analytics
214
+ ? (await safe('analytics.realtime', () => analytics.getRealtime())) || { activeSessions: 0 }
215
+ : { activeSessions: 0 };
216
+ const spikes = analytics
217
+ ? ((await safe('analytics.spikes', () => analytics.getSpikes())) || { items: [] }).items
218
+ : [];
219
+ const health = await safe('health', () => getHealth()) || { status: 'ok', checks: [] };
220
+
221
+ if (lite) {
222
+ return {
223
+ traffic: { today: traffic.today, yesterday: traffic.yesterday, deltaPct: traffic.deltaPct },
224
+ realtime,
225
+ spikes,
226
+ health: { status: health.status },
227
+ warnings
228
+ };
229
+ }
230
+
231
+ const topPages = buildTopPages(daily);
232
+ const journeys = analytics
233
+ ? (await safe('analytics.journeys', () => analytics.getJourneys({ range: '7d' }))) || null
234
+ : null;
235
+ const activity = (await safe('activity', () => buildActivity())) || [];
236
+
237
+ return { traffic, topPages, journeys, spikes, realtime, health, activity, warnings };
238
+ });
239
+ }
package/server/server.js CHANGED
@@ -249,6 +249,7 @@ const {componentsRoutes} = await import('./routes/api/components.js');
249
249
  const {versionsRoutes} = await import('./routes/api/versions.js');
250
250
  const {effectsRoutes} = await import('./routes/api/effects.js');
251
251
  const {notificationsRoutes} = await import('./routes/api/notifications.js');
252
+ const {dashboardRoutes} = await import('./routes/api/dashboard.js');
252
253
 
253
254
  await app.register(pagesRoutes, { prefix: '/api' });
254
255
  await app.register(settingsRoutes, { prefix: '/api' });
@@ -267,6 +268,7 @@ await app.register(componentsRoutes, {prefix: '/api'});
267
268
  await app.register(versionsRoutes, {prefix: '/api'});
268
269
  await app.register(effectsRoutes, {prefix: '/api'});
269
270
  await app.register(notificationsRoutes, {prefix: '/api'});
271
+ await app.register(dashboardRoutes, {prefix: '/api'});
270
272
 
271
273
  // ---------------------------------------------------------------------------
272
274
  // CMS Plugins (server-side Fastify plugins from plugins/ directory)