@harshankur/viewcounter 3.1.0 → 3.3.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 (50) hide show
  1. package/.env.example +37 -11
  2. package/README.md +330 -138
  3. package/admin/css/admin.css +891 -198
  4. package/admin/index.html +13 -7
  5. package/admin/js/api.js +52 -6
  6. package/admin/js/appTabs.js +100 -0
  7. package/admin/js/charts.js +529 -189
  8. package/admin/js/constants.js +98 -9
  9. package/admin/js/dataTable.js +478 -0
  10. package/admin/js/format.js +58 -7
  11. package/admin/js/icons.js +168 -0
  12. package/admin/js/listbox.js +2 -1
  13. package/admin/js/logs.js +211 -60
  14. package/admin/js/main.js +201 -37
  15. package/admin/js/overview.js +905 -0
  16. package/admin/js/passwordPrompt.js +75 -0
  17. package/admin/js/table.js +12 -52
  18. package/admin/js/viewDialogs.js +30 -14
  19. package/admin/js/views.js +273 -207
  20. package/admin/locales/en.json +352 -63
  21. package/config/index.js +40 -5
  22. package/constants.js +126 -8
  23. package/db/AdminRepository.js +85 -159
  24. package/db/DatabaseManager.js +57 -7
  25. package/db/LogRepository.js +172 -35
  26. package/db/adminSchema.js +93 -4
  27. package/db/adminSessionStore.js +104 -0
  28. package/db/analysis.js +484 -0
  29. package/db/rejectionCounter.js +117 -0
  30. package/index.js +49 -26
  31. package/middleware/adminAuth.js +83 -43
  32. package/middleware/adminValidation.js +69 -3
  33. package/middleware/auth.js +2 -2
  34. package/middleware/security.js +26 -2
  35. package/middleware/validation.js +50 -2
  36. package/package.json +5 -2
  37. package/routes/admin.js +130 -22
  38. package/routes/analytics.js +236 -18
  39. package/tracker/tracker.js +240 -0
  40. package/utils/appIdUtils.js +1 -1
  41. package/utils/durationUtils.js +33 -0
  42. package/utils/errorUtils.js +4 -1
  43. package/utils/geoCity.js +87 -0
  44. package/utils/ipUtils.js +1 -1
  45. package/utils/privacyUtils.js +2 -2
  46. package/utils/referrerParser.js +23 -5
  47. package/utils/secretStore.js +1 -1
  48. package/utils/userAgentParser.js +52 -3
  49. package/utils/visitorContext.js +70 -0
  50. package/admin/js/insights.js +0 -192
@@ -0,0 +1,75 @@
1
+ /**
2
+ * A password dialog over the current page, for the two times the admin UI
3
+ * needs the password without leaving what the admin was doing: signing in
4
+ * again after a session ends, and confirming an action that cannot be undone.
5
+ */
6
+
7
+ import { ApiError } from './api.js';
8
+ import { el, uniqueId } from './dom.js';
9
+ import { t } from './i18n.js';
10
+ import { openModal, VARIANT } from './modal.js';
11
+ import { ERROR_CODE } from './constants.js';
12
+
13
+ /**
14
+ * @param {{ title: string, message: string, submitLabel: string,
15
+ * submit: (password: string) => Promise<void> }} options
16
+ * @returns {Promise<boolean>} whether `submit` succeeded; false if cancelled
17
+ */
18
+ export function promptPassword({ title, message, submitLabel, submit }) {
19
+ return new Promise((resolve) => {
20
+ const inputId = uniqueId('password');
21
+ const input = el('input', {
22
+ className: 'input',
23
+ attrs: { id: inputId, type: 'password', autocomplete: 'current-password', required: 'true' },
24
+ });
25
+ const error = el('p', { className: 'field-error', attrs: { role: 'alert' } });
26
+ error.hidden = true;
27
+ let settled = false;
28
+
29
+ const attempt = async () => {
30
+ if (!input.value) {
31
+ input.focus();
32
+ return false;
33
+ }
34
+ try {
35
+ await submit(input.value);
36
+ settled = true;
37
+ resolve(true);
38
+ return true;
39
+ } catch (failure) {
40
+ const code = failure instanceof ApiError ? failure.code : ERROR_CODE.SERVER_ERROR;
41
+ error.textContent = t(`errors.${code}`);
42
+ error.hidden = false;
43
+ input.select();
44
+ return false;
45
+ }
46
+ };
47
+
48
+ const dialog = openModal({
49
+ title,
50
+ body: [
51
+ el('p', { text: message }),
52
+ el('label', { className: 'field-label', text: t('login.password'), attrs: { for: inputId } }),
53
+ input,
54
+ error,
55
+ ],
56
+ actions: [
57
+ { label: t('common.cancel'), variant: VARIANT.SECONDARY },
58
+ { label: submitLabel, variant: VARIANT.PRIMARY, onClick: attempt },
59
+ ],
60
+ onCancel: () => { if (!settled) resolve(false); },
61
+ initialFocus: input,
62
+ });
63
+
64
+ // Enter submits, as on the sign-in screen.
65
+ input.addEventListener('keydown', (event) => {
66
+ if (event.key !== 'Enter') return;
67
+ event.preventDefault();
68
+ dialog.querySelector('.modal-actions .btn-primary')?.click();
69
+ });
70
+ // A cancel button that closes the dialog is a cancel, too.
71
+ dialog.querySelector('.modal-actions .btn-secondary')?.addEventListener('click', () => {
72
+ if (!settled) resolve(false);
73
+ });
74
+ });
75
+ }
package/admin/js/table.js CHANGED
@@ -1,59 +1,11 @@
1
1
  /**
2
- * Pieces shared by every data table: sortable headers, the pager, and the
3
- * empty and loading states.
2
+ * Controls shared by the tables: the pager and the segmented control. The
3
+ * table itself is dataTable.js.
4
4
  */
