@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.
- package/.env.example +37 -11
- package/README.md +330 -138
- 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 +57 -7
- package/db/LogRepository.js +172 -35
- package/db/adminSchema.js +93 -4
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +484 -0
- package/db/rejectionCounter.js +117 -0
- package/index.js +49 -26
- 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 +236 -18
- package/tracker/tracker.js +240 -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/js/format.js
CHANGED
|
@@ -47,30 +47,81 @@ export function formatCompact(value) {
|
|
|
47
47
|
return new Intl.NumberFormat(currentLocale(), { notation: 'compact', maximumFractionDigits: 1 }).format(Number(value) || 0);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** A headline count: exact below ten thousand (1,029), compact above (12.9K). */
|
|
51
|
+
export function formatHeadline(value) {
|
|
52
|
+
return Math.abs(Number(value) || 0) < 10000 ? formatNumber(value) : formatCompact(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
50
55
|
/** A ratio (0..1) as a percentage. */
|
|
51
56
|
export function formatPercent(ratio) {
|
|
52
57
|
return new Intl.NumberFormat(currentLocale(), { style: 'percent', maximumFractionDigits: 1 }).format(Number(ratio) || 0);
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
/** A number with at most `digits` decimals: 1.75 pages per visit reads 1.8. */
|
|
61
|
+
export function formatDecimal(value, digits = 1) {
|
|
62
|
+
return new Intl.NumberFormat(currentLocale(), { maximumFractionDigits: digits }).format(Number(value) || 0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function unit(value, name) {
|
|
66
|
+
return new Intl.NumberFormat(currentLocale(), { style: 'unit', unit: name, unitDisplay: 'narrow' }).format(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
/**
|
|
56
|
-
* A
|
|
57
|
-
* (
|
|
58
|
-
*
|
|
70
|
+
* A duration, to the second below an hour: 45s, 3m 20s, 1h 5m. Empty
|
|
71
|
+
* (never measured) shows the placeholder rather than a misleading 0s.
|
|
72
|
+
* @param {number|null} ms
|
|
73
|
+
*/
|
|
74
|
+
export function formatDuration(ms) {
|
|
75
|
+
if (ms === null || ms === undefined || Number.isNaN(Number(ms))) return t('common.none');
|
|
76
|
+
const seconds = Math.round(Number(ms) / 1000);
|
|
77
|
+
if (seconds < 60) return unit(seconds, 'second');
|
|
78
|
+
if (seconds < 3600) {
|
|
79
|
+
const rest = seconds % 60;
|
|
80
|
+
return rest ? `${unit(Math.floor(seconds / 60), 'minute')} ${unit(rest, 'second')}` : unit(seconds / 60, 'minute');
|
|
81
|
+
}
|
|
82
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
83
|
+
const hours = Math.floor(seconds / 3600);
|
|
84
|
+
return minutes ? `${unit(hours, 'hour')} ${unit(minutes, 'minute')}` : unit(hours, 'hour');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* When a trend bucket starts. Day, week, and month buckets are named by their
|
|
89
|
+
* date (YYYY-MM-DD) and read as calendar dates at UTC midnight; hour buckets
|
|
90
|
+
* ("YYYY-MM-DD HH:00") are instants in the server's clock, which is UTC.
|
|
91
|
+
* @param {string} period
|
|
92
|
+
* @param {'hour'|'day'|'week'|'month'} bucket
|
|
93
|
+
* @returns {Date}
|
|
94
|
+
*/
|
|
95
|
+
export function periodStart(period, bucket) {
|
|
96
|
+
return bucket === 'hour'
|
|
97
|
+
? new Date(`${String(period).replace(' ', 'T')}:00Z`)
|
|
98
|
+
: new Date(`${period}T00:00:00Z`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A trend bucket label. Calendar buckets are formatted in UTC so a day never
|
|
103
|
+
* shifts across time zones; an hour is an instant, shown in local time.
|
|
59
104
|
* @param {string} period
|
|
60
|
-
* @param {'day'|'week'|'month'} bucket
|
|
61
|
-
* @param {{ long?: boolean }} [options] long adds the
|
|
105
|
+
* @param {'hour'|'day'|'week'|'month'} bucket
|
|
106
|
+
* @param {{ long?: boolean }} [options] long adds the date to an hour, the year
|
|
107
|
+
* to a day, and "week of" to a week
|
|
62
108
|
*/
|
|
63
109
|
export function formatPeriod(period, bucket, { long = false } = {}) {
|
|
64
|
-
const date =
|
|
110
|
+
const date = periodStart(period, bucket);
|
|
65
111
|
if (Number.isNaN(date.getTime())) return period;
|
|
66
112
|
const locale = currentLocale();
|
|
113
|
+
if (bucket === 'hour') {
|
|
114
|
+
return new Intl.DateTimeFormat(locale, {
|
|
115
|
+
...(long ? { day: 'numeric', month: 'short' } : {}), hour: 'numeric', minute: '2-digit',
|
|
116
|
+
}).format(date);
|
|
117
|
+
}
|
|
67
118
|
if (bucket === 'month') {
|
|
68
119
|
return new Intl.DateTimeFormat(locale, { month: long ? 'long' : 'short', year: 'numeric', timeZone: 'UTC' }).format(date);
|
|
69
120
|
}
|
|
70
121
|
const day = new Intl.DateTimeFormat(locale, {
|
|
71
122
|
day: 'numeric', month: 'short', ...(long ? { year: 'numeric' } : {}), timeZone: 'UTC',
|
|
72
123
|
}).format(date);
|
|
73
|
-
return bucket === 'week' && long ? t('
|
|
124
|
+
return bucket === 'week' && long ? t('charts.weekOf', { date: day }) : day;
|
|
74
125
|
}
|
|
75
126
|
|
|
76
127
|
/** A value for display, with a translated placeholder for empty. */
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The UI's icons: small line drawings on a 24-unit grid, built as SVG
|
|
3
|
+
* elements (never parsed from markup), stroked in the text colour around
|
|
4
|
+
* them. An icon is always decoration: the control it sits in carries the
|
|
5
|
+
* accessible name.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Each icon is a list of [tag, attributes]. Paths are open line drawings;
|
|
12
|
+
* `fill: true` marks the few shapes that are solid.
|
|
13
|
+
*/
|
|
14
|
+
const ICONS = {
|
|
15
|
+
overview: [
|
|
16
|
+
['rect', { x: 3, y: 3, width: 7, height: 9, rx: 1.5 }],
|
|
17
|
+
['rect', { x: 14, y: 3, width: 7, height: 5, rx: 1.5 }],
|
|
18
|
+
['rect', { x: 14, y: 12, width: 7, height: 9, rx: 1.5 }],
|
|
19
|
+
['rect', { x: 3, y: 16, width: 7, height: 5, rx: 1.5 }],
|
|
20
|
+
],
|
|
21
|
+
views: [
|
|
22
|
+
['rect', { x: 3, y: 4, width: 18, height: 16, rx: 2 }],
|
|
23
|
+
['path', { d: 'M3 9.5h18M3 15h18M9 9.5V20' }],
|
|
24
|
+
],
|
|
25
|
+
trash: [
|
|
26
|
+
['path', { d: 'M4 7h16M10 11v6M14 11v6M9 7V4.5h6V7M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12' }],
|
|
27
|
+
],
|
|
28
|
+
trackingLog: [
|
|
29
|
+
['path', { d: 'M3 12h4l3-7 4 14 3-7h4' }],
|
|
30
|
+
],
|
|
31
|
+
adminLog: [
|
|
32
|
+
['path', { d: 'M12 3l7 3v5c0 4.6-3 8.4-7 10-4-1.6-7-5.4-7-10V6z' }],
|
|
33
|
+
['path', { d: 'M9 12l2 2 4-4' }],
|
|
34
|
+
],
|
|
35
|
+
sun: [
|
|
36
|
+
['circle', { cx: 12, cy: 12, r: 4 }],
|
|
37
|
+
['path', { d: 'M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M5.3 18.7l1.4-1.4M17.3 6.7l1.4-1.4' }],
|
|
38
|
+
],
|
|
39
|
+
moon: [
|
|
40
|
+
['path', { d: 'M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z' }],
|
|
41
|
+
],
|
|
42
|
+
auto: [
|
|
43
|
+
['circle', { cx: 12, cy: 12, r: 8.5 }],
|
|
44
|
+
['path', { d: 'M12 3.5a8.5 8.5 0 0 1 0 17z', fill: true }],
|
|
45
|
+
],
|
|
46
|
+
signOut: [
|
|
47
|
+
['path', { d: 'M9 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h3M15 16.5l4.5-4.5L15 7.5M19.5 12H9' }],
|
|
48
|
+
],
|
|
49
|
+
external: [
|
|
50
|
+
['path', { d: 'M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4' }],
|
|
51
|
+
],
|
|
52
|
+
globe: [
|
|
53
|
+
['circle', { cx: 12, cy: 12, r: 9 }],
|
|
54
|
+
['path', { d: 'M3 12h18M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z' }],
|
|
55
|
+
],
|
|
56
|
+
book: [
|
|
57
|
+
['path', { d: 'M5 5.5A2.5 2.5 0 0 1 7.5 3H19v15H7.5A2.5 2.5 0 0 0 5 20.5zM5 20.5A2.5 2.5 0 0 0 7.5 21H19v-3' }],
|
|
58
|
+
],
|
|
59
|
+
code: [
|
|
60
|
+
['path', { d: 'M8.5 7L3.5 12l5 5M15.5 7l5 5-5 5' }],
|
|
61
|
+
],
|
|
62
|
+
package: [
|
|
63
|
+
['path', { d: 'M12 3l8 4.5v9L12 21l-8-4.5v-9zM4 7.5l8 4.5 8-4.5M12 12v9' }],
|
|
64
|
+
],
|
|
65
|
+
info: [
|
|
66
|
+
['circle', { cx: 12, cy: 12, r: 9 }],
|
|
67
|
+
['path', { d: 'M12 11v5.5M12 7.6v.1' }],
|
|
68
|
+
],
|
|
69
|
+
edit: [
|
|
70
|
+
['path', { d: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4' }],
|
|
71
|
+
],
|
|
72
|
+
note: [
|
|
73
|
+
['path', { d: 'M5 4h14v10.5L14.5 19H5zM14 19v-4.5h5M8.5 8.5h7M8.5 12h4' }],
|
|
74
|
+
],
|
|
75
|
+
restore: [
|
|
76
|
+
['path', { d: 'M3.5 12a8.5 8.5 0 1 0 2.6-6.1M3.5 4v4.5H8' }],
|
|
77
|
+
],
|
|
78
|
+
purge: [
|
|
79
|
+
['circle', { cx: 12, cy: 12, r: 9 }],
|
|
80
|
+
['path', { d: 'M9 9l6 6M15 9l-6 6' }],
|
|
81
|
+
],
|
|
82
|
+
chevronDown: [
|
|
83
|
+
['path', { d: 'M6 9l6 6 6-6' }],
|
|
84
|
+
],
|
|
85
|
+
chevronRight: [
|
|
86
|
+
['path', { d: 'M9 6l6 6-6 6' }],
|
|
87
|
+
],
|
|
88
|
+
columns: [
|
|
89
|
+
['rect', { x: 3, y: 4, width: 18, height: 16, rx: 2 }],
|
|
90
|
+
['path', { d: 'M9 4v16M15 4v16' }],
|
|
91
|
+
],
|
|
92
|
+
arrowUp: [
|
|
93
|
+
['path', { d: 'M12 19V5M6 11l6-6 6 6' }],
|
|
94
|
+
],
|
|
95
|
+
arrowDown: [
|
|
96
|
+
['path', { d: 'M12 5v14M6 13l6 6 6-6' }],
|
|
97
|
+
],
|
|
98
|
+
sort: [
|
|
99
|
+
['path', { d: 'M8 5v14M4.5 8.5L8 5l3.5 3.5M16 19V5M12.5 15.5L16 19l3.5-3.5' }],
|
|
100
|
+
],
|
|
101
|
+
table: [
|
|
102
|
+
['rect', { x: 3, y: 4, width: 18, height: 16, rx: 2 }],
|
|
103
|
+
['path', { d: 'M3 9.5h18M3 14.8h18M10 9.5V20' }],
|
|
104
|
+
],
|
|
105
|
+
chart: [
|
|
106
|
+
['path', { d: 'M4 4v16h16M7.5 15l4-4.5 3 3L20 7' }],
|
|
107
|
+
],
|
|
108
|
+
refresh: [
|
|
109
|
+
['path', { d: 'M20 12a8 8 0 1 1-2.4-5.7M20 4v5h-5' }],
|
|
110
|
+
],
|
|
111
|
+
close: [
|
|
112
|
+
['path', { d: 'M6 6l12 12M18 6L6 18' }],
|
|
113
|
+
],
|
|
114
|
+
filter: [
|
|
115
|
+
['path', { d: 'M4 5h16l-6.2 7.2V18l-3.6 2v-7.8z' }],
|
|
116
|
+
],
|
|
117
|
+
arrowRight: [
|
|
118
|
+
['path', { d: 'M5 12h14M13 6l6 6-6 6' }],
|
|
119
|
+
],
|
|
120
|
+
pause: [
|
|
121
|
+
['path', { d: 'M9 5.5v13M15 5.5v13' }],
|
|
122
|
+
],
|
|
123
|
+
play: [
|
|
124
|
+
['path', { d: 'M7.5 5.5l11 6.5-11 6.5z', fill: true }],
|
|
125
|
+
],
|
|
126
|
+
clock: [
|
|
127
|
+
['circle', { cx: 12, cy: 12, r: 9 }],
|
|
128
|
+
['path', { d: 'M12 7v5l3 2' }],
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Every icon name, for tests and for callers that pick one by key. */
|
|
133
|
+
export const ICON_NAMES = Object.freeze(Object.keys(ICONS));
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Build an icon.
|
|
137
|
+
* @param {string} name a key of ICONS
|
|
138
|
+
* @param {{ className?: string }} [options]
|
|
139
|
+
* @returns {SVGSVGElement}
|
|
140
|
+
*/
|
|
141
|
+
export function icon(name, { className = '' } = {}) {
|
|
142
|
+
const shapes = ICONS[name];
|
|
143
|
+
if (!shapes) throw new Error(`Unknown icon: ${name}`);
|
|
144
|
+
const root = document.createElementNS(SVG_NS, 'svg');
|
|
145
|
+
const attrs = {
|
|
146
|
+
class: `icon icon-${name} ${className}`.trim(),
|
|
147
|
+
viewBox: '0 0 24 24',
|
|
148
|
+
width: '16',
|
|
149
|
+
height: '16',
|
|
150
|
+
fill: 'none',
|
|
151
|
+
stroke: 'currentColor',
|
|
152
|
+
'stroke-width': '1.8',
|
|
153
|
+
'stroke-linecap': 'round',
|
|
154
|
+
'stroke-linejoin': 'round',
|
|
155
|
+
'aria-hidden': 'true',
|
|
156
|
+
focusable: 'false',
|
|
157
|
+
};
|
|
158
|
+
for (const [key, value] of Object.entries(attrs)) root.setAttribute(key, value);
|
|
159
|
+
for (const [tag, shapeAttrs] of shapes) {
|
|
160
|
+
const shape = document.createElementNS(SVG_NS, tag);
|
|
161
|
+
for (const [key, value] of Object.entries(shapeAttrs)) {
|
|
162
|
+
if (key === 'fill') shape.setAttribute('fill', 'currentColor');
|
|
163
|
+
else shape.setAttribute(key, String(value));
|
|
164
|
+
}
|
|
165
|
+
root.append(shape);
|
|
166
|
+
}
|
|
167
|
+
return root;
|
|
168
|
+
}
|
package/admin/js/listbox.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { el, clear, uniqueId } from './dom.js';
|
|
13
|
+
import { icon } from './icons.js';
|
|
13
14
|
import { KEY } from './constants.js';
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -28,7 +29,7 @@ export function createListbox({ label, options, value, onChange = () => {}, clas
|
|
|
28
29
|
const button = el('button', {
|
|
29
30
|
className: 'listbox-button',
|
|
30
31
|
attrs: { type: 'button', 'aria-haspopup': 'listbox', 'aria-expanded': 'false', 'aria-label': label },
|
|
31
|
-
}, [valueText,
|
|
32
|
+
}, [valueText, icon('chevronDown', { className: 'listbox-caret' })]);
|
|
32
33
|
|
|
33
34
|
const list = el('ul', {
|
|
34
35
|
className: 'listbox-popup',
|
package/admin/js/logs.js
CHANGED
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
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.
|
|
4
9
|
*/
|
|
5
10
|
|
|
6
11
|
import { api } from './api.js';
|
|
12
|
+
import { statTiles } from './charts.js';
|
|
7
13
|
import { clampText } from './clamp.js';
|
|
14
|
+
import { createDataTable } from './dataTable.js';
|
|
8
15
|
import { el, replaceChildren } from './dom.js';
|
|
9
|
-
import { formatNumber, orNone } from './format.js';
|
|
16
|
+
import { formatDate, formatHeadline, formatNumber, formatTime, orNone } from './format.js';
|
|
17
|
+
import { icon } from './icons.js';
|
|
10
18
|
import { t, tOr } from './i18n.js';
|
|
11
19
|
import { createListbox } from './listbox.js';
|
|
12
|
-
import { createPager
|
|
20
|
+
import { createPager } from './table.js';
|
|
21
|
+
import { TRACKING_LOG_REFRESH_MS } from './constants.js';
|
|
13
22
|
|
|
14
23
|
/** Sentinel for "no filter" in a filter listbox. */
|
|
15
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' };
|
|
16
27
|
|
|
17
28
|
function filterListbox(label, allLabel, values, labelFor, onChange) {
|
|
18
29
|
return createListbox({
|
|
@@ -23,20 +34,38 @@ function filterListbox(label, allLabel, values, labelFor, onChange) {
|
|
|
23
34
|
});
|
|
24
35
|
}
|
|
25
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
|
+
|
|
26
46
|
/**
|
|
27
|
-
* A paged, filterable, read-only log table
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
31
53
|
*/
|
|
32
|
-
function createLogPanel({
|
|
54
|
+
function createLogPanel({
|
|
55
|
+
name, title, intro, columns, rowKey, rowAttrs, filters, fetchPage, meta, reportError, emptyKey, notice,
|
|
56
|
+
above = [], controls = [], onLoad, tableClassName = '',
|
|
57
|
+
}) {
|
|
33
58
|
const state = { page: 1, pageSize: meta.pageSizeDefault, total: 0, entries: [], query: {}, seq: 0 };
|
|
34
59
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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;
|
|
40
69
|
const pager = createPager((page) => {
|
|
41
70
|
state.page = page;
|
|
42
71
|
load();
|
|
@@ -44,26 +73,31 @@ function createLogPanel({ title, columns, filters, fetchPage, renderRow, meta, r
|
|
|
44
73
|
|
|
45
74
|
const toolbar = el('div', { className: 'toolbar' }, [
|
|
46
75
|
el('div', { className: 'toolbar-group' }, filters.map((filter) => filter.listbox.element)),
|
|
76
|
+
el('div', { className: 'toolbar-group' }, [...controls, grid.chooserButton]),
|
|
47
77
|
]);
|
|
48
78
|
|
|
49
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,
|
|
50
85
|
toolbar,
|
|
51
86
|
notice ? el('p', { className: 'notice', text: notice }) : null,
|
|
52
|
-
|
|
87
|
+
grid.element,
|
|
53
88
|
pager.element,
|
|
54
89
|
]);
|
|
55
90
|
|
|
56
91
|
async function load() {
|
|
57
92
|
const seq = ++state.seq;
|
|
58
93
|
table.setAttribute('aria-busy', 'true');
|
|
94
|
+
onLoad?.(state.query);
|
|
59
95
|
try {
|
|
60
96
|
const result = await fetchPage({ ...state.query, page: state.page, pageSize: state.pageSize });
|
|
61
97
|
if (seq !== state.seq) return;
|
|
62
98
|
state.entries = result.entries;
|
|
63
99
|
state.total = result.total;
|
|
64
|
-
|
|
65
|
-
? state.entries.map(renderRow)
|
|
66
|
-
: [messageRow(columns.length, t(emptyKey))]);
|
|
100
|
+
grid.setRows(state.entries);
|
|
67
101
|
pager.update(state.page, state.pageSize, state.total);
|
|
68
102
|
} catch (error) {
|
|
69
103
|
if (seq === state.seq) reportError(error);
|
|
@@ -80,88 +114,205 @@ function createLogPanel({ title, columns, filters, fetchPage, renderRow, meta, r
|
|
|
80
114
|
};
|
|
81
115
|
}
|
|
82
116
|
|
|
83
|
-
return { element, load };
|
|
117
|
+
return { element, load, show: load, hide() {} };
|
|
84
118
|
}
|
|
85
119
|
|
|
86
120
|
/**
|
|
87
121
|
* @param {{ meta: object, appIds: () => string[], reportError: (error: unknown) => void }} deps
|
|
88
122
|
*/
|
|
89
123
|
export function createAdminLogPanel({ meta, appIds, reportError }) {
|
|
90
|
-
const filters = [];
|
|
91
124
|
const actionFilter = { param: 'action' };
|
|
92
125
|
actionFilter.listbox = filterListbox(t('logs.filterAction'), t('logs.allActions'), meta.actions,
|
|
93
126
|
(action) => tOr(`actions.${action}`, action), (value) => actionFilter.onValue(value));
|
|
94
127
|
const appFilter = { param: 'appId' };
|
|
95
128
|
appFilter.listbox = filterListbox(t('logs.filterApp'), t('logs.allApps'), appIds(), (id) => id,
|
|
96
129
|
(value) => appFilter.onValue(value));
|
|
97
|
-
filters.push(actionFilter, appFilter);
|
|
98
|
-
|
|
99
|
-
const renderRow = (entry) => el('tr', {}, [
|
|
100
|
-
timeCell(entry.createdAt, { seconds: true }),
|
|
101
|
-
el('td', { className: 'col-action' }, [clampText(tOr(`actions.${entry.action}`, entry.action))]),
|
|
102
|
-
el('td', { className: 'col-app' }, [clampText(orNone(entry.appId))]),
|
|
103
|
-
el('td', { className: 'col-rows' }, [clampText(formatNumber(entry.targetCount))]),
|
|
104
|
-
el('td', { className: 'col-fields' }, [clampText(
|
|
105
|
-
entry.fields.length ? entry.fields.map((field) => tOr(`fields.${field}`, field)).join(', ') : t('common.none'),
|
|
106
|
-
)]),
|
|
107
|
-
el('td', { className: 'col-session' }, [clampText(entry.sessionId ? entry.sessionId.slice(0, 8) : t('logs.system'), {
|
|
108
|
-
className: 'mono',
|
|
109
|
-
})]),
|
|
110
|
-
el('td', { className: 'col-ip' }, [clampText(orNone(entry.maskedIp), { className: 'mono' })]),
|
|
111
|
-
]);
|
|
112
130
|
|
|
113
|
-
const
|
|
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',
|
|
114
146
|
title: t('tabs.adminLog'),
|
|
115
|
-
|
|
116
|
-
|
|
147
|
+
intro: t('logs.adminIntro'),
|
|
148
|
+
columns,
|
|
149
|
+
rowKey: (entry) => entry.id,
|
|
150
|
+
filters: [actionFilter, appFilter],
|
|
117
151
|
fetchPage: api.adminLog,
|
|
118
|
-
renderRow,
|
|
119
152
|
meta,
|
|
120
153
|
reportError,
|
|
121
154
|
emptyKey: 'logs.emptyAdmin',
|
|
122
155
|
});
|
|
123
|
-
return panel;
|
|
124
156
|
}
|
|
125
157
|
|
|
126
158
|
/**
|
|
127
|
-
* Why old entries leave the
|
|
159
|
+
* Why old entries leave the tracking log. Empty when the host did not say (an
|
|
128
160
|
* embedding app that configures no retention), so the UI never guesses.
|
|
129
161
|
* @param {number|undefined} days
|
|
130
162
|
*/
|
|
131
|
-
function
|
|
163
|
+
function trackingLogNotice(days) {
|
|
132
164
|
if (typeof days !== 'number') return '';
|
|
133
|
-
return days > 0 ? t('logs.
|
|
165
|
+
return days > 0 ? t('logs.trackingRetention', { count: days }) : t('logs.trackingRetentionOff');
|
|
134
166
|
}
|
|
135
167
|
|
|
136
168
|
/**
|
|
137
|
-
* @param {{ meta: object, appIds: () => string[], reportError: (error: unknown) => void
|
|
169
|
+
* @param {{ meta: object, appIds: () => string[], reportError: (error: unknown) => void,
|
|
170
|
+
* openView: (appId: string, viewId: string) => void }} deps
|
|
138
171
|
*/
|
|
139
|
-
export function
|
|
172
|
+
export function createTrackingLogPanel({ meta, appIds, reportError, openView }) {
|
|
140
173
|
const sourceFilter = { param: 'source' };
|
|
141
174
|
sourceFilter.listbox = filterListbox(t('logs.filterSource'), t('logs.allSources'), meta.sources,
|
|
142
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));
|
|
143
179
|
const appFilter = { param: 'appId' };
|
|
144
180
|
appFilter.listbox = filterListbox(t('logs.filterApp'), t('logs.allApps'), appIds(), (id) => id,
|
|
145
181
|
(value) => appFilter.onValue(value));
|
|
146
182
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
el('
|
|
150
|
-
el('
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 }) }),
|
|
154
271
|
]);
|
|
155
272
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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,
|
|
162
282
|
meta,
|
|
163
283
|
reportError,
|
|
164
|
-
emptyKey: 'logs.
|
|
165
|
-
notice:
|
|
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',
|
|
166
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
|
+
};
|
|
167
318
|
}
|