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.
- package/admin/css/dashboard.css +1 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +4 -4
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +1 -1
- package/admin/js/templates/dashboard/activity-feed.html +3 -0
- package/admin/js/templates/dashboard/health-detail.html +2 -0
- package/admin/js/templates/dashboard/journeys.html +17 -0
- package/admin/js/templates/dashboard/kpi-strip.html +34 -0
- package/admin/js/templates/dashboard/spike-feed.html +3 -0
- package/admin/js/templates/dashboard/top-pages.html +3 -0
- package/admin/js/templates/dashboard/traffic-chart.html +3 -0
- package/admin/js/templates/dashboard.html +22 -44
- package/admin/js/views/dashboard/lib/escape.js +1 -0
- package/admin/js/views/dashboard/widgets/activity-feed.js +1 -0
- package/admin/js/views/dashboard/widgets/health-detail.js +1 -0
- package/admin/js/views/dashboard/widgets/journeys.js +1 -0
- package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -0
- package/admin/js/views/dashboard/widgets/spike-feed.js +6 -0
- package/admin/js/views/dashboard/widgets/top-pages.js +1 -0
- package/admin/js/views/dashboard/widgets/traffic-chart.js +1 -0
- package/admin/js/views/dashboard.js +1 -1
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/config/plugins.json +3 -0
- package/package.json +2 -2
- package/plugins/analytics/daily.json +5 -0
- package/plugins/analytics/journeys.json +10 -0
- package/plugins/analytics/lifetime.json +25 -0
- package/plugins/analytics/plugin.js +231 -16
- package/plugins/analytics/public/inject-body.html +26 -2
- package/server/routes/api/dashboard.js +239 -0
- package/server/server.js +2 -0
- package/server/services/email.js +60 -20
- package/server/services/health.js +282 -0
- 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
|
-
|
|
48
|
-
} catch {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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([
|
|
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
|
-
//
|
|
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)
|