5
5
 
6
- import { clampText } from './clamp.js';
7
6
  import { el, replaceChildren } from './dom.js';
8
- import { formatDate, formatNumber, formatTime } from './format.js';
7
+ import { formatNumber } from './format.js';
9
8
  import { t } from './i18n.js';
10
- import { SORT_ORDER } from './constants.js';
11
-
12
- /**
13
- * A header cell. Sortable headers are buttons, so they are reachable and
14
- * operable by keyboard, and expose the sort through aria-sort.
15
- *
16
- * @param {{ label: string, sortKey?: string, sort?: string, order?: string,
17
- * onSort?: (key: string) => void, className?: string }} options
18
- */
19
- export function headerCell({ label, sortKey, sort, order, onSort, className = '' }) {
20
- if (!sortKey) return el('th', { className, text: label, attrs: { scope: 'col' } });
21
-
22
- const active = sort === sortKey;
23
- const ariaSort = active ? (order === SORT_ORDER.ASC ? 'ascending' : 'descending') : 'none';
24
- const indicator = active ? (order === SORT_ORDER.ASC ? '▲' : '▼') : '↕';
25
-
26
- return el('th', { className, attrs: { scope: 'col', 'aria-sort': ariaSort } }, [
27
- el('button', {
28
- className: `sort-btn${active ? ' active' : ''}`,
29
- attrs: { type: 'button' },
30
- dataset: { sortKey },
31
- on: { click: () => onSort(sortKey) },
32
- }, [
33
- el('span', { text: label }),
34
- el('span', { className: 'sort-indicator', text: indicator, attrs: { 'aria-hidden': 'true' } }),
35
- ]),
36
- ]);
37
- }
38
-
39
- /**
40
- * A timestamp cell: the date above the time of day, like every other two-line
41
- * cell, so it never needs the width of both on one line.
42
- *
43
- * @param {string|Date} value
44
- * @param {{ seconds?: boolean }} [options]
45
- */
46
- export function timeCell(value, { seconds = false } = {}) {
47
- return el('td', { className: 'col-time' }, [
48
- clampText(formatDate(value), { className: 'cell-primary' }),
49
- clampText(formatTime(value, { seconds }), { className: 'cell-secondary' }),
50
- ]);
51
- }
52
-
53
- /** A full-width row for empty or loading states. */
54
- export function messageRow(columns, text, className = 'table-message') {
55
- return el('tr', {}, [el('td', { className, text, attrs: { colspan: String(columns) } })]);
56
- }
57
9
 
58
10
  /**
59
11
  * Previous / next pagination with a live summary.
@@ -130,5 +82,13 @@ export function createSegmented({ label, options, value, onChange }) {
130
82
  }
131
83
  render();
132
84
 
133
- return { element: group, getValue: () => current };
85
+ return {
86
+ element: group,
87
+ getValue: () => current,
88
+ /** Show a value chosen elsewhere, without reporting it as a change. */
89
+ setValue(value) {
90
+ current = value;
91
+ render();
92
+ },
93
+ };
134
94
  }
