@harshankur/viewcounter 3.0.1 → 3.2.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 (64) hide show
  1. package/.env.example +50 -6
  2. package/README.md +444 -104
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +2568 -0
  6. package/admin/favicon.ico +0 -0
  7. package/admin/favicon.svg +9 -0
  8. package/admin/icon-192.png +0 -0
  9. package/admin/icon-512.png +0 -0
  10. package/admin/index.html +95 -0
  11. package/admin/js/api.js +146 -0
  12. package/admin/js/appTabs.js +100 -0
  13. package/admin/js/charts.js +842 -0
  14. package/admin/js/clamp.js +41 -0
  15. package/admin/js/constants.js +239 -0
  16. package/admin/js/dataTable.js +478 -0
  17. package/admin/js/dom.js +83 -0
  18. package/admin/js/format.js +130 -0
  19. package/admin/js/i18n.js +80 -0
  20. package/admin/js/icons.js +168 -0
  21. package/admin/js/listbox.js +145 -0
  22. package/admin/js/logs.js +318 -0
  23. package/admin/js/main.js +399 -0
  24. package/admin/js/modal.js +171 -0
  25. package/admin/js/overview.js +905 -0
  26. package/admin/js/passwordPrompt.js +75 -0
  27. package/admin/js/table.js +94 -0
  28. package/admin/js/theme.js +72 -0
  29. package/admin/js/toast.js +47 -0
  30. package/admin/js/viewDialogs.js +224 -0
  31. package/admin/js/views.js +751 -0
  32. package/admin/locales/en.json +683 -0
  33. package/admin/site.webmanifest +20 -0
  34. package/config/index.js +122 -4
  35. package/constants.js +334 -3
  36. package/db/AdminRepository.js +488 -0
  37. package/db/DatabaseManager.js +148 -26
  38. package/db/LogRepository.js +354 -0
  39. package/db/adminSchema.js +329 -0
  40. package/db/adminSessionStore.js +104 -0
  41. package/db/analysis.js +479 -0
  42. package/db/rejectionCounter.js +117 -0
  43. package/db/retention.js +97 -0
  44. package/index.js +91 -24
  45. package/middleware/adminAuth.js +244 -0
  46. package/middleware/adminValidation.js +319 -0
  47. package/middleware/auth.js +2 -2
  48. package/middleware/security.js +26 -2
  49. package/middleware/validation.js +50 -2
  50. package/package.json +20 -10
  51. package/routes/admin.js +546 -0
  52. package/routes/analytics.js +207 -19
  53. package/tracker/tracker.js +191 -0
  54. package/utils/appIdUtils.js +1 -1
  55. package/utils/cookieUtils.js +47 -0
  56. package/utils/durationUtils.js +33 -0
  57. package/utils/errorUtils.js +39 -1
  58. package/utils/geoCity.js +87 -0
  59. package/utils/ipUtils.js +1 -1
  60. package/utils/privacyUtils.js +2 -2
  61. package/utils/referrerParser.js +23 -5
  62. package/utils/secretStore.js +1 -1
  63. package/utils/userAgentParser.js +52 -3
  64. package/utils/visitorContext.js +70 -0
