@harshankur/viewcounter 3.1.0 → 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.
- package/.env.example +34 -10
- package/README.md +306 -136
- package/admin/css/admin.css +891 -198
- package/admin/index.html +13 -7
- package/admin/js/api.js +52 -6
- package/admin/js/appTabs.js +100 -0
- package/admin/js/charts.js +529 -189
- package/admin/js/constants.js +98 -9
- package/admin/js/dataTable.js +478 -0
- package/admin/js/format.js +58 -7
- package/admin/js/icons.js +168 -0
- package/admin/js/listbox.js +2 -1
- package/admin/js/logs.js +211 -60
- package/admin/js/main.js +201 -37
- package/admin/js/overview.js +905 -0
- package/admin/js/passwordPrompt.js +75 -0
- package/admin/js/table.js +12 -52
- package/admin/js/viewDialogs.js +30 -14
- package/admin/js/views.js +273 -207
- package/admin/locales/en.json +352 -63
- package/config/index.js +40 -5
- package/constants.js +126 -8
- package/db/AdminRepository.js +85 -159
- package/db/DatabaseManager.js +55 -7
- package/db/LogRepository.js +172 -35
- package/db/adminSchema.js +89 -4
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +479 -0
- package/db/rejectionCounter.js +117 -0
- package/index.js +53 -19
- package/middleware/adminAuth.js +83 -43
- package/middleware/adminValidation.js +69 -3
- package/middleware/auth.js +2 -2
- package/middleware/security.js +26 -2
- package/middleware/validation.js +50 -2
- package/package.json +5 -2
- package/routes/admin.js +130 -22
- package/routes/analytics.js +197 -18
- package/tracker/tracker.js +191 -0
- package/utils/appIdUtils.js +1 -1
- package/utils/durationUtils.js +33 -0
- package/utils/errorUtils.js +4 -1
- package/utils/geoCity.js +87 -0
- package/utils/ipUtils.js +1 -1
- package/utils/privacyUtils.js +2 -2
- package/utils/referrerParser.js +23 -5
- package/utils/secretStore.js +1 -1
- package/utils/userAgentParser.js +52 -3
- package/utils/visitorContext.js +70 -0
- 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
|
-
*
|
|
3
|
-
*
|
|
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 {
|
|
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 {
|
|
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
|
}
|
package/admin/js/viewDialogs.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
27
|
-
if (field ===
|
|
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
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
59
|
+
body,
|
|
44
60
|
actions: [{ label: t('common.close'), variant: VARIANT.SECONDARY }],
|
|
45
61
|
wide: true,
|
|
46
62
|
});
|