@@ -5,7 +5,9 @@
5
5
  import { el, uniqueId } from './dom.js';
6
6
  import { openModal, VARIANT } from './modal.js';
7
7
  import { createListbox } from './listbox.js';
8
- import { formatDateTime, orNone } from './format.js';
8
+ import { formatDateTime, formatDuration, orNone } from './format.js';
9
+ import { regionName } from './charts.js';
10
+ import { valueLabel } from './overview.js';
9
11
  import { t, tOr } from './i18n.js';
10
12
  import {
11
13
  DEVICE_SIZE_FIELD,
@@ -14,7 +16,7 @@ import {
14
16
  JSON_INDENT,
15
17
  NOTE_ROWS,
16
18
  TEXT_FIELDS,
17
- VIEW_DETAIL_FIELDS,
19
+ VIEW_DETAIL_GROUPS,
18
20
  } from './constants.js';
19
21
 
20
22
  const DATE_FIELDS = new Set(['timestamp', 'adminModifiedAt', 'deletedAt']);
@@ -23,24 +25,38 @@ const DATE_FIELDS = new Set(['timestamp', 'adminModifiedAt', 'deletedAt']);
23
25
  function displayValue(field, value) {
24
26
  if (DATE_FIELDS.has(field)) return formatDateTime(value);
25
27
  if (field === 'isUnique') return value ? t('common.yes') : t('common.no');
26
- if (field === 'sourceType' && value) return tOr(`sources.${value}`, value);
27
- if (field === EVENT_DATA_FIELD) return value === null || value === undefined ? t('common.none') : JSON.stringify(value, null, JSON_INDENT);
28
+ if (value === null || value === undefined || value === '') return t('common.none');
29
+ if (field === 'sourceType') return tOr(`sources.${value}`, value);
30
+ if (field === 'deviceSize') return tOr(`deviceSizes.${value}`, value);
31
+ if (field === 'deviceType') return valueLabel('deviceType', value);
32
+ if (field === 'language') return valueLabel('language', value);
33
+ if (field === 'eventType') return valueLabel('eventType', value);
34
+ if (field === 'country') return `${regionName(value)} (${value})`;
35
+ if (field === 'engagedMs') return formatDuration(value);
36
+ if (field === 'scrollDepth') return t('details.scrolled', { percent: value });
37
+ if (field === EVENT_DATA_FIELD) return JSON.stringify(value, null, JSON_INDENT);
28
38
  return orNone(value);
29
39
  }
30
40
 
31
- /** Read-only view of every field. */
41
+ /** Read-only view of every field, grouped: the view, its page, source, visitor, engagement, and admin changes. */
32
42
  export function openDetails(view) {
33
- const list = el('dl', { className: 'detail-list' });
34
- for (const field of VIEW_DETAIL_FIELDS) {
35
- const value = displayValue(field, view[field]);
36
- const valueNode = field === EVENT_DATA_FIELD
37
- ? el('pre', { className: 'detail-json', text: value })
38
- : el('span', { className: 'detail-value', text: value });
39
- list.append(el('dt', { text: t(`fields.${field}`) }), el('dd', {}, [valueNode]));
40
- }
43
+ const body = el('div', { className: 'detail-groups' }, VIEW_DETAIL_GROUPS.map((group) => {
44
+ const list = el('dl', { className: 'detail-list' });
45
+ for (const field of group.fields) {
46
+ const value = displayValue(field, view[field]);
47
+ const valueNode = field === EVENT_DATA_FIELD && view[field] !== null && view[field] !== undefined
48
+ ? el('pre', { className: 'detail-json', text: value })
49
+ : el('span', { className: 'detail-value', text: value });
50
+ list.append(el('dt', { text: t(`fields.${field}`) }), el('dd', {}, [valueNode]));
51
+ }
52
+ return el('section', { className: 'detail-group' }, [
53
+ el('h3', { className: 'detail-group-title', text: t(`details.groups.${group.key}`) }),
54
+ list,
55
+ ]);
56
+ }));
41
57
  openModal({
42
58
  title: t('details.title'),
43
- body: list,
59
+ body,
44
60
  actions: [{ label: t('common.close'), variant: VARIANT.SECONDARY }],
45
61
  wide: true,
46
62
  });