@@ -0,0 +1,318 @@
1
+ /**
2
+ * The tracking log and the admin log. Both are read-only here: the UI has no
3
+ * way to alter a log entry, and neither does the API.
4
+ *
5
+ * They answer different questions from Views. Views holds what was recorded,
6
+ * the rows every statistic is computed from. The tracking log holds what
7
+ * arrived: every tracking request and what became of it, including the ones
8
+ * never stored (bots and refusals). The admin log holds what admins did.
9
+ */
10
+
11
+ import { api } from './api.js';
12
+ import { statTiles } from './charts.js';
13
+ import { clampText } from './clamp.js';
14
+ import { createDataTable } from './dataTable.js';
15
+ import { el, replaceChildren } from './dom.js';
16
+ import { formatDate, formatHeadline, formatNumber, formatTime, orNone } from './format.js';
17
+ import { icon } from './icons.js';
18
+ import { t, tOr } from './i18n.js';
19
+ import { createListbox } from './listbox.js';
20
+ import { createPager } from './table.js';
21
+ import { TRACKING_LOG_REFRESH_MS } from './constants.js';
22
+
23
+ /** Sentinel for "no filter" in a filter listbox. */
24
+ const ALL = '';
25
+ /** Badge styling for each tracking outcome. */
26
+ const OUTCOME_BADGE = { recorded: 'badge-success', repeat: 'badge-muted', bot: 'badge-info', rejected: 'badge-danger' };
27
+
28
+ function filterListbox(label, allLabel, values, labelFor, onChange) {
29
+ return createListbox({
30
+ label,
31
+ options: [{ value: ALL, label: allLabel }, ...values.map((value) => ({ value, label: labelFor(value) }))],
32
+ value: ALL,
33
+ onChange,
34
+ });
35
+ }
36
+
37
+ /** A cell of two lines: what matters, and a detail under it. */
38
+ const two = (primary, secondary) => [
39
+ clampText(primary, { className: 'cell-primary' }),
40
+ clampText(secondary, { className: 'cell-secondary' }),
41
+ ];
42
+ const one = (text, className = '') => [clampText(text, { className })];
43
+ /** When something happened, to the second: logs order events moments apart. */
44
+ const when = (value) => two(formatDate(value), formatTime(value, { seconds: true }));
45
+
46
+ /**
47
+ * A paged, filterable, read-only log table under a title and an explanation.
48
+ * Its columns can be chosen, ordered, and sized like those of the views table.
49
+ * @param {{ name: string, title: string, intro: string, columns: object[], rowKey: (entry: object) => string,
50
+ * rowAttrs?: (entry: object) => object, filters: object[], fetchPage: (query: object) => Promise<object>,
51
+ * meta: object, reportError: (error: unknown) => void, emptyKey: string, notice?: string, above?: Node[],
52
+ * controls?: Node[], onLoad?: (query: object) => void, tableClassName?: string }} options
53
+ */
54
+ function createLogPanel({
55
+ name, title, intro, columns, rowKey, rowAttrs, filters, fetchPage, meta, reportError, emptyKey, notice,
56
+ above = [], controls = [], onLoad, tableClassName = '',
57
+ }) {
58
+ const state = { page: 1, pageSize: meta.pageSizeDefault, total: 0, entries: [], query: {}, seq: 0 };
59
+
60
+ const grid = createDataTable({
61
+ storageKey: name,
62
+ className: `log-table ${tableClassName}`.trim(),
63
+ columns: columns.map((column) => ({ ...column, label: t(`logColumns.${column.id}`) })),
64
+ rowKey,
65
+ rowAttrs,
66
+ empty: () => t(emptyKey),
67
+ });
68
+ const { table } = grid;
69
+ const pager = createPager((page) => {
70
+ state.page = page;
71
+ load();
72
+ });
73
+
74
+ const toolbar = el('div', { className: 'toolbar' }, [
75
+ el('div', { className: 'toolbar-group' }, filters.map((filter) => filter.listbox.element)),
76
+ el('div', { className: 'toolbar-group' }, [...controls, grid.chooserButton]),
77
+ ]);
78
+
79
+ const element = el('section', { className: 'panel', attrs: { 'aria-label': title } }, [
80
+ el('header', { className: 'panel-header' }, [
81
+ el('h2', { className: 'panel-title', text: title }),
82
+ el('p', { className: 'panel-intro', text: intro }),
83
+ ]),
84
+ ...above,
85
+ toolbar,
86
+ notice ? el('p', { className: 'notice', text: notice }) : null,
87
+ grid.element,
88
+ pager.element,
89
+ ]);
90
+
91
+ async function load() {
92
+ const seq = ++state.seq;
93
+ table.setAttribute('aria-busy', 'true');
94
+ onLoad?.(state.query);
95
+ try {
96
+ const result = await fetchPage({ ...state.query, page: state.page, pageSize: state.pageSize });
97
+ if (seq !== state.seq) return;
98
+ state.entries = result.entries;
99
+ state.total = result.total;
100
+ grid.setRows(state.entries);
101
+ pager.update(state.page, state.pageSize, state.total);
102
+ } catch (error) {
103
+ if (seq === state.seq) reportError(error);
104
+ } finally {
105
+ if (seq === state.seq) table.removeAttribute('aria-busy');
106
+ }
107
+ }
108
+
109
+ for (const filter of filters) {
110
+ filter.onValue = (value) => {
111
+ state.query[filter.param] = value || undefined;
112
+ state.page = 1;
113
+ load();
114
+ };
115
+ }
116
+
117
+ return { element, load, show: load, hide() {} };
118
+ }
119
+
120
+ /**
121
+ * @param {{ meta: object, appIds: () => string[], reportError: (error: unknown) => void }} deps
122
+ */
123
+ export function createAdminLogPanel({ meta, appIds, reportError }) {
124
+ const actionFilter = { param: 'action' };
125
+ actionFilter.listbox = filterListbox(t('logs.filterAction'), t('logs.allActions'), meta.actions,
126
+ (action) => tOr(`actions.${action}`, action), (value) => actionFilter.onValue(value));
127
+ const appFilter = { param: 'appId' };
128
+ appFilter.listbox = filterListbox(t('logs.filterApp'), t('logs.allApps'), appIds(), (id) => id,
129
+ (value) => appFilter.onValue(value));
130
+
131
+ const columns = [
132
+ { id: 'time', width: 140, cell: (entry) => when(entry.createdAt) },
133
+ { id: 'action', width: 176, cell: (entry) => one(tOr(`actions.${entry.action}`, entry.action)) },
134
+ { id: 'app', width: 144, cell: (entry) => one(orNone(entry.appId)) },
135
+ { id: 'rows', width: 84, cell: (entry) => one(formatNumber(entry.targetCount)) },
136
+ {
137
+ id: 'fields', width: 240, minWidth: 160, grow: true,
138
+ cell: (entry) => one(entry.fields.length ? entry.fields.map((field) => tOr(`fields.${field}`, field)).join(', ') : t('common.none')),
139
+ },
140
+ { id: 'session', width: 116, cell: (entry) => one(entry.sessionId ? entry.sessionId.slice(0, 8) : t('logs.system'), 'mono') },
141
+ { id: 'ip', width: 170, cell: (entry) => one(orNone(entry.maskedIp), 'mono') },
142
+ ];
143
+
144
+ return createLogPanel({
145
+ name: 'admin-log',
146
+ title: t('tabs.adminLog'),
147
+ intro: t('logs.adminIntro'),
148
+ columns,
149
+ rowKey: (entry) => entry.id,
150
+ filters: [actionFilter, appFilter],
151
+ fetchPage: api.adminLog,
152
+ meta,
153
+ reportError,
154
+ emptyKey: 'logs.emptyAdmin',
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Why old entries leave the tracking log. Empty when the host did not say (an
160
+ * embedding app that configures no retention), so the UI never guesses.
161
+ * @param {number|undefined} days
162
+ */
163
+ function trackingLogNotice(days) {
164
+ if (typeof days !== 'number') return '';
165
+ return days > 0 ? t('logs.trackingRetention', { count: days }) : t('logs.trackingRetentionOff');
166
+ }
167
+
168
+ /**
169
+ * @param {{ meta: object, appIds: () => string[], reportError: (error: unknown) => void,
170
+ * openView: (appId: string, viewId: string) => void }} deps
171
+ */
172
+ export function createTrackingLogPanel({ meta, appIds, reportError, openView }) {
173
+ const sourceFilter = { param: 'source' };
174
+ sourceFilter.listbox = filterListbox(t('logs.filterSource'), t('logs.allSources'), meta.sources,
175
+ (source) => tOr(`logSources.${source}`, source), (value) => sourceFilter.onValue(value));
176
+ const outcomeFilter = { param: 'outcome' };
177
+ outcomeFilter.listbox = filterListbox(t('logs.filterOutcome'), t('logs.allOutcomes'), meta.outcomes,
178
+ (outcome) => tOr(`outcomes.${outcome}`, outcome), (value) => outcomeFilter.onValue(value));
179
+ const appFilter = { param: 'appId' };
180
+ appFilter.listbox = filterListbox(t('logs.filterApp'), t('logs.allApps'), appIds(), (id) => id,
181
+ (value) => appFilter.onValue(value));
182
+
183
+ // What each outcome means, so nobody has to guess why a row is not a view.
184
+ const legend = el('dl', { className: 'outcome-legend' }, meta.outcomes.flatMap((outcome) => [
185
+ el('dt', {}, [el('span', { className: `badge ${OUTCOME_BADGE[outcome] ?? 'badge-muted'}`, text: tOr(`outcomes.${outcome}`, outcome) })]),
186
+ el('dd', { text: t(`logs.outcomeHelp.${outcome}`) }),
187
+ ]));
188
+
189
+ // The last day at a glance, for the app filter's app or all of them.
190
+ const summaryTiles = el('div', { className: 'tracking-summary' });
191
+ const summaryReasons = el('p', { className: 'panel-summary' });
192
+ let summarySeq = 0;
193
+ async function loadSummary(query) {
194
+ const mine = ++summarySeq;
195
+ try {
196
+ const summary = await api.trackingSummary({ appId: query.appId });
197
+ if (mine !== summarySeq) return;
198
+ replaceChildren(summaryTiles, [statTiles(meta.outcomes.map((outcome) => {
199
+ const count = summary.outcomes[outcome] ?? 0;
200
+ return {
201
+ key: outcome,
202
+ label: tOr(`outcomes.${outcome}`, outcome),
203
+ value: formatHeadline(count),
204
+ exact: formatNumber(count),
205
+ note: t('logs.lastHours', { count: summary.hours }),
206
+ spark: [],
207
+ };
208
+ }), { label: t('logs.summaryLabel', { count: summary.hours }) })]);
209
+ // Bots have their own tile; the list is why requests were refused.
210
+ const reasons = Object.entries(summary.reasons).filter(([reason]) => reason !== 'bot').sort((a, b) => b[1] - a[1]);
211
+ summaryReasons.textContent = reasons.length
212
+ ? t('logs.reasons', {
213
+ list: reasons.map(([reason, count]) => `${tOr(`rejectionReasons.${reason}`, reason)} ${formatNumber(count)}`).join(' · '),
214
+ })
215
+ : t('logs.noReasons');
216
+ } catch (error) {
217
+ if (mine === summarySeq) reportError(error);
218
+ }
219
+ }
220
+
221
+ const outcomeBadge = (entry) => {
222
+ const counted = entry.requests > 1 ? ` ×${formatNumber(entry.requests)}` : '';
223
+ return [el('span', {
224
+ className: `badge ${OUTCOME_BADGE[entry.outcome] ?? 'badge-muted'}`,
225
+ text: `${tOr(`outcomes.${entry.outcome}`, entry.outcome)}${counted}`,
226
+ })];
227
+ };
228
+
229
+ const detailText = (entry) => {
230
+ const reason = entry.reason && entry.reason !== 'bot' ? tOr(`rejectionReasons.${entry.reason}`, entry.reason) : null;
231
+ return [reason, entry.detail].filter(Boolean).join(': ') || t('common.none');
232
+ };
233
+
234
+ const viewLink = (entry) => [entry.viewId && entry.appId
235
+ ? el('button', {
236
+ className: 'link-btn mono',
237
+ attrs: { type: 'button', title: t('logs.openView'), 'aria-label': t('logs.openViewNamed', { id: entry.viewId }) },
238
+ on: { click: () => openView(entry.appId, entry.viewId) },
239
+ }, [el('span', { text: entry.viewId.slice(0, 8) }), icon('arrowRight')])
240
+ : clampText(t('common.none'))];
241
+
242
+ const eventName = (entry) => (entry.eventType ? tOr(`eventTypes.${entry.eventType}`, entry.eventType) : t('common.none'));
243
+ const columns = [
244
+ { id: 'time', width: 140, cell: (entry) => when(entry.at) },
245
+ { id: 'app', width: 160, cell: (entry) => two(orNone(entry.appId), orNone(entry.hostname)) },
246
+ {
247
+ // A page view's type goes without saying; an event names its type.
248
+ id: 'source', width: 144,
249
+ cell: (entry) => two(tOr(`logSources.${entry.source}`, entry.source), entry.source === 'event' ? eventName(entry) : t('common.none')),
250
+ },
251
+ { id: 'outcome', width: 152, cell: outcomeBadge },
252
+ { id: 'detail', width: 240, minWidth: 160, grow: true, cell: (entry) => one(detailText(entry)) },
253
+ { id: 'view', width: 112, cell: viewLink },
254
+ { id: 'site', width: 200, hidden: true, cell: (entry) => one(orNone(entry.hostname)) },
255
+ { id: 'event', width: 150, hidden: true, cell: (entry) => one(eventName(entry)) },
256
+ { id: 'requests', width: 104, hidden: true, cell: (entry) => one(formatNumber(entry.requests)) },
257
+ ];
258
+
259
+ // Auto-refresh: for watching a site's views arrive after setting it up.
260
+ let timer = null;
261
+ let visible = false;
262
+ const liveToggle = el('input', {
263
+ className: 'switch-input',
264
+ attrs: { type: 'checkbox', role: 'switch' },
265
+ on: { change: () => schedule() },
266
+ });
267
+ const liveSwitch = el('label', { className: 'switch' }, [
268
+ liveToggle,
269
+ el('span', { className: 'switch-track', attrs: { 'aria-hidden': 'true' } }),
270
+ el('span', { className: 'switch-label', text: t('logs.autoRefresh', { seconds: TRACKING_LOG_REFRESH_MS / 1000 }) }),
271
+ ]);
272
+
273
+ const panel = createLogPanel({
274
+ name: 'tracking-log',
275
+ title: t('tabs.trackingLog'),
276
+ intro: t('logs.trackingIntro'),
277
+ columns,
278
+ rowKey: (entry) => entry.id,
279
+ rowAttrs: (entry) => ({ dataset: { outcome: entry.outcome } }),
280
+ filters: [appFilter, sourceFilter, outcomeFilter],
281
+ fetchPage: api.trackingLog,
282
+ meta,
283
+ reportError,
284
+ emptyKey: 'logs.emptyTracking',
285
+ notice: trackingLogNotice(meta.viewLogRetentionDays),
286
+ above: [
287
+ el('details', { className: 'metric-help' }, [el('summary', { text: t('logs.outcomesTitle') }), legend]),
288
+ summaryTiles,
289
+ summaryReasons,
290
+ ],
291
+ controls: [liveSwitch],
292
+ onLoad: loadSummary,
293
+ tableClassName: 'tracking-table',
294
+ });
295
+
296
+ function schedule() {
297
+ clearInterval(timer);
298
+ timer = null;
299
+ if (visible && liveToggle.checked) {
300
+ timer = setInterval(() => {
301
+ if (document.visibilityState === 'visible') panel.load();
302
+ }, TRACKING_LOG_REFRESH_MS);
303
+ }
304
+ }
305
+
306
+ return {
307
+ ...panel,
308
+ show() {
309
+ visible = true;
310
+ panel.load();
311
+ schedule();
312
+ },
313
+ hide() {
314
+ visible = false;
315
+ schedule();
316
+ },
317
+ };
318
+ }