@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,41 @@
1
+ /**
2
+ * Clamped text with its full value on hover (FRONTEND.md §14).
3
+ *
4
+ * Clamp and tooltip ship as one unit so they cannot drift apart: call sites
5
+ * pass text and a line count, and the tooltip appears only when the rendered
6
+ * box is actually truncated, re-measured whenever the box resizes.
7
+ */
8
+
9
+ import { el } from './dom.js';
10
+ import { CLAMP_LINES } from './constants.js';
11
+
12
+ const observed = new WeakSet();
13
+ const resizeObserver = new ResizeObserver((entries) => {
14
+ for (const entry of entries) updateTitle(entry.target);
15
+ });
16
+
17
+ function isTruncated(node) {
18
+ return node.scrollHeight > node.clientHeight + 1 || node.scrollWidth > node.clientWidth + 1;
19
+ }
20
+
21
+ function updateTitle(node) {
22
+ if (isTruncated(node)) node.setAttribute('title', node.textContent);
23
+ else node.removeAttribute('title');
24
+ }
25
+
26
+ /**
27
+ * @param {string} text
28
+ * @param {{ lines?: number, className?: string }} [options]
29
+ * @returns {HTMLElement}
30
+ */
31
+ export function clampText(text, { lines = CLAMP_LINES.SINGLE, className = '' } = {}) {
32
+ const node = el('span', {
33
+ className: `clamp clamp-${lines} ${className}`.trim(),
34
+ text,
35
+ });
36
+ if (!observed.has(node)) {
37
+ observed.add(node);
38
+ resizeObserver.observe(node);
39
+ }
40
+ return node;
41
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Front-end constants for the admin UI (CODE_STANDARDS.md §0/§1).
3
+ *
4
+ * Limits that the server enforces (batch size, field lengths, page sizes) are
5
+ * NOT duplicated here: the UI reads them from GET api/meta, so the two can
6
+ * never disagree. What lives here is either UI-only or part of the wire
7
+ * contract; the wire-contract values are checked against constants.js by
8
+ * tests/adminUiContract.test.js.
9
+ */
10
+
11
+ /** Human-facing product name. Mirrors APP_NAME in the server constants. */
12
+ export const APP_NAME = 'ViewCounter';
13
+
14
+ /** Storage-key namespace. Mirrors APP_SLUG in the server constants. */
15
+ export const APP_SLUG = 'viewcounter';
16
+
17
+ /** Relative to the page, so the UI works wherever the router is mounted. */
18
+ export const API_BASE = 'api';
19
+
20
+ /** Must match ADMIN.CSRF_HEADER on the server. */
21
+ export const CSRF_HEADER = 'x-csrf-token';
22
+
23
+ /** Mirrors ADMIN_ERROR_CODE on the server. */
24
+ export const ERROR_CODE = Object.freeze({
25
+ UNAUTHENTICATED: 'UNAUTHENTICATED',
26
+ INVALID_PASSWORD: 'INVALID_PASSWORD',
27
+ TOO_MANY_ATTEMPTS: 'TOO_MANY_ATTEMPTS',
28
+ RATE_LIMITED: 'RATE_LIMITED',
29
+ CSRF_REJECTED: 'CSRF_REJECTED',
30
+ REAUTH_REQUIRED: 'REAUTH_REQUIRED',
31
+ VALIDATION_FAILED: 'VALIDATION_FAILED',
32
+ NOT_FOUND: 'NOT_FOUND',
33
+ SERVER_ERROR: 'SERVER_ERROR',
34
+ /** Client-side only: the request never got an answer. */
35
+ NETWORK: 'NETWORK',
36
+ /** Client-side only: the page itself failed (a bug), not the server. */
37
+ UNEXPECTED: 'UNEXPECTED',
38
+ /**
39
+ * Client-side only: a gateway in front of the server (Cloudflare Access,
40
+ * say) answered with a redirect to its own sign-in page.
41
+ */
42
+ ACCESS_EXPIRED: 'ACCESS_EXPIRED',
43
+ });
44
+
45
+ /** Using the UI extends the session at most this often (the server keeps it for days). */
46
+ export const SESSION_PING_INTERVAL_MS = 5 * 60 * 1000;
47
+
48
+ /** How often "Right now" on the Overview refreshes while it is on screen. */
49
+ export const REALTIME_REFRESH_MS = 15 * 1000;
50
+
51
+ /** How often the tracking log refreshes itself when auto-refresh is on. */
52
+ export const TRACKING_LOG_REFRESH_MS = 10 * 1000;
53
+
54
+ /** Where the product lives: shown in the header and footer. */
55
+ export const LINKS = Object.freeze({
56
+ WEBSITE: 'https://viewcounter.harshankur.com',
57
+ DOCS_ADMIN: 'https://viewcounter.harshankur.com/#admin',
58
+ SOURCE: 'https://github.com/harshankur/viewcounter',
59
+ CHANGELOG: 'https://github.com/harshankur/viewcounter/blob/master/CHANGELOG.md',
60
+ NPM: 'https://www.npmjs.com/package/@harshankur/viewcounter',
61
+ });
62
+
63
+ /** The copyright notice in the footer, as the LICENSE file states it. */
64
+ export const COPYRIGHT = Object.freeze({
65
+ YEAR: 2026,
66
+ HOLDER: 'Harsh Ankur',
67
+ HOLDER_URL: 'https://harshankur.com',
68
+ });
69
+
70
+ /** Mirrors VIEW_STATUS on the server. */
71
+ export const VIEW_STATUS = Object.freeze({
72
+ ACTIVE: 'active',
73
+ DELETED: 'deleted',
74
+ ALL: 'all',
75
+ });
76
+
77
+ /** Mirrors MODIFIED_FILTER on the server. */
78
+ export const MODIFIED_FILTER = Object.freeze({
79
+ ANY: 'any',
80
+ MODIFIED: 'modified',
81
+ UNMODIFIED: 'unmodified',
82
+ });
83
+
84
+ export const SORT_ORDER = Object.freeze({
85
+ ASC: 'asc',
86
+ DESC: 'desc',
87
+ });
88
+
89
+ /** The sections of the UI, in order. */
90
+ export const TAB = Object.freeze({
91
+ OVERVIEW: 'overview',
92
+ VIEWS: 'views',
93
+ TRASH: 'trash',
94
+ TRACKING_LOG: 'trackingLog',
95
+ ADMIN_LOG: 'adminLog',
96
+ });
97
+
98
+ /** Each section's address after the #, so a section can be bookmarked. */
99
+ export const TAB_HASH = Object.freeze({
100
+ [TAB.OVERVIEW]: 'overview',
101
+ [TAB.VIEWS]: 'views',
102
+ [TAB.TRASH]: 'trash',
103
+ [TAB.TRACKING_LOG]: 'tracking-log',
104
+ [TAB.ADMIN_LOG]: 'admin-log',
105
+ });
106
+
107
+ /** Addresses earlier versions used, still honoured. */
108
+ export const LEGACY_TAB_HASH = Object.freeze({
109
+ viewLog: TAB.TRACKING_LOG,
110
+ adminLog: TAB.ADMIN_LOG,
111
+ });
112
+
113
+ export const THEME = Object.freeze({
114
+ LIGHT: 'light',
115
+ DARK: 'dark',
116
+ AUTO: 'auto',
117
+ });
118
+
119
+ export const STORAGE_KEY = Object.freeze({
120
+ THEME: `${APP_SLUG}-admin-theme`,
121
+ LAST_APP: `${APP_SLUG}-admin-last-app`,
122
+ OVERVIEW_RANGE: `${APP_SLUG}-admin-overview-range`,
123
+ OVERVIEW_METRIC: `${APP_SLUG}-admin-overview-metric`,
124
+ OVERVIEW_CARDS: `${APP_SLUG}-admin-overview-cards`,
125
+ /** Followed by a table's name: its chosen columns, their order, and widths. */
126
+ TABLE_PREFIX: `${APP_SLUG}-admin-table-`,
127
+ });
128
+
129
+ /** Geometry of the data tables, in CSS pixels. */
130
+ export const TABLE = Object.freeze({
131
+ /** Narrower than this, a table is a list of cards. */
132
+ CARDS_BELOW: 600,
133
+ /** A card shows this many of the chosen columns; the rest are under "more". */
134
+ CARD_FIELDS: 5,
135
+ SELECT_WIDTH: 42,
136
+ /** The column of "more" buttons, present only when some columns do not fit. */
137
+ MORE_WIDTH: 40,
138
+ /** Narrowest and widest a column can be made. */
139
+ MIN_WIDTH: 72,
140
+ MAX_WIDTH: 720,
141
+ /** Arrow keys on a column edge move it this far; with Shift, further. */
142
+ RESIZE_STEP: 16,
143
+ RESIZE_STEP_LARGE: 64,
144
+ });
145
+
146
+ export const TOAST_DURATION_MS = 4000;
147
+
148
+ /** Wait after the last keystroke before searching. */
149
+ export const SEARCH_DEBOUNCE_MS = 300;
150
+
151
+ export const DEFAULT_LOCALE = 'en';
152
+
153
+ /**
154
+ * Every field a view row exposes, grouped for the details dialog. The group
155
+ * key names its heading (details.groups.*).
156
+ */
157
+ export const VIEW_DETAIL_GROUPS = Object.freeze([
158
+ Object.freeze({ key: 'view', fields: Object.freeze(['id', 'timestamp', 'eventType', 'eventData', 'isUnique', 'sessionId']) }),
159
+ Object.freeze({ key: 'page', fields: Object.freeze(['hostname', 'pagePath', 'pageTitle']) }),
160
+ Object.freeze({ key: 'source', fields: Object.freeze([
161
+ 'sourceType', 'referrer', 'referrerDomain', 'utmSource', 'utmMedium', 'utmCampaign', 'utmTerm', 'utmContent',
162
+ ]) }),
163
+ Object.freeze({ key: 'visitor', fields: Object.freeze([
164
+ 'country', 'region', 'city', 'language', 'maskedIp', 'deviceSize', 'deviceType', 'browser', 'browserVersion', 'os', 'osVersion',
165
+ ]) }),
166
+ Object.freeze({ key: 'engagement', fields: Object.freeze(['engagedMs', 'scrollDepth']) }),
167
+ Object.freeze({ key: 'admin', fields: Object.freeze(['note', 'adminModifiedAt', 'deletedAt']) }),
168
+ ]);
169
+
170
+ /** Every field a view row exposes, in display order. */
171
+ export const VIEW_DETAIL_FIELDS = Object.freeze(VIEW_DETAIL_GROUPS.flatMap((group) => group.fields));
172
+
173
+ /** Editable fields rendered as free text, and those rendered otherwise. */
174
+ export const TEXT_FIELDS = Object.freeze(['pagePath', 'pageTitle', 'referrer', 'eventType']);
175
+ export const DEVICE_SIZE_FIELD = 'deviceSize';
176
+ export const EVENT_DATA_FIELD = 'eventData';
177
+
178
+ /** Rows of the event-data editor. */
179
+ export const EVENT_DATA_ROWS = 6;
180
+ export const NOTE_ROWS = 4;
181
+
182
+ /** Two-line clamp for page titles, one line for everything else. */
183
+ export const CLAMP_LINES = Object.freeze({ SINGLE: 1, DOUBLE: 2 });
184
+
185
+ /** Key names the keyboard handlers compare against. */
186
+ export const KEY = Object.freeze({
187
+ ESCAPE: 'Escape',
188
+ ENTER: 'Enter',
189
+ SPACE: ' ',
190
+ TAB: 'Tab',
191
+ ARROW_UP: 'ArrowUp',
192
+ ARROW_DOWN: 'ArrowDown',
193
+ ARROW_LEFT: 'ArrowLeft',
194
+ ARROW_RIGHT: 'ArrowRight',
195
+ HOME: 'Home',
196
+ END: 'End',
197
+ });
198
+
199
+ /** Sentinel app selection meaning every app. Not a valid app ID ('*' is refused). */
200
+ export const ALL_APPS = '*';
201
+
202
+ /** Mirrors ADMIN_RANGE on the server. */
203
+ export const RANGE = Object.freeze({
204
+ DAY: '24h',
205
+ WEEK: '7d',
206
+ MONTH: '30d',
207
+ QUARTER: '90d',
208
+ YEAR: '1y',
209
+ ALL: 'all',
210
+ });
211
+
212
+ /** Geometry and limits for the insights charts. */
213
+ export const CHART = Object.freeze({
214
+ TREND: Object.freeze({
215
+ WIDTH: 720,
216
+ HEIGHT: 220,
217
+ MARGIN: Object.freeze({ top: 12, right: 12, bottom: 28, left: 44 }),
218
+ }),
219
+ /** A stat tile's sparkline. */
220
+ SPARK: Object.freeze({ WIDTH: 120, HEIGHT: 28, PAD: 3 }),
221
+ /** "Right now": one column per minute. */
222
+ MINUTES: Object.freeze({ WIDTH: 300, HEIGHT: 56, GAP: 2, SPAN: 30 }),
223
+ /** Label every third hour across the heatmap. */
224
+ HEATMAP_HOUR_LABEL_EVERY: 3,
225
+ Y_TICKS: 4,
226
+ TICK_GAP: 8,
227
+ X_LABEL_GAP: 18,
228
+ /** r >= 4, so a marker is at least 8px across. */
229
+ DOT_RADIUS: 4,
230
+ TOOLTIP_OFFSET: 12,
231
+ /** Event types listed in a country's tooltip. */
232
+ TOOLTIP_TYPES: 4,
233
+ /** Sequential classes on the map; must match --map-1..5 in admin.css. */
234
+ MAP_CLASSES: 5,
235
+ COUNTRY_LIST_LIMIT: 10,
236
+ });
237
+
238
+ /** JSON indentation in the details and edit dialogs. */
239
+ export const JSON_INDENT = 2;