Add website analytics
A Tianji-style Websites section: a small tracking script, a public collection endpoint, and a page per website with visitors, page views, visits, bounce rate, a chart and top lists for pages, referrers, browsers, systems, devices, countries, languages and events. No cookies and no stored IP address or user agent. SANDBAR_TRUST_PROXY makes the client address come from the proxy. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ea4fe5289
commit
b4fd31a182
13 files changed
+843
-22
No files matched your search
@@ -227,6 +227,28 @@
|
||||
.feed .empty { margin:0; }
|
||||
.msg { border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s2) var(--s3); display:flex; flex-direction:column; gap:var(--s1); }
|
||||
.msg .tag { margin-right:var(--s1); }
|
||||
/* website analytics */
|
||||
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(104px,1fr)); gap:var(--s3); margin-bottom:var(--s4); }
|
||||
.stat { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s3) var(--s4); }
|
||||
.stat .n { font-size:var(--text-xl); font-weight:600; font-variant-numeric:tabular-nums; }
|
||||
.stat .l { font-size:var(--text-sm); color:var(--text-muted); }
|
||||
.chart { display:flex; align-items:flex-end; gap:2px; height:180px; margin-top:var(--s3); }
|
||||
.chart .col { position:relative; flex:1; min-width:0; height:100%; display:flex; align-items:flex-end; }
|
||||
.chart .col:hover { background:var(--surface-2); }
|
||||
.chart .v, .chart .u { position:absolute; left:0; right:0; bottom:0; border-radius:2px 2px 0 0; }
|
||||
.chart .v { background:color-mix(in srgb, var(--accent) 35%, var(--surface)); }
|
||||
.chart .u { background:var(--accent); }
|
||||
.axis2 { display:flex; justify-content:space-between; margin-top:var(--s1); font-size:var(--text-xs); color:var(--text-faint); }
|
||||
.legend { display:flex; gap:var(--s4); font-size:var(--text-sm); color:var(--text-muted); flex-wrap:wrap; }
|
||||
.legend i { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:var(--s1); vertical-align:baseline; }
|
||||
.cols { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:var(--s4); margin-top:var(--s4); }
|
||||
.cols .card { margin:0; }
|
||||
.top { width:100%; border-collapse:collapse; font-size:var(--text-sm); }
|
||||
.top td { padding:var(--s1) var(--s2); }
|
||||
.top td:first-child { background:linear-gradient(to right, var(--accent-soft) var(--w), transparent var(--w)); overflow-wrap:anywhere; }
|
||||
.top td.num { text-align:right; white-space:nowrap; width:1%; }
|
||||
.top th { text-align:right; font-weight:500; color:var(--text-muted); padding:var(--s1) var(--s2); }
|
||||
.top th:first-child { text-align:left; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -360,6 +382,7 @@ const ICONS = {
|
||||
incidents:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
|
||||
oncall:'<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
|
||||
monitors:'<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
||||
websites:'<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
|
||||
topics:'<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||
channels:'<path d="m22 2-7 20-4-9-9-4z"/><path d="M22 2 11 13"/>',
|
||||
status:'<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||
@@ -420,6 +443,7 @@ const PAGES = {
|
||||
incidents: ['Incidents', 'Coordinate the response when something is wrong, and keep a record of what happened.'],
|
||||
oncall: ['On-call', 'Who is responsible right now, and who is told next when nobody answers.'],
|
||||
monitors: ['Monitors', 'Checks that tell you when a website, port, certificate or scheduled job stops working.'],
|
||||
websites: ['Websites', 'Count visitors and page views on your sites, without cookies or personal data.'],
|
||||
topics: ['Topics', 'Publish and subscribe to messages with plain HTTP requests. Phones can use the ntfy app.'],
|
||||
channels: ['Channels', 'Email addresses and webhooks that receive every alert and incident update.'],
|
||||
users: ['People', 'Everyone who can sign in, and how to reach them.'],
|
||||
@@ -427,7 +451,7 @@ const PAGES = {
|
||||
};
|
||||
const NAV = [
|
||||
['Respond', [['alerts', 'Alerts', 'alerts'], ['incidents', 'Incidents', 'incidents'], ['oncall', 'On-call', 'oncall']]],
|
||||
['Observe', [['monitors', 'Monitors', 'monitors'], ['topics', 'Topics', 'topics']]],
|
||||
['Observe', [['monitors', 'Monitors', 'monitors'], ['websites', 'Websites', 'websites'], ['topics', 'Topics', 'topics']]],
|
||||
['Set up', [['channels', 'Channels', 'channels'], ['users', 'People', 'users']]],
|
||||
];
|
||||
function route() {
|
||||
@@ -754,6 +778,75 @@ function passwordDialog(u) {
|
||||
dialogForm('Set a password for ' + u.name, 'They are signed out everywhere.', [field('New password', pw, 'At least 10 characters')], 'Set password', async () => { await api('POST', `${V1}/users/${u.id}/password`, {password:pw.value}); toast('Password changed.'); });
|
||||
}
|
||||
|
||||
// ---------- websites (analytics) ----------
|
||||
const RANGES = [['24h', 'Last 24 hours'], ['7d', 'Last 7 days'], ['30d', 'Last 30 days'], ['90d', 'Last 90 days']];
|
||||
const num = n => Number(n || 0).toLocaleString();
|
||||
function regionName(code) { try { return new Intl.DisplayNames(undefined, {type:'region'}).of(code) || code; } catch (e) { return code; } }
|
||||
function languageName(code) { try { return new Intl.DisplayNames(undefined, {type:'language'}).of(code) || code; } catch (e) { return code; } }
|
||||
|
||||
views.websites = async () => {
|
||||
if (S.arg) return websiteDetail(S.arg);
|
||||
const admin = S.me.role === 'admin';
|
||||
S.headerAction = admin ? ['Add website', () => websiteDialog()] : null;
|
||||
const [sites, sum] = await Promise.all([api('GET', V1 + '/websites'), api('GET', V1 + '/website-summary')]);
|
||||
if (!sites.length) return emptyState('websites', 'No websites yet', 'Add a website, then paste one line of code into its pages. Visitors and page views show up here. No cookies are set and no personal data is kept.',
|
||||
admin ? h('button', {class:'btn', onclick:() => websiteDialog()}, 'Add website') : null);
|
||||
return table(['Website', 'Visitors, last 24 hours', 'Page views, last 24 hours', ''], sites.map(w => { const s = sum[w.id] || {};
|
||||
return h('tr', {}, h('td', {}, h('a', {href:'#websites/' + w.id}, w.name), w.domain ? h('div', {class:'small dim'}, w.domain) : null),
|
||||
h('td', {class:'num'}, num(s.visitors)), h('td', {class:'num'}, num(s.views)),
|
||||
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => trackingDialog(w)}, 'Tracking code'),
|
||||
admin ? h('button', {class:'ghost', onclick:() => websiteDialog(w)}, 'Edit') : null,
|
||||
admin ? h('button', {class:'ghost danger', onclick:ask({title:`Delete ${w.name}?`, body:'The website and all of its visits and events are removed. This cannot be undone.', confirm:'Delete website'}, async () => { await api('DELETE', `${V1}/websites/${w.id}`); toast('Website deleted.'); refresh(); })}, 'Delete') : null)));
|
||||
}));
|
||||
};
|
||||
function websiteDialog(w) {
|
||||
const e = !!w, f = { name: h('input', {value:w ? w.name : ''}), domain: h('input', {value:w ? w.domain : '', placeholder:'example.com'}) };
|
||||
dialogForm(e ? 'Edit ' + w.name : 'Add a website', null, [field('Name', f.name), field('Domain', f.domain, 'Optional. When set, visits from other domains are ignored, which stops others counting against your site.')], e ? 'Save changes' : 'Add website',
|
||||
async () => { const saved = await api(e ? 'PUT' : 'POST', e ? `${V1}/websites/${w.id}` : V1 + '/websites', {...(w || {}), name:f.name.value, domain:f.domain.value}); toast(e ? 'Website saved.' : 'Website added.'); await refresh(); if (!e) trackingDialog(saved); });
|
||||
}
|
||||
function trackingDialog(w) {
|
||||
const tag = `<script defer src="${origin()}/script.js" data-website="${w.id}"><\/script>`;
|
||||
const done = modal('Tracking code for ' + w.name, h('div', {class:'stack'},
|
||||
h('p', {class:'dim', style:'margin:0'}, 'Paste this inside the head of every page you want to count.'), cmdBox(tag),
|
||||
h('p', {class:'dim', style:'margin:0'}, 'Single-page sites are counted as people move between pages. To count something else, such as a sign-up, call this from your code:'),
|
||||
cmdBox('sandbar.track("signup", { plan: "pro" })'),
|
||||
h('p', {class:'small dim', style:'margin:0'}, 'The script sets no cookies, keeps no IP address, and does nothing when the visitor has Do Not Track on. Behind a reverse proxy, set SANDBAR_TRUST_PROXY=1 so visitors are told apart.'),
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => done()}, 'Done'))));
|
||||
}
|
||||
async function websiteDetail(id) {
|
||||
S.noHeader = true;
|
||||
const range = S.siteRange || '7d';
|
||||
const [sites, st] = await Promise.all([api('GET', V1 + '/websites'), api('GET', `${V1}/websites/${id}/stats?range=${range}`)]);
|
||||
setTimeout(() => { S.noHeader = false; });
|
||||
const w = sites.find(x => x.id === id);
|
||||
if (!w) return emptyState('websites', 'That website no longer exists', 'It may have been deleted.', h('a', {class:'btn', href:'#websites'}, 'Back to websites'));
|
||||
const tabs = h('div', {class:'tabs2', role:'group', 'aria-label':'Time range'}, RANGES.map(([k, l]) => h('button', {class:range === k ? 'on' : '', 'aria-pressed':range === k, onclick:() => { S.siteRange = k; refresh(); }}, l)));
|
||||
const tile = (n, l) => h('div', {class:'stat'}, h('div', {class:'n'}, n), h('div', {class:'l'}, l));
|
||||
const stats = h('div', {class:'stats'}, tile(num(st.visitors), 'Visitors'), tile(num(st.views), 'Page views'), tile(num(st.visits), 'Visits'),
|
||||
tile(st.bounceRate >= 0 ? Math.round(st.bounceRate) + '%' : 'No data', 'Bounce rate'), tile(num(st.online), 'Online now'));
|
||||
const max = Math.max(1, ...st.series.map(p => p.views));
|
||||
const fmt = p => new Date(p.t * 1000).toLocaleString(undefined, st.bucket === 'hour' ? {month:'short', day:'numeric', hour:'numeric'} : {month:'short', day:'numeric', year:'numeric'});
|
||||
const chart = h('div', {class:'chart', role:'img', 'aria-label':`${num(st.views)} page views from ${num(st.visitors)} visitors in the ${RANGES.find(r => r[0] === range)[1].toLowerCase()}`},
|
||||
st.series.map(p => h('div', {class:'col', title:`${fmt(p)}: ${num(p.visitors)} visitors, ${num(p.views)} page views`},
|
||||
h('div', {class:'v', style:`height:${p.views ? Math.max(2, p.views * 100 / max) : 0}%`}), h('div', {class:'u', style:`height:${p.visitors ? Math.max(2, p.visitors * 100 / max) : 0}%`}))));
|
||||
const list = (title, rows, label, nameOf) => {
|
||||
const top = Math.max(1, ...rows.map(r => r.views));
|
||||
return h('div', {class:'card'}, h('h2', {}, title), rows.length ? h('table', {class:'top'}, h('thead', {}, h('tr', {}, h('th', {scope:'col'}, label), h('th', {scope:'col'}, 'Visitors'), h('th', {scope:'col'}, 'Views'))),
|
||||
h('tbody', {}, rows.map(r => h('tr', {}, h('td', {style:`--w:${Math.round(r.views * 100 / top)}%`}, nameOf ? nameOf(r.name) : r.name), h('td', {class:'num'}, num(r.visitors)), h('td', {class:'num'}, num(r.views))))))
|
||||
: h('p', {class:'dim', style:'margin:0'}, 'Nothing yet.'));
|
||||
};
|
||||
const nothing = !st.views && !st.events.length;
|
||||
return h('div', {}, h('div', {class:'page-h'}, h('div', {}, h('a', {href:'#websites', class:'small'}, 'Back to websites'), h('h1', {}, w.name), h('p', {}, w.domain || 'Any domain')),
|
||||
h('button', {class:'ghost', onclick:() => trackingDialog(w)}, 'Tracking code')),
|
||||
tabs, h('div', {style:'height:16px'}), stats,
|
||||
nothing ? emptyState('websites', 'No visits in this period', 'Paste the tracking code into your pages, then open one. The first visit appears within a few seconds.', h('button', {class:'btn', onclick:() => trackingDialog(w)}, 'Show tracking code')) : h('div', {},
|
||||
card('Visitors and page views', h('div', {class:'legend'}, h('span', {}, h('i', {style:'background:var(--accent)'}), 'Visitors'), h('span', {}, h('i', {style:'background:color-mix(in srgb, var(--accent) 35%, var(--surface))'}), 'Page views')), chart,
|
||||
h('div', {class:'axis2', 'aria-hidden':'true'}, h('span', {}, fmt(st.series[0])), h('span', {}, 'Now'))),
|
||||
h('div', {class:'cols'}, list('Pages', st.pages, 'Path'), list('Referrers', st.referrers, 'Source'), list('Browsers', st.browsers, 'Browser'), list('Operating systems', st.systems, 'System'),
|
||||
list('Devices', st.devices, 'Device'), list('Countries', st.countries, 'Country', n => n.length === 2 ? regionName(n) : n), list('Languages', st.languages, 'Language', n => n.length <= 3 ? languageName(n) : n),
|
||||
list('Events', st.events, 'Event'))));
|
||||
}
|
||||
|
||||
// ---------- account ----------
|
||||
// Administrators see which release is running and can install a newer one. The server restarts
|
||||
// into the new binary, so the page waits for it to come back.
|
||||
|
||||
Reference in new issue
Block a user