webspresso 0.0.63 → 0.0.65
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/README.md +42 -1
- package/core/orm/model.js +6 -0
- package/core/orm/types.js +9 -0
- package/index.d.ts +482 -0
- package/index.js +2 -1
- package/package.json +7 -1
- package/plugins/admin-panel/components.js +120 -120
- package/plugins/admin-panel/field-renderers/array.js +2 -2
- package/plugins/admin-panel/field-renderers/basic.js +6 -6
- package/plugins/admin-panel/field-renderers/file-upload.js +2 -2
- package/plugins/admin-panel/field-renderers/json.js +1 -1
- package/plugins/admin-panel/field-renderers/relations.js +2 -2
- package/plugins/admin-panel/field-renderers/rich-text.js +3 -3
- package/plugins/admin-panel/index.js +35 -4
- package/plugins/admin-panel/modules/bulk-actions.js +6 -6
- package/plugins/admin-panel/modules/custom-pages.js +7 -7
- package/plugins/admin-panel/modules/dashboard.js +14 -14
- package/plugins/admin-panel/modules/menu.js +71 -26
- package/plugins/index.js +2 -0
- package/plugins/rest-resources/index.js +350 -0
- package/plugins/site-analytics/admin-component.js +34 -4
- package/plugins/site-analytics/api-handlers.js +74 -1
- package/plugins/site-analytics/index.js +1 -0
- package/src/server.js +128 -36
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST resource plugin — opt-in CRUD routes from ORM models, eager-loaded includes (no N+1)
|
|
3
|
+
* @module plugins/rest-resources
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { attachDbMiddleware } = require('../../src/app-context');
|
|
7
|
+
const { getAllModels } = require('../../core/orm/model');
|
|
8
|
+
const { omit } = require('../../core/orm/utils');
|
|
9
|
+
|
|
10
|
+
const RESERVED_QUERY_KEYS = new Set(['page', 'perPage', 'sort', 'order', 'include', 'trashed']);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pluralize a PascalCase model name to a URL segment (e.g. User -> users, Company -> companies)
|
|
14
|
+
* @param {string} modelName
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function pluralizeSegment(modelName) {
|
|
18
|
+
const base = modelName.charAt(0).toLowerCase() + modelName.slice(1);
|
|
19
|
+
if (base.length >= 2 && base.endsWith('y') && !'aeiou'.includes(base[base.length - 2])) {
|
|
20
|
+
return `${base.slice(0, -1)}ies`;
|
|
21
|
+
}
|
|
22
|
+
if (/(s|x|z|ch|sh)$/i.test(base)) {
|
|
23
|
+
return `${base}es`;
|
|
24
|
+
}
|
|
25
|
+
return `${base}s`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {import('../../core/orm/types').ModelDefinition} model
|
|
30
|
+
* @param {string} raw
|
|
31
|
+
* @returns {string[]}
|
|
32
|
+
*/
|
|
33
|
+
function parseIncludeParam(model, raw) {
|
|
34
|
+
if (raw == null || raw === '') {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const str = typeof raw === 'string' ? raw : String(raw);
|
|
38
|
+
const names = str
|
|
39
|
+
.split(',')
|
|
40
|
+
.map((s) => s.trim())
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
|
|
43
|
+
const allowedRelationNames = model.rest?.allowInclude?.length
|
|
44
|
+
? new Set(model.rest.allowInclude)
|
|
45
|
+
: new Set(Object.keys(model.relations));
|
|
46
|
+
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
if (name.includes('.')) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!allowedRelationNames.has(name) || !model.relations[name]) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
out.push(name);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Strip hidden columns and recurse into loaded relations (belongsTo / hasOne / hasMany).
|
|
62
|
+
* @param {Object|Object[]|null} record
|
|
63
|
+
* @param {import('../../core/orm/types').ModelDefinition} model
|
|
64
|
+
* @returns {Object|Object[]|null}
|
|
65
|
+
*/
|
|
66
|
+
function sanitizeRecordTree(record, model) {
|
|
67
|
+
if (record == null) {
|
|
68
|
+
return record;
|
|
69
|
+
}
|
|
70
|
+
if (Array.isArray(record)) {
|
|
71
|
+
return record.map((r) => sanitizeRecordTree(r, model));
|
|
72
|
+
}
|
|
73
|
+
if (typeof record !== 'object') {
|
|
74
|
+
return record;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let base = model.hidden?.length ? omit(record, model.hidden) : { ...record };
|
|
78
|
+
|
|
79
|
+
for (const [relName, rel] of Object.entries(model.relations)) {
|
|
80
|
+
if (!(relName in base)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const relatedModel = rel.model();
|
|
84
|
+
const val = base[relName];
|
|
85
|
+
if (rel.type === 'hasMany') {
|
|
86
|
+
base[relName] = Array.isArray(val) ? val.map((item) => sanitizeRecordTree(item, relatedModel)) : val;
|
|
87
|
+
} else {
|
|
88
|
+
base[relName] = sanitizeRecordTree(val, relatedModel);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return base;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Only real table columns; drop relations and hidden (aligned with repo validation).
|
|
97
|
+
* @param {Object} body
|
|
98
|
+
* @param {import('../../core/orm/types').ModelDefinition} model
|
|
99
|
+
* @returns {Object}
|
|
100
|
+
*/
|
|
101
|
+
function pickWritableColumns(body, model) {
|
|
102
|
+
const out = {};
|
|
103
|
+
if (!body || typeof body !== 'object') {
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
for (const key of Object.keys(body)) {
|
|
107
|
+
if (!model.columns.has(key)) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const meta = model.columns.get(key);
|
|
111
|
+
if (meta.primary && meta.autoIncrement) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
out[key] = body[key];
|
|
115
|
+
}
|
|
116
|
+
for (const h of model.hidden || []) {
|
|
117
|
+
delete out[h];
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {object} db
|
|
124
|
+
* @param {object} opts
|
|
125
|
+
* @param {string[]|null} [opts.models]
|
|
126
|
+
* @param {string[]} [opts.excludeModels]
|
|
127
|
+
* @param {function(import('../../core/orm/types').ModelDefinition): boolean} [opts.filter]
|
|
128
|
+
* @returns {import('../../core/orm/types').ModelDefinition[]}
|
|
129
|
+
*/
|
|
130
|
+
function resolveExposedModels(db, opts) {
|
|
131
|
+
const allModels = db && typeof db.getAllModels === 'function'
|
|
132
|
+
? Array.from(db.getAllModels().values())
|
|
133
|
+
: Array.from(getAllModels().values());
|
|
134
|
+
|
|
135
|
+
const exclude = new Set(opts.excludeModels || []);
|
|
136
|
+
let list = allModels.filter((m) => !exclude.has(m.name));
|
|
137
|
+
|
|
138
|
+
if (opts.models && opts.models.length > 0) {
|
|
139
|
+
const allow = new Set(opts.models);
|
|
140
|
+
list = list.filter((m) => allow.has(m.name));
|
|
141
|
+
} else {
|
|
142
|
+
list = list.filter((m) => m.rest && m.rest.enabled === true);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (typeof opts.filter === 'function') {
|
|
146
|
+
list = list.filter(opts.filter);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return list;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeBasePath(p) {
|
|
153
|
+
return `/${String(p).replace(/^\/+|\/+$/g, '')}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function applySoftDeleteScope(query, countQuery, model, trashed) {
|
|
157
|
+
if (!model.scopes?.softDelete) {
|
|
158
|
+
return { query, countQuery };
|
|
159
|
+
}
|
|
160
|
+
if (trashed === 'only') {
|
|
161
|
+
return { query: query.onlyTrashed(), countQuery: countQuery.onlyTrashed() };
|
|
162
|
+
}
|
|
163
|
+
if (trashed === 'include') {
|
|
164
|
+
return { query: query.withTrashed(), countQuery: countQuery.withTrashed() };
|
|
165
|
+
}
|
|
166
|
+
return { query, countQuery };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function applyColumnFilters(query, countQuery, model, req) {
|
|
170
|
+
let q = query;
|
|
171
|
+
let c = countQuery;
|
|
172
|
+
for (const [key, value] of Object.entries(req.query)) {
|
|
173
|
+
if (RESERVED_QUERY_KEYS.has(key)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (!model.columns.has(key)) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (value === undefined || value === '') {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
q = q.where(key, value);
|
|
183
|
+
c = c.where(key, value);
|
|
184
|
+
}
|
|
185
|
+
return { query: q, countQuery: c };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {Object} options
|
|
190
|
+
* @param {string} [options.path='/api/rest'] - Base path (no trailing slash)
|
|
191
|
+
* @param {import('express').RequestHandler[]} [options.middleware] - Before attachDbMiddleware
|
|
192
|
+
* @param {string[]} [options.models] - Whitelist model names (ignores rest.enabled when set)
|
|
193
|
+
* @param {string[]} [options.excludeModels] - Exclude model names
|
|
194
|
+
* @param {function(import('../../core/orm/types').ModelDefinition): boolean} [options.filter] - Extra filter
|
|
195
|
+
*/
|
|
196
|
+
function restResourcePlugin(options = {}) {
|
|
197
|
+
const {
|
|
198
|
+
path: basePath = '/api/rest',
|
|
199
|
+
middleware = [],
|
|
200
|
+
models: modelNameWhitelist = null,
|
|
201
|
+
excludeModels = [],
|
|
202
|
+
filter: modelFilter = null,
|
|
203
|
+
} = options;
|
|
204
|
+
|
|
205
|
+
const normalizedBase = normalizeBasePath(basePath);
|
|
206
|
+
const extra = Array.isArray(middleware) ? middleware : [];
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
name: 'rest-resources',
|
|
210
|
+
version: '1.0.0',
|
|
211
|
+
|
|
212
|
+
onRoutesReady(ctx) {
|
|
213
|
+
const db = ctx.db ?? ctx.options?.db;
|
|
214
|
+
if (!db) {
|
|
215
|
+
console.warn('[rest-resources] Skipping routes: createApp({ db }) is required');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const exposed = resolveExposedModels(db, {
|
|
220
|
+
models: modelNameWhitelist,
|
|
221
|
+
excludeModels,
|
|
222
|
+
filter: modelFilter,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const chain = (handler) => [...extra, attachDbMiddleware, handler];
|
|
226
|
+
|
|
227
|
+
for (const model of exposed) {
|
|
228
|
+
const segment = model.rest?.path || pluralizeSegment(model.name);
|
|
229
|
+
const base = `${normalizedBase}/${segment}`;
|
|
230
|
+
|
|
231
|
+
ctx.addRoute(
|
|
232
|
+
'get',
|
|
233
|
+
base,
|
|
234
|
+
...chain(async (req, res) => {
|
|
235
|
+
try {
|
|
236
|
+
const repo = db.getRepository(model.name);
|
|
237
|
+
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
|
238
|
+
const perPage = Math.min(100, Math.max(1, parseInt(req.query.perPage, 10) || 15));
|
|
239
|
+
const offset = (page - 1) * perPage;
|
|
240
|
+
const include = parseIncludeParam(model, req.query.include);
|
|
241
|
+
|
|
242
|
+
let query = repo.query();
|
|
243
|
+
let countQuery = repo.query();
|
|
244
|
+
({ query, countQuery } = applySoftDeleteScope(query, countQuery, model, req.query.trashed));
|
|
245
|
+
({ query, countQuery } = applyColumnFilters(query, countQuery, model, req));
|
|
246
|
+
|
|
247
|
+
const sortCol = req.query.sort && model.columns.has(req.query.sort) ? req.query.sort : model.primaryKey;
|
|
248
|
+
const order = String(req.query.order || 'desc').toLowerCase() === 'asc' ? 'asc' : 'desc';
|
|
249
|
+
|
|
250
|
+
const total = await countQuery.count();
|
|
251
|
+
let listQ = query.orderBy(sortCol, order).offset(offset).limit(perPage);
|
|
252
|
+
if (include.length > 0) {
|
|
253
|
+
listQ = listQ.with(...include);
|
|
254
|
+
}
|
|
255
|
+
const records = await listQ.list();
|
|
256
|
+
|
|
257
|
+
res.json({
|
|
258
|
+
data: sanitizeRecordTree(records, model),
|
|
259
|
+
pagination: {
|
|
260
|
+
page,
|
|
261
|
+
perPage,
|
|
262
|
+
total,
|
|
263
|
+
totalPages: Math.ceil(total / perPage) || 0,
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
} catch (err) {
|
|
267
|
+
res.status(400).json({ error: err.message });
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
ctx.addRoute(
|
|
273
|
+
'get',
|
|
274
|
+
`${base}/:id`,
|
|
275
|
+
...chain(async (req, res) => {
|
|
276
|
+
try {
|
|
277
|
+
const repo = db.getRepository(model.name);
|
|
278
|
+
const include = parseIncludeParam(model, req.query.include);
|
|
279
|
+
const record = await repo.findById(req.params.id, { with: include });
|
|
280
|
+
|
|
281
|
+
if (!record) {
|
|
282
|
+
return res.status(404).json({ error: 'Record not found' });
|
|
283
|
+
}
|
|
284
|
+
res.json({ data: sanitizeRecordTree(record, model) });
|
|
285
|
+
} catch (err) {
|
|
286
|
+
res.status(400).json({ error: err.message });
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
ctx.addRoute(
|
|
292
|
+
'post',
|
|
293
|
+
base,
|
|
294
|
+
...chain(async (req, res) => {
|
|
295
|
+
try {
|
|
296
|
+
const repo = db.getRepository(model.name);
|
|
297
|
+
const payload = pickWritableColumns(req.body, model);
|
|
298
|
+
const record = await repo.create(payload);
|
|
299
|
+
res.status(201).json({ data: sanitizeRecordTree(record, model) });
|
|
300
|
+
} catch (err) {
|
|
301
|
+
res.status(400).json({ error: err.message });
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
ctx.addRoute(
|
|
307
|
+
'patch',
|
|
308
|
+
`${base}/:id`,
|
|
309
|
+
...chain(async (req, res) => {
|
|
310
|
+
try {
|
|
311
|
+
const repo = db.getRepository(model.name);
|
|
312
|
+
const payload = pickWritableColumns(req.body, model);
|
|
313
|
+
const record = await repo.update(req.params.id, payload);
|
|
314
|
+
if (!record) {
|
|
315
|
+
return res.status(404).json({ error: 'Record not found' });
|
|
316
|
+
}
|
|
317
|
+
res.json({ data: sanitizeRecordTree(record, model) });
|
|
318
|
+
} catch (err) {
|
|
319
|
+
res.status(400).json({ error: err.message });
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
ctx.addRoute(
|
|
325
|
+
'delete',
|
|
326
|
+
`${base}/:id`,
|
|
327
|
+
...chain(async (req, res) => {
|
|
328
|
+
try {
|
|
329
|
+
const repo = db.getRepository(model.name);
|
|
330
|
+
const ok = await repo.delete(req.params.id);
|
|
331
|
+
if (!ok) {
|
|
332
|
+
return res.status(404).json({ error: 'Record not found' });
|
|
333
|
+
}
|
|
334
|
+
res.json({ success: true });
|
|
335
|
+
} catch (err) {
|
|
336
|
+
res.status(400).json({ error: err.message });
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
module.exports = restResourcePlugin;
|
|
346
|
+
module.exports.pluralizeSegment = pluralizeSegment;
|
|
347
|
+
module.exports.parseIncludeParam = parseIncludeParam;
|
|
348
|
+
module.exports.sanitizeRecordTree = sanitizeRecordTree;
|
|
349
|
+
module.exports.pickWritableColumns = pickWritableColumns;
|
|
350
|
+
module.exports.resolveExposedModels = resolveExposedModels;
|
|
@@ -187,6 +187,7 @@ var AnalyticsPage = {
|
|
|
187
187
|
vnode.state.viewsOverTime = [];
|
|
188
188
|
vnode.state.topPages = [];
|
|
189
189
|
vnode.state.botActivity = [];
|
|
190
|
+
vnode.state.referrerSources = [];
|
|
190
191
|
vnode.state.countries = [];
|
|
191
192
|
vnode.state.recent = [];
|
|
192
193
|
vnode.state.chartLoaded = false;
|
|
@@ -217,6 +218,7 @@ var AnalyticsPage = {
|
|
|
217
218
|
analyticsApi('views-over-time', days),
|
|
218
219
|
analyticsApi('top-pages', days),
|
|
219
220
|
analyticsApi('bot-activity', days),
|
|
221
|
+
analyticsApi('referrer-sources', days),
|
|
220
222
|
analyticsApi('countries', days),
|
|
221
223
|
analyticsApi('client-errors', days),
|
|
222
224
|
analyticsApi('recent', days),
|
|
@@ -225,9 +227,10 @@ var AnalyticsPage = {
|
|
|
225
227
|
vnode.state.viewsOverTime = results[1];
|
|
226
228
|
vnode.state.topPages = results[2];
|
|
227
229
|
vnode.state.botActivity = results[3];
|
|
228
|
-
vnode.state.
|
|
229
|
-
vnode.state.
|
|
230
|
-
vnode.state.
|
|
230
|
+
vnode.state.referrerSources = results[4];
|
|
231
|
+
vnode.state.countries = results[5];
|
|
232
|
+
vnode.state.clientErrors = results[6];
|
|
233
|
+
vnode.state.recent = results[7];
|
|
231
234
|
vnode.state.loading = false;
|
|
232
235
|
|
|
233
236
|
if (chartInstance) {
|
|
@@ -279,11 +282,13 @@ var AnalyticsPage = {
|
|
|
279
282
|
? m('div.flex.justify-center.py-24', m(Spinner))
|
|
280
283
|
: [
|
|
281
284
|
// Stat cards
|
|
282
|
-
m('div.grid.grid-cols-
|
|
285
|
+
m('div.grid.grid-cols-2.sm:grid-cols-3.lg:grid-cols-3.xl:grid-cols-6.gap-4.mb-6', [
|
|
283
286
|
m(StatCard, { icon: '👁', label: 'Views', value: s.stats?.views, bgClass: 'bg-blue-100' }),
|
|
284
287
|
m(StatCard, { icon: '👤', label: 'Visitors', value: s.stats?.visitors, bgClass: 'bg-green-100' }),
|
|
285
288
|
m(StatCard, { icon: '📄', label: 'Unique Pages', value: s.stats?.uniquePages, bgClass: 'bg-yellow-100' }),
|
|
286
289
|
m(StatCard, { icon: '🔗', label: 'Sessions', value: s.stats?.sessions, bgClass: 'bg-purple-100' }),
|
|
290
|
+
m(StatCard, { icon: '🏠', label: 'Direct traffic', value: s.stats?.directViews, bgClass: 'bg-slate-100' }),
|
|
291
|
+
m(StatCard, { icon: '🌐', label: 'With referrer', value: s.stats?.referredViews, bgClass: 'bg-cyan-100' }),
|
|
287
292
|
]),
|
|
288
293
|
|
|
289
294
|
// Chart + Bot Activity row
|
|
@@ -413,6 +418,31 @@ var AnalyticsPage = {
|
|
|
413
418
|
]),
|
|
414
419
|
]),
|
|
415
420
|
|
|
421
|
+
// Referrer sources (hostname-level)
|
|
422
|
+
m('div.bg-white.rounded-lg.shadow.mb-6', [
|
|
423
|
+
m('div.px-5.py-4.border-b.border-gray-100.flex.items-center.justify-between', [
|
|
424
|
+
m('h3.text-sm.font-semibold.text-gray-900', 'Referrer sources'),
|
|
425
|
+
m('span.text-xs.text-gray-400', 'By hostname · last ' + s.days + ' days'),
|
|
426
|
+
]),
|
|
427
|
+
m('div.p-4', [
|
|
428
|
+
!s.referrerSources || s.referrerSources.length === 0
|
|
429
|
+
? m('p.text-gray-400.text-sm.text-center.py-4', 'No external referrer data (direct visits or missing Referer header)')
|
|
430
|
+
: (function() {
|
|
431
|
+
var maxV = s.referrerSources[0]?.views || 1;
|
|
432
|
+
return m('div.grid.grid-cols-1.sm:grid-cols-2.gap-x-8.gap-y-2', s.referrerSources.map(function(r) {
|
|
433
|
+
return m('div.flex.items-center.gap-3.py-1', [
|
|
434
|
+
m('span.text-sm.text-gray-700.flex-1.min-w-0.truncate', { title: r.source }, r.source),
|
|
435
|
+
m('div.w-32.shrink-0', m(HBar, {
|
|
436
|
+
pct: (r.views / maxV) * 100,
|
|
437
|
+
color: 'bg-sky-500',
|
|
438
|
+
})),
|
|
439
|
+
m('span.text-xs.text-gray-500.w-12.text-right.tabular-nums', formatNumber(r.views)),
|
|
440
|
+
]);
|
|
441
|
+
}));
|
|
442
|
+
})(),
|
|
443
|
+
]),
|
|
444
|
+
]),
|
|
445
|
+
|
|
416
446
|
// Country Stats
|
|
417
447
|
m('div.bg-white.rounded-lg.shadow.mb-6', [
|
|
418
448
|
m('div.px-5.py-4.border-b.border-gray-100.flex.items-center.justify-between', [
|
|
@@ -29,6 +29,24 @@ function createAnalyticsApiHandlers(options) {
|
|
|
29
29
|
return d.toISOString();
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Extract registrable hostname from Referer URL (strip www.)
|
|
34
|
+
* @param {string|null|undefined} referrer
|
|
35
|
+
* @returns {string|null}
|
|
36
|
+
*/
|
|
37
|
+
function hostnameFromReferrer(referrer) {
|
|
38
|
+
if (!referrer || typeof referrer !== 'string' || !referrer.trim()) return null;
|
|
39
|
+
try {
|
|
40
|
+
const u = new URL(referrer);
|
|
41
|
+
const h = u.hostname;
|
|
42
|
+
if (!h) return null;
|
|
43
|
+
const norm = h.replace(/^www\./i, '').toLowerCase();
|
|
44
|
+
return norm || null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
/**
|
|
33
51
|
* GET /stats - Summary statistics
|
|
34
52
|
*/
|
|
@@ -42,6 +60,18 @@ function createAnalyticsApiHandlers(options) {
|
|
|
42
60
|
.where('is_bot', false)
|
|
43
61
|
.count('id as count');
|
|
44
62
|
|
|
63
|
+
const totalViews = parseInt(views.count) || 0;
|
|
64
|
+
|
|
65
|
+
const [directRow] = await knex(tableName)
|
|
66
|
+
.where('created_at', '>=', since)
|
|
67
|
+
.where('is_bot', false)
|
|
68
|
+
.where((qb) => {
|
|
69
|
+
qb.whereNull('referrer').orWhere('referrer', '');
|
|
70
|
+
})
|
|
71
|
+
.count('id as count');
|
|
72
|
+
|
|
73
|
+
const directViews = parseInt(directRow.count) || 0;
|
|
74
|
+
|
|
45
75
|
const [visitors] = await knex(tableName)
|
|
46
76
|
.where('created_at', '>=', since)
|
|
47
77
|
.where('is_bot', false)
|
|
@@ -58,7 +88,9 @@ function createAnalyticsApiHandlers(options) {
|
|
|
58
88
|
.countDistinct('session_id as count');
|
|
59
89
|
|
|
60
90
|
res.json({
|
|
61
|
-
views:
|
|
91
|
+
views: totalViews,
|
|
92
|
+
directViews,
|
|
93
|
+
referredViews: Math.max(0, totalViews - directViews),
|
|
62
94
|
visitors: parseInt(visitors.count) || 0,
|
|
63
95
|
uniquePages: parseInt(uniquePages.count) || 0,
|
|
64
96
|
sessions: parseInt(sessions.count) || 0,
|
|
@@ -183,6 +215,46 @@ function createAnalyticsApiHandlers(options) {
|
|
|
183
215
|
}
|
|
184
216
|
}
|
|
185
217
|
|
|
218
|
+
/**
|
|
219
|
+
* GET /referrer-sources - Top referrer hostnames (aggregated from full Referer URLs)
|
|
220
|
+
*/
|
|
221
|
+
async function getReferrerSources(req, res) {
|
|
222
|
+
try {
|
|
223
|
+
const days = parseDays(req);
|
|
224
|
+
const since = sinceDate(days);
|
|
225
|
+
const limit = Math.min(parseInt(req.query.limit) || 20, 50);
|
|
226
|
+
/** Max distinct full Referer URLs read before hostname merge (long tail capped). */
|
|
227
|
+
const referrerGroupLimit = 400;
|
|
228
|
+
|
|
229
|
+
const rows = await knex(tableName)
|
|
230
|
+
.select('referrer')
|
|
231
|
+
.where('created_at', '>=', since)
|
|
232
|
+
.where('is_bot', false)
|
|
233
|
+
.whereNotNull('referrer')
|
|
234
|
+
.where('referrer', '!=', '')
|
|
235
|
+
.count('id as views')
|
|
236
|
+
.groupBy('referrer')
|
|
237
|
+
.orderBy('views', 'desc')
|
|
238
|
+
.limit(referrerGroupLimit);
|
|
239
|
+
|
|
240
|
+
const byHost = new Map();
|
|
241
|
+
for (const r of rows) {
|
|
242
|
+
const host = hostnameFromReferrer(r.referrer) || 'Other';
|
|
243
|
+
const n = parseInt(r.views) || 0;
|
|
244
|
+
byHost.set(host, (byHost.get(host) || 0) + n);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const list = [...byHost.entries()]
|
|
248
|
+
.map(([source, views]) => ({ source, views }))
|
|
249
|
+
.sort((a, b) => b.views - a.views)
|
|
250
|
+
.slice(0, limit);
|
|
251
|
+
|
|
252
|
+
res.json(list);
|
|
253
|
+
} catch (e) {
|
|
254
|
+
res.status(500).json({ error: e.message });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
186
258
|
/**
|
|
187
259
|
* GET /countries - Country breakdown
|
|
188
260
|
*/
|
|
@@ -262,6 +334,7 @@ function createAnalyticsApiHandlers(options) {
|
|
|
262
334
|
getViewsOverTime,
|
|
263
335
|
getTopPages,
|
|
264
336
|
getBotActivity,
|
|
337
|
+
getReferrerSources,
|
|
265
338
|
getCountries,
|
|
266
339
|
getClientErrors,
|
|
267
340
|
getRecent,
|
|
@@ -115,6 +115,7 @@ function siteAnalyticsPlugin(options = {}) {
|
|
|
115
115
|
{ method: 'get', path: '/views-over-time', handler: handlers.getViewsOverTime },
|
|
116
116
|
{ method: 'get', path: '/top-pages', handler: handlers.getTopPages },
|
|
117
117
|
{ method: 'get', path: '/bot-activity', handler: handlers.getBotActivity },
|
|
118
|
+
{ method: 'get', path: '/referrer-sources', handler: handlers.getReferrerSources },
|
|
118
119
|
{ method: 'get', path: '/countries', handler: handlers.getCountries },
|
|
119
120
|
{ method: 'get', path: '/client-errors', handler: handlers.getClientErrors },
|
|
120
121
|
{ method: 'get', path: '/recent', handler: handlers.getRecent },
|