domma-cms 0.15.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/admin/templates/analytics.html +52 -1
- package/plugins/analytics/admin/views/analytics.js +157 -32
- package/plugins/analytics/config.js +10 -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 +429 -25
- package/plugins/analytics/plugin.json +9 -5
- package/plugins/analytics/public/inject-body.html +49 -7
- package/plugins/blog/admin/templates/blog.html +25 -2
- package/plugins/blog/admin/views/blog.js +72 -56
- package/plugins/blog/admin/views/post-editor.js +98 -79
- package/plugins/blog/plugin.js +133 -0
- package/plugins/blog/plugin.json +3 -3
- package/plugins/blog/templates/post.html +2 -1
- package/plugins/invoice/admin/templates/editor.html +129 -0
- package/plugins/invoice/admin/templates/index.html +43 -0
- package/plugins/invoice/admin/templates/issuers.html +5 -0
- package/plugins/invoice/admin/templates/receivers.html +5 -0
- package/plugins/invoice/admin/views/editor.js +267 -0
- package/plugins/invoice/admin/views/index.js +155 -0
- package/plugins/invoice/admin/views/issuers.js +23 -0
- package/plugins/invoice/admin/views/party-view.js +148 -0
- package/plugins/invoice/admin/views/receivers.js +22 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +16 -0
- package/plugins/invoice/collections/invoice-receivers/schema.json +15 -0
- package/plugins/invoice/collections/invoices/schema.json +27 -0
- package/plugins/invoice/config.js +16 -0
- package/plugins/invoice/plugin.js +283 -0
- package/plugins/invoice/plugin.json +85 -0
- package/plugins/invoice/templates/invoice-print.html +213 -0
- 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/markdown.js +24 -4
- package/server/services/plugins.js +37 -5
- package/server/services/renderer.js +9 -3
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"/": 162,
|
|
3
|
+
"/about": 88,
|
|
4
|
+
"/blog": 42,
|
|
5
|
+
"/contact": 31,
|
|
6
|
+
"/resources/typography": 4,
|
|
7
|
+
"/resources": 13,
|
|
8
|
+
"/resources/shortcodes": 14,
|
|
9
|
+
"/resources/cards": 19,
|
|
10
|
+
"/resources/interactive": 13,
|
|
11
|
+
"/resources/grid": 6,
|
|
12
|
+
"/forms": 14,
|
|
13
|
+
"/resources/effects": 6,
|
|
14
|
+
"/blog/hello-world": 28,
|
|
15
|
+
"/feedback": 42,
|
|
16
|
+
"/resources/dependencies": 2,
|
|
17
|
+
"/resources/components": 6,
|
|
18
|
+
"/gdpr": 3,
|
|
19
|
+
"/scratch": 84,
|
|
20
|
+
"/getting-started": 3,
|
|
21
|
+
"/resources/pro": 1,
|
|
22
|
+
"/todo": 23,
|
|
23
|
+
"/thank-you": 1,
|
|
24
|
+
"/test-from-controller": 1
|
|
25
|
+
}
|
|
@@ -1,58 +1,462 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Analytics Plugin — Server
|
|
3
|
-
* Tracks page hits
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* Tracks page hits with per-day buckets so we can chart trends and
|
|
4
|
+
* filter by time range.
|
|
5
|
+
*
|
|
6
|
+
* Storage:
|
|
7
|
+
* lifetime.json { "/url": totalHits }
|
|
8
|
+
* daily.json { "YYYY-MM-DD": { "/url": hits } }
|
|
9
|
+
*
|
|
10
|
+
* Endpoints (mounted at /api/plugins/analytics):
|
|
11
|
+
* POST /hit public record a hit { url, dnt }
|
|
12
|
+
* GET /stats admin summary + top pages (?range=today|7d|30d|90d|all|custom&from=&to=)
|
|
13
|
+
* GET /series admin per-day totals series (?days=N or ?from=&to=)
|
|
14
|
+
* GET /export.csv admin CSV export of top pages for the active range
|
|
15
|
+
* DELETE /stats admin reset all stats
|
|
8
16
|
*/
|
|
9
17
|
import fs from 'fs/promises';
|
|
10
18
|
import path from 'path';
|
|
11
19
|
import {fileURLToPath} from 'url';
|
|
12
20
|
|
|
13
21
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
-
const
|
|
22
|
+
const LIFETIME_FILE = path.join(__dirname, 'lifetime.json');
|
|
23
|
+
const DAILY_FILE = path.join(__dirname, 'daily.json');
|
|
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;
|
|
15
28
|
|
|
16
|
-
async function
|
|
29
|
+
async function readJson(file, fallback) {
|
|
17
30
|
try {
|
|
18
|
-
return JSON.parse(await fs.readFile(
|
|
31
|
+
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
19
32
|
} catch {
|
|
20
|
-
return
|
|
33
|
+
return fallback;
|
|
21
34
|
}
|
|
22
35
|
}
|
|
23
36
|
|
|
24
|
-
async function
|
|
25
|
-
|
|
37
|
+
async function writeJson(file, data) {
|
|
38
|
+
const tmp = file + '.tmp';
|
|
39
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
40
|
+
await fs.rename(tmp, file);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One-shot migration from the legacy flat stats.json.
|
|
45
|
+
* Preserves existing totals as lifetime numbers; daily history starts empty.
|
|
46
|
+
* Also seeds journeys.json if missing.
|
|
47
|
+
*/
|
|
48
|
+
async function migrateLegacy() {
|
|
49
|
+
let lifetimeExists = false;
|
|
50
|
+
try {
|
|
51
|
+
await fs.access(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
|
+
}
|
|
62
|
+
await writeJson(DAILY_FILE, {});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await fs.access(JOURNEYS_FILE);
|
|
67
|
+
} catch {
|
|
68
|
+
await writeJson(JOURNEYS_FILE, {});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function todayKey(date = new Date()) {
|
|
73
|
+
return date.toISOString().slice(0, 10);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function rangeToDates(range, from, to) {
|
|
77
|
+
const today = new Date();
|
|
78
|
+
today.setUTCHours(0, 0, 0, 0);
|
|
79
|
+
const end = to ? new Date(to + 'T00:00:00Z') : today;
|
|
80
|
+
let start;
|
|
81
|
+
switch (range) {
|
|
82
|
+
case 'today':
|
|
83
|
+
start = today;
|
|
84
|
+
break;
|
|
85
|
+
case '7d':
|
|
86
|
+
start = new Date(today);
|
|
87
|
+
start.setUTCDate(start.getUTCDate() - 6);
|
|
88
|
+
break;
|
|
89
|
+
case '30d':
|
|
90
|
+
start = new Date(today);
|
|
91
|
+
start.setUTCDate(start.getUTCDate() - 29);
|
|
92
|
+
break;
|
|
93
|
+
case '90d':
|
|
94
|
+
start = new Date(today);
|
|
95
|
+
start.setUTCDate(start.getUTCDate() - 89);
|
|
96
|
+
break;
|
|
97
|
+
case 'custom':
|
|
98
|
+
start = from ? new Date(from + 'T00:00:00Z') : today;
|
|
99
|
+
break;
|
|
100
|
+
case 'all':
|
|
101
|
+
default:
|
|
102
|
+
return null; // all-time → use lifetime aggregate
|
|
103
|
+
}
|
|
104
|
+
return { start, end };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function aggregateDaily(daily, start, end) {
|
|
108
|
+
const totals = {};
|
|
109
|
+
let total = 0;
|
|
110
|
+
const startMs = start.getTime();
|
|
111
|
+
const endMs = end.getTime();
|
|
112
|
+
for (const [day, urls] of Object.entries(daily)) {
|
|
113
|
+
const dayMs = new Date(day + 'T00:00:00Z').getTime();
|
|
114
|
+
if (dayMs < startMs || dayMs > endMs) continue;
|
|
115
|
+
for (const [url, hits] of Object.entries(urls)) {
|
|
116
|
+
totals[url] = (totals[url] || 0) + hits;
|
|
117
|
+
total += hits;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { totals, total };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildSeries(daily, start, end) {
|
|
124
|
+
const series = [];
|
|
125
|
+
const cursor = new Date(start);
|
|
126
|
+
while (cursor.getTime() <= end.getTime()) {
|
|
127
|
+
const key = todayKey(cursor);
|
|
128
|
+
const day = daily[key] || {};
|
|
129
|
+
const hits = Object.values(day).reduce((acc, n) => acc + n, 0);
|
|
130
|
+
series.push({ date: key, hits });
|
|
131
|
+
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
132
|
+
}
|
|
133
|
+
return series;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function escapeCsvCell(value) {
|
|
137
|
+
const str = String(value ?? '');
|
|
138
|
+
if (/[",\n\r]/.test(str)) {
|
|
139
|
+
return '"' + str.replace(/"/g, '""') + '"';
|
|
140
|
+
}
|
|
141
|
+
return str;
|
|
142
|
+
}
|
|
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;
|
|
26
250
|
}
|
|
27
251
|
|
|
28
252
|
export default async function analyticsPlugin(fastify, options) {
|
|
29
|
-
|
|
253
|
+
const { authenticate, requireAdmin } = options.auth;
|
|
254
|
+
const settings = options.settings || {};
|
|
255
|
+
|
|
256
|
+
await migrateLegacy();
|
|
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
|
+
});
|
|
30
299
|
|
|
31
|
-
// Record a page hit — called by the client-side injection script (public)
|
|
32
300
|
fastify.post('/hit', async (request, reply) => {
|
|
33
|
-
const { url } = request.body || {};
|
|
301
|
+
const { url, dnt, sid, ref } = request.body || {};
|
|
34
302
|
if (!url || typeof url !== 'string') {
|
|
35
303
|
return reply.status(400).send({ error: 'url is required' });
|
|
36
304
|
}
|
|
305
|
+
if (dnt === true || dnt === '1') {
|
|
306
|
+
return { ok: true, ignored: 'dnt' };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const normalised = url.split('?')[0].split('#')[0].replace(/\/+$/, '') || '/';
|
|
310
|
+
if (settings.excludeAdmin && normalised.startsWith('/admin')) {
|
|
311
|
+
return { ok: true, ignored: 'admin' };
|
|
312
|
+
}
|
|
313
|
+
if (Array.isArray(settings.excludePaths)) {
|
|
314
|
+
for (const prefix of settings.excludePaths) {
|
|
315
|
+
if (typeof prefix === 'string' && prefix && normalised.startsWith(prefix)) {
|
|
316
|
+
return { ok: true, ignored: 'excluded' };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
37
320
|
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
321
|
+
const today = todayKey();
|
|
322
|
+
const [lifetime, daily, journeys] = await Promise.all([
|
|
323
|
+
readJson(LIFETIME_FILE, {}),
|
|
324
|
+
readJson(DAILY_FILE, {}),
|
|
325
|
+
readJson(JOURNEYS_FILE, {})
|
|
326
|
+
]);
|
|
327
|
+
|
|
328
|
+
lifetime[normalised] = (lifetime[normalised] || 0) + 1;
|
|
329
|
+
if (!daily[today]) daily[today] = {};
|
|
330
|
+
daily[today][normalised] = (daily[today][normalised] || 0) + 1;
|
|
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
|
+
]);
|
|
42
347
|
return { ok: true };
|
|
43
348
|
});
|
|
44
349
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
350
|
+
fastify.get('/stats', { preHandler: [authenticate, requireAdmin] }, async (request) => {
|
|
351
|
+
const { range = '30d', from, to } = request.query || {};
|
|
352
|
+
const dates = rangeToDates(range, from, to);
|
|
353
|
+
const [lifetime, daily] = await Promise.all([
|
|
354
|
+
readJson(LIFETIME_FILE, {}),
|
|
355
|
+
readJson(DAILY_FILE, {})
|
|
356
|
+
]);
|
|
357
|
+
|
|
358
|
+
let totals;
|
|
359
|
+
let total;
|
|
360
|
+
if (!dates) {
|
|
361
|
+
totals = lifetime;
|
|
362
|
+
total = Object.values(lifetime).reduce((acc, n) => acc + n, 0);
|
|
363
|
+
} else {
|
|
364
|
+
const agg = aggregateDaily(daily, dates.start, dates.end);
|
|
365
|
+
totals = agg.totals;
|
|
366
|
+
total = agg.total;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const top = Object.entries(totals)
|
|
49
370
|
.map(([url, hits]) => ({ url, hits }))
|
|
50
371
|
.sort((a, b) => b.hits - a.hits);
|
|
372
|
+
|
|
373
|
+
const lifetimeTotal = Object.values(lifetime).reduce((acc, n) => acc + n, 0);
|
|
374
|
+
return {
|
|
375
|
+
range,
|
|
376
|
+
from: dates ? todayKey(dates.start) : null,
|
|
377
|
+
to: dates ? todayKey(dates.end) : null,
|
|
378
|
+
total,
|
|
379
|
+
lifetimeTotal,
|
|
380
|
+
uniquePages: top.length,
|
|
381
|
+
top
|
|
382
|
+
};
|
|
51
383
|
});
|
|
52
384
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
385
|
+
fastify.get('/series', { preHandler: [authenticate, requireAdmin] }, async (request) => {
|
|
386
|
+
const { range = '30d', from, to } = request.query || {};
|
|
387
|
+
const dates = rangeToDates(range === 'all' ? '30d' : range, from, to);
|
|
388
|
+
const daily = await readJson(DAILY_FILE, {});
|
|
389
|
+
return { series: buildSeries(daily, dates.start, dates.end) };
|
|
390
|
+
});
|
|
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
|
+
|
|
423
|
+
fastify.get('/export.csv', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
|
|
424
|
+
const { range = '30d', from, to } = request.query || {};
|
|
425
|
+
const dates = rangeToDates(range, from, to);
|
|
426
|
+
const [lifetime, daily] = await Promise.all([
|
|
427
|
+
readJson(LIFETIME_FILE, {}),
|
|
428
|
+
readJson(DAILY_FILE, {})
|
|
429
|
+
]);
|
|
430
|
+
const totals = dates ? aggregateDaily(daily, dates.start, dates.end).totals : lifetime;
|
|
431
|
+
const rows = Object.entries(totals)
|
|
432
|
+
.map(([url, hits]) => ({ url, hits }))
|
|
433
|
+
.sort((a, b) => b.hits - a.hits);
|
|
434
|
+
|
|
435
|
+
const lines = ['url,hits'];
|
|
436
|
+
for (const row of rows) {
|
|
437
|
+
lines.push(`${escapeCsvCell(row.url)},${row.hits}`);
|
|
438
|
+
}
|
|
439
|
+
const filename = `analytics-${range}-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
440
|
+
reply
|
|
441
|
+
.header('Content-Type', 'text/csv; charset=utf-8')
|
|
442
|
+
.header('Content-Disposition', `attachment; filename="${filename}"`);
|
|
443
|
+
return lines.join('\n') + '\n';
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
fastify.delete('/stats', { preHandler: [authenticate, requireAdmin] }, async () => {
|
|
447
|
+
await Promise.all([
|
|
448
|
+
writeJson(LIFETIME_FILE, {}),
|
|
449
|
+
writeJson(DAILY_FILE, {}),
|
|
450
|
+
writeJson(JOURNEYS_FILE, {})
|
|
451
|
+
]);
|
|
452
|
+
try {
|
|
453
|
+
await fs.unlink(LEGACY_FILE);
|
|
454
|
+
} catch {
|
|
455
|
+
// ignore
|
|
456
|
+
}
|
|
56
457
|
return { ok: true };
|
|
57
458
|
});
|
|
58
459
|
}
|
|
460
|
+
|
|
461
|
+
export { aggregateJourneys, detectSpikes, shouldRecordJourney };
|
|
462
|
+
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "analytics",
|
|
3
3
|
"displayName": "Analytics",
|
|
4
|
-
"version": "
|
|
5
|
-
"description": "
|
|
4
|
+
"version": "2.0.0",
|
|
5
|
+
"description": "Page view analytics with daily trends, time-range filtering, top-pages table and CSV export. Honours Do-Not-Track and dedups per session.",
|
|
6
6
|
"author": "Darryl Waterhouse",
|
|
7
|
-
"date": "2026-
|
|
7
|
+
"date": "2026-05-04",
|
|
8
8
|
"icon": "chart-bar",
|
|
9
9
|
"admin": {
|
|
10
10
|
"sidebar": [
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
],
|
|
26
26
|
"views": {
|
|
27
27
|
"plugin-analytics": {
|
|
28
|
-
"entry": "analytics/admin/views/analytics.js?v=
|
|
28
|
+
"entry": "analytics/admin/views/analytics.js?v=2c26480d",
|
|
29
29
|
"exportName": "analyticsView"
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -37,7 +37,11 @@
|
|
|
37
37
|
"scaffold": {
|
|
38
38
|
"reset": [
|
|
39
39
|
{
|
|
40
|
-
"path": "
|
|
40
|
+
"path": "lifetime.json",
|
|
41
|
+
"content": "{}"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"path": "daily.json",
|
|
41
45
|
"content": "{}"
|
|
42
46
|
}
|
|
43
47
|
]
|
|
@@ -1,13 +1,55 @@
|
|
|
1
|
-
<!-- analytics: page view tracker -->
|
|
1
|
+
<!-- analytics: page view tracker (DNT-aware, session-deduped) -->
|
|
2
2
|
<script>
|
|
3
3
|
(function () {
|
|
4
|
+
if (typeof fetch !== 'function') return;
|
|
5
|
+
|
|
6
|
+
var dnt = navigator.doNotTrack === '1'
|
|
7
|
+
|| window.doNotTrack === '1'
|
|
8
|
+
|| navigator.msDoNotTrack === '1';
|
|
9
|
+
if (dnt) return;
|
|
10
|
+
|
|
4
11
|
var url = window.location.pathname;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
11
25
|
}
|
|
26
|
+
|
|
27
|
+
// Session dedup (one hit per URL per session)
|
|
28
|
+
try {
|
|
29
|
+
var key = 'dm_analytics_seen';
|
|
30
|
+
var seen = sessionStorage.getItem(key);
|
|
31
|
+
var list = seen ? seen.split('|') : [];
|
|
32
|
+
if (list.indexOf(url) !== -1) return;
|
|
33
|
+
list.push(url);
|
|
34
|
+
sessionStorage.setItem(key, list.join('|'));
|
|
35
|
+
} catch (e) {
|
|
36
|
+
// proceed anyway
|
|
37
|
+
}
|
|
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
|
+
|
|
48
|
+
fetch('/api/plugins/analytics/hit', {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
keepalive: true,
|
|
52
|
+
body: JSON.stringify({ url: url, sid: sid, ref: ref })
|
|
53
|
+
}).catch(function () { /* silent */ });
|
|
12
54
|
})();
|
|
13
55
|
</script>
|
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
<div class="view-header d-flex align-items-center justify-content-between mb-4">
|
|
2
|
-
<h1 class="h3 mb-0">Blog Posts</h1>
|
|
2
|
+
<h1 class="h3 mb-0"><span data-icon="file-text"></span> Blog Posts</h1>
|
|
3
3
|
<button id="btn-new-post" class="btn btn-primary">
|
|
4
4
|
<span data-icon="plus"></span> New Post
|
|
5
5
|
</button>
|
|
6
6
|
</div>
|
|
7
7
|
|
|
8
|
+
<div class="grid grid-cols-4 gap-3 mb-4">
|
|
9
|
+
<div class="card"><div class="card-body">
|
|
10
|
+
<div class="text-muted small">Total</div>
|
|
11
|
+
<div class="h3 mb-0" id="kpi-total">—</div>
|
|
12
|
+
</div></div>
|
|
13
|
+
<div class="card"><div class="card-body">
|
|
14
|
+
<div class="text-muted small">Published</div>
|
|
15
|
+
<div class="h3 mb-0" id="kpi-published">—</div>
|
|
16
|
+
</div></div>
|
|
17
|
+
<div class="card"><div class="card-body">
|
|
18
|
+
<div class="text-muted small">Drafts</div>
|
|
19
|
+
<div class="h3 mb-0" id="kpi-drafts">—</div>
|
|
20
|
+
</div></div>
|
|
21
|
+
<div class="card"><div class="card-body">
|
|
22
|
+
<div class="text-muted small">Scheduled</div>
|
|
23
|
+
<div class="h3 mb-0" id="kpi-scheduled">—</div>
|
|
24
|
+
</div></div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
8
27
|
<div class="card mb-3">
|
|
9
28
|
<div class="card-body">
|
|
10
29
|
<div class="d-flex gap-2 flex-wrap">
|
|
@@ -14,7 +33,11 @@
|
|
|
14
33
|
<option value="scheduled">Scheduled</option>
|
|
15
34
|
<option value="published">Published</option>
|
|
16
35
|
</select>
|
|
17
|
-
<
|
|
36
|
+
<select id="filter-category" class="form-select" style="width:auto;">
|
|
37
|
+
<option value="">All categories</option>
|
|
38
|
+
</select>
|
|
39
|
+
<input id="filter-search" type="text" class="form-control" style="width:240px;" placeholder="Search title or excerpt…">
|
|
40
|
+
<button id="filter-reset" class="btn btn-ghost btn-sm">Reset</button>
|
|
18
41
|
</div>
|
|
19
42
|
</div>
|
|
20
43
|
</div>
|