@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.
Files changed (50) hide show
  1. package/.env.example +34 -10
  2. package/README.md +306 -136
  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 +55 -7
  25. package/db/LogRepository.js +172 -35
  26. package/db/adminSchema.js +89 -4
  27. package/db/adminSessionStore.js +104 -0
  28. package/db/analysis.js +479 -0
  29. package/db/rejectionCounter.js +117 -0
  30. package/index.js +53 -19
  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 +197 -18
  39. package/tracker/tracker.js +191 -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
@@ -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 trend bucket label. Buckets are named by the date they start on
57
- * (YYYY-MM-DD, from the server) and read as calendar dates, not instants, so
58
- * they are formatted in UTC to avoid shifting a day across time zones.
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 year, and "week of"
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 = new Date(`${period}T00:00:00Z`);
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('insights.weekOf', { date: day }) : day;
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
+ }
@@ -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, el('span', { className: 'listbox-caret', text: '▾', attrs: { 'aria-hidden': 'true' } })]);
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 admin operation log and the view register log. Both are read-only here:
3
- * the UI has no way to alter a log entry, and neither does the API.
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, headerCell, messageRow, timeCell } from './table.js';
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, optionally with a notice above it.
28
- * @param {{ title: string, columns: string[], filters: object[], fetchPage: (query: object) => Promise<object>,
29
- * renderRow: (entry: object) => HTMLElement, meta: object, reportError: (error: unknown) => void,
30
- * emptyKey: string, notice?: string }} options
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({ title, columns, filters, fetchPage, renderRow, meta, reportError, emptyKey, notice }) {
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 tbody = el('tbody');
36
- const table = el('table', { className: 'data-table log-table' }, [
37
- el('thead', {}, [el('tr', {}, columns.map((key) => headerCell({ label: t(`logColumns.${key}`), className: `col-${key}` })))]),
38
- tbody,
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
- el('div', { className: 'table-wrap' }, [table]),
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
- replaceChildren(tbody, state.entries.length
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 panel = createLogPanel({
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
- columns: ['time', 'action', 'app', 'rows', 'fields', 'session', 'ip'],
116
- filters,
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 view log. Empty when the host did not say (an
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 viewLogNotice(days) {
163
+ function trackingLogNotice(days) {
132
164
  if (typeof days !== 'number') return '';
133
- return days > 0 ? t('logs.viewLogRetention', { count: days }) : t('logs.viewLogRetentionOff');
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 }} deps
169
+ * @param {{ meta: object, appIds: () => string[], reportError: (error: unknown) => void,
170
+ * openView: (appId: string, viewId: string) => void }} deps
138
171
  */
139
- export function createViewLogPanel({ meta, appIds, reportError }) {
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
- const renderRow = (entry) => el('tr', {}, [
148
- timeCell(entry.createdAt, { seconds: true }),
149
- el('td', { className: 'col-app' }, [clampText(entry.appId)]),
150
- el('td', { className: 'col-source' }, [clampText(tOr(`logSources.${entry.source}`, entry.source))]),
151
- el('td', { className: 'col-event' }, [clampText(orNone(entry.eventType))]),
152
- el('td', { className: 'col-unique' }, [clampText(entry.isUnique ? t('common.yes') : t('common.no'))]),
153
- el('td', { className: 'col-view' }, [clampText(entry.viewId, { className: 'mono' })]),
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
- return createLogPanel({
157
- title: t('tabs.viewLog'),
158
- columns: ['time', 'app', 'source', 'event', 'unique', 'view'],
159
- filters: [sourceFilter, appFilter],
160
- fetchPage: api.viewLog,
161
- renderRow,
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.emptyViews',
165
- notice: viewLogNotice(meta.viewLogRetentionDays),
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
  }