@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
package/admin/index.html
CHANGED
|
@@ -34,19 +34,23 @@
|
|
|
34
34
|
</svg>
|
|
35
35
|
<span class="logo-text" data-i18n="app.name"></span>
|
|
36
36
|
<span class="logo-badge" data-i18n="app.badge"></span>
|
|
37
|
+
<span class="version-badge" id="app-version" hidden></span>
|
|
37
38
|
</div>
|
|
38
39
|
|
|
39
40
|
<div class="topbar-actions">
|
|
41
|
+
<a class="header-link" id="website-link" href="https://viewcounter.harshankur.com" target="_blank"
|
|
42
|
+
rel="noopener noreferrer" data-icon="globe" data-i18n-attr="title:nav.websiteTitle"><span
|
|
43
|
+
data-i18n="nav.website"></span></a>
|
|
40
44
|
<div class="theme-switcher" role="group" data-i18n-attr="aria-label:theme.label">
|
|
41
|
-
<button class="theme-btn" id="theme-light" type="button" aria-pressed="false"
|
|
42
|
-
data-i18n-attr="aria-label:theme.light;title:theme.light"
|
|
43
|
-
<button class="theme-btn" id="theme-dark" type="button" aria-pressed="false"
|
|
44
|
-
data-i18n-attr="aria-label:theme.dark;title:theme.dark"
|
|
45
|
-
<button class="theme-btn" id="theme-auto" type="button" aria-pressed="false"
|
|
46
|
-
data-i18n-attr="aria-label:theme.auto;title:theme.auto"
|
|
45
|
+
<button class="theme-btn" id="theme-light" type="button" aria-pressed="false" data-icon="sun"
|
|
46
|
+
data-i18n-attr="aria-label:theme.light;title:theme.light"></button>
|
|
47
|
+
<button class="theme-btn" id="theme-dark" type="button" aria-pressed="false" data-icon="moon"
|
|
48
|
+
data-i18n-attr="aria-label:theme.dark;title:theme.dark"></button>
|
|
49
|
+
<button class="theme-btn" id="theme-auto" type="button" aria-pressed="false" data-icon="auto"
|
|
50
|
+
data-i18n-attr="aria-label:theme.auto;title:theme.auto"></button>
|
|
47
51
|
</div>
|
|
48
52
|
<button class="btn btn-secondary btn-small" id="logout-btn" type="button" hidden
|
|
49
|
-
data-i18n="nav.logout"></button>
|
|
53
|
+
data-icon="signOut"><span data-i18n="nav.logout"></span></button>
|
|
50
54
|
</div>
|
|
51
55
|
</nav>
|
|
52
56
|
|
|
@@ -82,6 +86,8 @@
|
|
|
82
86
|
</section>
|
|
83
87
|
</main>
|
|
84
88
|
|
|
89
|
+
<footer class="app-footer" id="app-footer" hidden></footer>
|
|
90
|
+
|
|
85
91
|
<div id="modal-root" hidden></div>
|
|
86
92
|
<div id="toast-root" class="toast-root"></div>
|
|
87
93
|
</body>
|
package/admin/js/api.js
CHANGED
|
@@ -11,10 +11,31 @@ import { API_BASE, CSRF_HEADER, ERROR_CODE } from './constants.js';
|
|
|
11
11
|
const SAFE_METHODS = new Set(['GET', 'HEAD']);
|
|
12
12
|
const HTTP_NO_CONTENT = 204;
|
|
13
13
|
const HTTP_UNAUTHORIZED = 401;
|
|
14
|
+
const HTTP_FORBIDDEN = 403;
|
|
15
|
+
/** These establish a session; failing them is an answer, not something to recover from. */
|
|
16
|
+
const SESSION_PATHS = new Set(['session', 'login']);
|
|
14
17
|
|
|
15
18
|
let csrfToken = null;
|
|
16
19
|
const unauthenticatedListeners = new Set();
|
|
17
20
|
|
|
21
|
+
/**
|
|
22
|
+
* How a failed request recovers instead of failing, set by main.js:
|
|
23
|
+
* reauthenticate() a sign-in dialog over the page; true once signed in again
|
|
24
|
+
* confirmPassword() the password for an action that cannot be undone; true once confirmed
|
|
25
|
+
*/
|
|
26
|
+
const recovery = { reauthenticate: null, confirmPassword: null };
|
|
27
|
+
/** Several requests can find the session gone at once; they share one dialog. */
|
|
28
|
+
let pendingSignIn = null;
|
|
29
|
+
|
|
30
|
+
export function setRecovery(handlers) {
|
|
31
|
+
Object.assign(recovery, handlers);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function signInAgain() {
|
|
35
|
+
pendingSignIn ??= recovery.reauthenticate().finally(() => { pendingSignIn = null; });
|
|
36
|
+
return pendingSignIn;
|
|
37
|
+
}
|
|
38
|
+
|
|
18
39
|
export class ApiError {
|
|
19
40
|
/** @param {number} status @param {string} code @param {object} [body] */
|
|
20
41
|
constructor(status, code, body = {}) {
|
|
@@ -38,7 +59,7 @@ export function setCsrfToken(token) {
|
|
|
38
59
|
* @param {string} path relative to the API base, no leading slash
|
|
39
60
|
* @param {{ body?: object, query?: Record<string, string|number|undefined> }} [options]
|
|
40
61
|
*/
|
|
41
|
-
export async function request(method, path, { body, query } = {}) {
|
|
62
|
+
export async function request(method, path, { body, query } = {}, { retried = false } = {}) {
|
|
42
63
|
const params = new URLSearchParams();
|
|
43
64
|
for (const [key, value] of Object.entries(query || {})) {
|
|
44
65
|
if (value !== undefined && value !== null && value !== '') params.set(key, String(value));
|
|
@@ -55,11 +76,15 @@ export async function request(method, path, { body, query } = {}) {
|
|
|
55
76
|
method,
|
|
56
77
|
headers,
|
|
57
78
|
credentials: 'same-origin',
|
|
79
|
+
// The API never redirects. A gateway in front of it (Cloudflare
|
|
80
|
+
// Access, say) does, to its own sign-in page, when its session ends.
|
|
81
|
+
redirect: 'manual',
|
|
58
82
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
59
83
|
});
|
|
60
84
|
} catch {
|
|
61
85
|
throw new ApiError(0, ERROR_CODE.NETWORK);
|
|
62
86
|
}
|
|
87
|
+
if (response.type === 'opaqueredirect') throw new ApiError(0, ERROR_CODE.ACCESS_EXPIRED);
|
|
63
88
|
|
|
64
89
|
if (response.status === HTTP_NO_CONTENT) return null;
|
|
65
90
|
|
|
@@ -72,29 +97,50 @@ export async function request(method, path, { body, query } = {}) {
|
|
|
72
97
|
|
|
73
98
|
if (!response.ok) {
|
|
74
99
|
const code = payload.code || ERROR_CODE.SERVER_ERROR;
|
|
100
|
+
const retry = () => request(method, path, { body, query }, { retried: true });
|
|
101
|
+
|
|
75
102
|
if (response.status === HTTP_UNAUTHORIZED && code === ERROR_CODE.UNAUTHENTICATED) {
|
|
103
|
+
// Sign in again over the page, then carry on with this request.
|
|
104
|
+
if (!retried && !SESSION_PATHS.has(path) && recovery.reauthenticate && await signInAgain()) return retry();
|
|
76
105
|
for (const listener of unauthenticatedListeners) listener();
|
|
77
106
|
}
|
|
107
|
+
if (response.status === HTTP_FORBIDDEN && code === ERROR_CODE.REAUTH_REQUIRED
|
|
108
|
+
&& !retried && recovery.confirmPassword && await recovery.confirmPassword()) {
|
|
109
|
+
return retry();
|
|
110
|
+
}
|
|
78
111
|
throw new ApiError(response.status, code, payload);
|
|
79
112
|
}
|
|
80
113
|
return payload;
|
|
81
114
|
}
|
|
82
115
|
|
|
116
|
+
/** A listing or analysis query for the wire: breakdown filters travel as JSON. */
|
|
117
|
+
function filterQuery({ where, ...query } = {}) {
|
|
118
|
+
return where && Object.keys(where).length ? { ...query, where: JSON.stringify(where) } : query;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const appPath = (appId, rest) => `apps/${encodeURIComponent(appId)}/${rest}`;
|
|
122
|
+
|
|
83
123
|
export const api = {
|
|
84
124
|
session: () => request('GET', 'session'),
|
|
85
125
|
login: (password) => request('POST', 'login', { body: { password } }),
|
|
126
|
+
reauth: (password) => request('POST', 'reauth', { body: { password } }),
|
|
86
127
|
logout: () => request('POST', 'logout'),
|
|
87
128
|
meta: () => request('GET', 'meta'),
|
|
88
129
|
apps: () => request('GET', 'apps'),
|
|
89
|
-
views: (appId, query) => request('GET',
|
|
90
|
-
allViews: (query) => request('GET', 'views', { query }),
|
|
91
|
-
analytics: (appId, query) => request('GET',
|
|
92
|
-
analyticsAll: (query) => request('GET', 'analytics', { query }),
|
|
130
|
+
views: (appId, query) => request('GET', appPath(appId, 'views'), { query: filterQuery(query) }),
|
|
131
|
+
allViews: (query) => request('GET', 'views', { query: filterQuery(query) }),
|
|
132
|
+
analytics: (appId, query) => request('GET', appPath(appId, 'analytics'), { query: filterQuery(query) }),
|
|
133
|
+
analyticsAll: (query) => request('GET', 'analytics', { query: filterQuery(query) }),
|
|
134
|
+
eventTypes: (appId, query) => request('GET', appPath(appId, 'event-types'), { query }),
|
|
135
|
+
eventTypesAll: (query) => request('GET', 'event-types', { query }),
|
|
136
|
+
realtime: (appId) => request('GET', appPath(appId, 'realtime')),
|
|
137
|
+
realtimeAll: () => request('GET', 'realtime'),
|
|
93
138
|
edit: (appId, ids, changes) => request('PATCH', `apps/${encodeURIComponent(appId)}/views`, { body: { ids, changes } }),
|
|
94
139
|
note: (appId, ids, note) => request('PUT', `apps/${encodeURIComponent(appId)}/views/note`, { body: { ids, note } }),
|
|
95
140
|
remove: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/delete`, { body: { ids } }),
|
|
96
141
|
restore: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/restore`, { body: { ids } }),
|
|
97
142
|
purge: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/purge`, { body: { ids } }),
|
|
98
143
|
adminLog: (query) => request('GET', 'logs/admin', { query }),
|
|
99
|
-
|
|
144
|
+
trackingLog: (query) => request('GET', 'logs/tracking', { query }),
|
|
145
|
+
trackingSummary: (query) => request('GET', 'logs/tracking/summary', { query }),
|
|
100
146
|
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app tabs: one per app, led by one for every app together. Overview,
|
|
3
|
+
* Views, and Trash each show a strip, and all three follow the same choice:
|
|
4
|
+
* switching in one switches the others.
|
|
5
|
+
*
|
|
6
|
+
* ARIA tabs pattern with automatic activation: arrow keys, Home and End move
|
|
7
|
+
* and select.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { el, replaceChildren } from './dom.js';
|
|
11
|
+
import { formatNumber } from './format.js';
|
|
12
|
+
import { t } from './i18n.js';
|
|
13
|
+
import { ALL_APPS, KEY } from './constants.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every app, led by one entry for all of them together.
|
|
17
|
+
* @param {object[]} apps as GET api/apps lists them
|
|
18
|
+
*/
|
|
19
|
+
export function appEntries(apps) {
|
|
20
|
+
const sum = (key) => apps.reduce((total, app) => total + app[key], 0);
|
|
21
|
+
return [
|
|
22
|
+
{
|
|
23
|
+
appId: ALL_APPS,
|
|
24
|
+
label: t('appTabs.all'),
|
|
25
|
+
active: sum('active'),
|
|
26
|
+
deleted: sum('deleted'),
|
|
27
|
+
modified: sum('modified'),
|
|
28
|
+
available: true,
|
|
29
|
+
},
|
|
30
|
+
...apps.map((app) => ({ ...app, label: app.appId })),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {{ context: { apps: () => object[], appId: () => string, setAppId: (id: string) => void },
|
|
36
|
+
* controls: string, trash?: boolean }} options controls is the id of the region the tabs switch
|
|
37
|
+
* @returns {{ element: HTMLElement, render: () => void }}
|
|
38
|
+
*/
|
|
39
|
+
export function createAppTabs({ context, controls, trash = false }) {
|
|
40
|
+
const element = el('div', {
|
|
41
|
+
className: 'app-tabs',
|
|
42
|
+
attrs: { role: 'tablist', 'aria-label': t('appTabs.label') },
|
|
43
|
+
});
|
|
44
|
+
let focusOnRender = false;
|
|
45
|
+
|
|
46
|
+
function select(appId, { focus = false } = {}) {
|
|
47
|
+
focusOnRender = focus;
|
|
48
|
+
if (appId === context.appId()) {
|
|
49
|
+
render();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
context.setAppId(appId);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function render() {
|
|
56
|
+
const current = context.appId();
|
|
57
|
+
replaceChildren(element, appEntries(context.apps()).map((app) => {
|
|
58
|
+
const selected = app.appId === current;
|
|
59
|
+
const count = trash ? app.deleted : app.active;
|
|
60
|
+
const countText = t(trash ? 'appTabs.deletedCount' : 'appTabs.activeCount', { count });
|
|
61
|
+
return el('button', {
|
|
62
|
+
className: `app-tab${selected ? ' active' : ''}${app.available ? '' : ' unavailable'}`,
|
|
63
|
+
attrs: {
|
|
64
|
+
type: 'button',
|
|
65
|
+
role: 'tab',
|
|
66
|
+
'aria-selected': String(selected),
|
|
67
|
+
'aria-controls': controls,
|
|
68
|
+
'aria-label': t('appTabs.tabName', { app: app.label, count: countText }),
|
|
69
|
+
tabindex: selected ? '0' : '-1',
|
|
70
|
+
title: app.available ? null : t('appTabs.unavailable', { app: app.appId }),
|
|
71
|
+
},
|
|
72
|
+
dataset: { appId: app.appId },
|
|
73
|
+
on: { click: () => select(app.appId) },
|
|
74
|
+
}, [
|
|
75
|
+
el('span', { className: 'app-tab-name', text: app.label }),
|
|
76
|
+
el('span', { className: 'app-tab-count', text: formatNumber(count), attrs: { 'aria-hidden': 'true' } }),
|
|
77
|
+
]);
|
|
78
|
+
}));
|
|
79
|
+
if (focusOnRender) {
|
|
80
|
+
element.querySelector('[aria-selected="true"]')?.focus();
|
|
81
|
+
focusOnRender = false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
element.addEventListener('keydown', (event) => {
|
|
86
|
+
const ids = appEntries(context.apps()).map((app) => app.appId);
|
|
87
|
+
const index = Math.max(0, ids.indexOf(context.appId()));
|
|
88
|
+
const moves = {
|
|
89
|
+
[KEY.ARROW_RIGHT]: (index + 1) % ids.length,
|
|
90
|
+
[KEY.ARROW_LEFT]: (index - 1 + ids.length) % ids.length,
|
|
91
|
+
[KEY.HOME]: 0,
|
|
92
|
+
[KEY.END]: ids.length - 1,
|
|
93
|
+
};
|
|
94
|
+
if (!(event.key in moves)) return;
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
select(ids[moves[event.key]], { focus: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return { element, render };
|
|
100
|
+
}
|