update viewers page

This commit is contained in:
2026-05-16 07:36:49 +01:00
parent 712acd2348
commit eec70b39aa
2 changed files with 426 additions and 9 deletions
+102 -7
View File
@@ -172,6 +172,7 @@ function ensureAnalyticsDb() {
screen text not null default '', screen text not null default '',
language text not null default '', language text not null default '',
timezone text not null default '', timezone text not null default '',
country text not null default '',
consent text not null default 'analytics', consent text not null default 'analytics',
metadata text not null default '{}' metadata text not null default '{}'
); );
@@ -191,7 +192,8 @@ function ensureAnalyticsDb() {
device text not null default 'Desktop', device text not null default 'Desktop',
screen text not null default '', screen text not null default '',
language text not null default '', language text not null default '',
timezone text not null default '' timezone text not null default '',
country text not null default ''
); );
create index if not exists viewer_events_occurred_at_idx create index if not exists viewer_events_occurred_at_idx
@@ -204,6 +206,17 @@ function ensureAnalyticsDb() {
on active_viewers (last_seen_at desc); on active_viewers (last_seen_at desc);
`) `)
for (const statement of [
`alter table viewer_events add column country text not null default ''`,
`alter table active_viewers add column country text not null default ''`,
]) {
try {
analyticsDb.exec(statement)
} catch (error) {
if (!String(error.message || '').includes('duplicate column name')) throw error
}
}
return analyticsDb return analyticsDb
} }
@@ -393,6 +406,17 @@ function headerValue(req, name, maxLength = 200) {
return sanitizeText(value, maxLength) return sanitizeText(value, maxLength)
} }
function countryFromHeaders(req) {
const raw =
headerValue(req, 'cf-ipcountry', 12) ||
headerValue(req, 'x-vercel-ip-country', 12) ||
headerValue(req, 'x-appengine-country', 12) ||
headerValue(req, 'cloudfront-viewer-country', 12)
const country = raw.toUpperCase()
if (!/^[A-Z]{2}$/.test(country) || country === 'XX') return ''
return country
}
function analyticsMetadata(req, payload) { function analyticsMetadata(req, payload) {
const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {} const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {}
const preferences = metadata.preferences && typeof metadata.preferences === 'object' const preferences = metadata.preferences && typeof metadata.preferences === 'object'
@@ -513,6 +537,7 @@ function recordViewerEvent(req, payload) {
screen: sanitizeText(payload.screen, 40), screen: sanitizeText(payload.screen, 40),
language: sanitizeText(payload.language, 40), language: sanitizeText(payload.language, 40),
timezone: sanitizeText(payload.timezone, 80), timezone: sanitizeText(payload.timezone, 80),
country: countryFromHeaders(req),
consent: payload.consent === 'analytics' ? 'analytics' : '', consent: payload.consent === 'analytics' ? 'analytics' : '',
metadata: JSON.stringify(analyticsMetadata(req, payload)), metadata: JSON.stringify(analyticsMetadata(req, payload)),
} }
@@ -525,19 +550,19 @@ function recordViewerEvent(req, payload) {
db.prepare(` db.prepare(`
insert into viewer_events insert into viewer_events
(occurred_at, visitor_id, session_id, ip_hash, event_type, page_path, page_title, (occurred_at, visitor_id, session_id, ip_hash, event_type, page_path, page_title,
referrer, user_agent, browser, os, device, screen, language, timezone, consent, metadata) referrer, user_agent, browser, os, device, screen, language, timezone, country, consent, metadata)
values values
(@occurred_at, @visitor_id, @session_id, @ip_hash, @event_type, @page_path, @page_title, (@occurred_at, @visitor_id, @session_id, @ip_hash, @event_type, @page_path, @page_title,
@referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, @consent, @metadata) @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, @country, @consent, @metadata)
`).run({ ...event, occurred_at: now }) `).run({ ...event, occurred_at: now })
db.prepare(` db.prepare(`
insert into active_viewers insert into active_viewers
(session_id, visitor_id, ip_hash, first_seen_at, last_seen_at, page_path, page_title, (session_id, visitor_id, ip_hash, first_seen_at, last_seen_at, page_path, page_title,
referrer, user_agent, browser, os, device, screen, language, timezone) referrer, user_agent, browser, os, device, screen, language, timezone, country)
values values
(@session_id, @visitor_id, @ip_hash, @now, @now, @page_path, @page_title, (@session_id, @visitor_id, @ip_hash, @now, @now, @page_path, @page_title,
@referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone) @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, @country)
on conflict(session_id) do update set on conflict(session_id) do update set
last_seen_at = excluded.last_seen_at, last_seen_at = excluded.last_seen_at,
page_path = excluded.page_path, page_path = excluded.page_path,
@@ -549,7 +574,8 @@ function recordViewerEvent(req, payload) {
device = excluded.device, device = excluded.device,
screen = excluded.screen, screen = excluded.screen,
language = excluded.language, language = excluded.language,
timezone = excluded.timezone timezone = excluded.timezone,
country = excluded.country
`).run({ ...event, now }) `).run({ ...event, now })
} }
@@ -589,7 +615,7 @@ function viewerDashboard() {
const activeSince = `-${ANALYTICS_ACTIVE_WINDOW_SECONDS} seconds` const activeSince = `-${ANALYTICS_ACTIVE_WINDOW_SECONDS} seconds`
const active = db.prepare(` const active = db.prepare(`
select session_id, visitor_id, first_seen_at, last_seen_at, page_path, page_title, select session_id, visitor_id, first_seen_at, last_seen_at, page_path, page_title,
referrer, browser, os, device, screen, language, timezone referrer, browser, os, device, screen, language, timezone, country
from active_viewers from active_viewers
where last_seen_at >= datetime('now', ?) where last_seen_at >= datetime('now', ?)
order by last_seen_at desc order by last_seen_at desc
@@ -608,6 +634,7 @@ function viewerDashboard() {
screen: row.screen, screen: row.screen,
language: row.language, language: row.language,
timezone: row.timezone, timezone: row.timezone,
country: row.country,
})) }))
const topPages = db.prepare(` const topPages = db.prepare(`
@@ -629,6 +656,47 @@ function viewerDashboard() {
limit 12 limit 12
`).all() `).all()
const clients30d = db.prepare(`
select browser, os, device, count(*) as events, count(distinct visitor_id) as visitors
from viewer_events
where occurred_at >= datetime('now', '-30 days')
group by browser, os, device
order by events desc
limit 12
`).all()
const activity30d = db.prepare(`
select
date(occurred_at) as date,
count(*) as events,
count(distinct visitor_id) as visitors,
sum(case when event_type = 'page_view' then 1 else 0 end) as page_views
from viewer_events
where occurred_at >= datetime('now', '-30 days')
group by date(occurred_at)
order by date asc
`).all()
const countries = db.prepare(`
select country, count(*) as events, count(distinct visitor_id) as visitors
from viewer_events
where occurred_at >= datetime('now', '-30 days')
and country != ''
group by country
order by visitors desc, events desc
limit 80
`).all()
const locations = db.prepare(`
select country, timezone, language, count(*) as events, count(distinct visitor_id) as visitors
from viewer_events
where occurred_at >= datetime('now', '-30 days')
and (country != '' or (timezone != '' and timezone != 'Not shared'))
group by country, timezone, language
order by visitors desc, events desc
limit 32
`).all()
const totals = db.prepare(` const totals = db.prepare(`
select select
count(*) as events_24h, count(*) as events_24h,
@@ -638,22 +706,49 @@ function viewerDashboard() {
where occurred_at >= datetime('now', '-24 hours') where occurred_at >= datetime('now', '-24 hours')
`).get() `).get()
const totals30d = db.prepare(`
select
count(*) as events_30d,
count(distinct visitor_id) as visitors_30d,
count(distinct session_id) as sessions_30d,
sum(case when event_type = 'page_view' then 1 else 0 end) as page_views_30d
from viewer_events
where occurred_at >= datetime('now', '-30 days')
`).get()
return { return {
active_window_seconds: ANALYTICS_ACTIVE_WINDOW_SECONDS, active_window_seconds: ANALYTICS_ACTIVE_WINDOW_SECONDS,
generated_at: new Date().toISOString(), generated_at: new Date().toISOString(),
active, active,
top_pages: topPages, top_pages: topPages,
clients, clients,
clients_30d: clients30d,
activity_30d: activity30d,
countries,
locations,
totals: { totals: {
active_now: active.length, active_now: active.length,
events_24h: totals?.events_24h || 0, events_24h: totals?.events_24h || 0,
visitors_24h: totals?.visitors_24h || 0, visitors_24h: totals?.visitors_24h || 0,
page_views_24h: totals?.page_views_24h || 0, page_views_24h: totals?.page_views_24h || 0,
events_30d: totals30d?.events_30d || 0,
visitors_30d: totals30d?.visitors_30d || 0,
sessions_30d: totals30d?.sessions_30d || 0,
page_views_30d: totals30d?.page_views_30d || 0,
}, },
data_types: [
{ key: 'page', label: 'Page activity', detail: 'Page path, title, page views, and heartbeat state' },
{ key: 'browser', label: 'Browser and device', detail: 'Browser, operating system, broad device type, and user-agent only when allowed' },
{ key: 'display', label: 'Display', detail: 'Screen size, viewport size, pixel ratio, and colour depth when allowed' },
{ key: 'locale', label: 'Language and coarse location', detail: 'Browser language and timezone when allowed, plus country code when supplied by the hosting edge' },
{ key: 'referrer', label: 'Referrer', detail: 'The referring page when allowed and provided by the browser' },
{ key: 'diagnostics', label: 'Diagnostics', detail: 'Privacy signals, network hints, request headers, and browser capability details when allowed' },
],
privacy: { privacy: {
retention_days: ANALYTICS_RETENTION_DAYS, retention_days: ANALYTICS_RETENTION_DAYS,
stores_ip_hashes: false, stores_ip_hashes: false,
exposes_raw_ip: false, exposes_raw_ip: false,
exposes_precise_location: false,
}, },
} }
} }
+324 -2
View File
@@ -2004,12 +2004,132 @@ function relativeSeconds(timestamp) {
return `${Math.round(seconds / 60)}m ago` return `${Math.round(seconds / 60)}m ago`
} }
const shortDateFormat = new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
})
const timezoneMapPoints = {
'America/Los_Angeles': [18, 42],
'America/Denver': [24, 43],
'America/Chicago': [30, 45],
'America/New_York': [37, 43],
'America/Toronto': [38, 40],
'America/Sao_Paulo': [43, 68],
'America/Mexico_City': [27, 54],
'Europe/London': [48, 36],
'Europe/Paris': [51, 39],
'Europe/Berlin': [53, 37],
'Europe/Madrid': [49, 42],
'Europe/Rome': [53, 43],
'Europe/Amsterdam': [51, 37],
'Europe/Stockholm': [54, 31],
'Europe/Warsaw': [56, 37],
'Europe/Moscow': [63, 34],
'Africa/Cairo': [58, 49],
'Africa/Johannesburg': [58, 75],
'Asia/Dubai': [66, 52],
'Asia/Kolkata': [72, 56],
'Asia/Singapore': [79, 66],
'Asia/Tokyo': [88, 45],
'Asia/Seoul': [85, 44],
'Asia/Shanghai': [81, 48],
'Australia/Sydney': [90, 78],
'Pacific/Auckland': [95, 84],
}
const countryNames = {
AR: 'Argentina',
AU: 'Australia',
BR: 'Brazil',
CA: 'Canada',
CL: 'Chile',
CN: 'China',
DE: 'Germany',
ES: 'Spain',
FR: 'France',
GB: 'United Kingdom',
ID: 'Indonesia',
IN: 'India',
IT: 'Italy',
JP: 'Japan',
KR: 'South Korea',
MX: 'Mexico',
NL: 'Netherlands',
PL: 'Poland',
RU: 'Russia',
SE: 'Sweden',
SG: 'Singapore',
US: 'United States',
ZA: 'South Africa',
}
const countryMapPoints = {
AR: [-64, -34],
AU: [134, -25],
BR: [-51, -10],
CA: [-106, 56],
CL: [-71, -30],
CN: [104, 35],
DE: [10, 51],
ES: [-4, 40],
FR: [2, 46],
GB: [-2, 54],
ID: [118, -2],
IN: [78, 22],
IT: [12, 43],
JP: [138, 37],
KR: [128, 36],
MX: [-102, 23],
NL: [5, 52],
PL: [19, 52],
RU: [90, 60],
SE: [15, 62],
SG: [104, 1],
US: [-98, 39],
ZA: [24, -29],
}
function geoPoint(lon, lat) {
return [
((lon + 180) / 360) * 100,
((85 - lat) / 145) * 100,
]
}
function timezonePoint(timezone = '') {
if (timezoneMapPoints[timezone]) return timezoneMapPoints[timezone]
if (timezone.startsWith('America/')) return [30, 48]
if (timezone.startsWith('Europe/')) return [53, 38]
if (timezone.startsWith('Africa/')) return [56, 60]
if (timezone.startsWith('Asia/')) return [75, 52]
if (timezone.startsWith('Australia/')) return [88, 76]
if (timezone.startsWith('Pacific/')) return [92, 70]
return [50, 50]
}
function filledLast30Days(rows) {
const byDate = new Map(rows.map((row) => [row.date, row]))
return Array.from({ length: 30 }, (_, index) => {
const date = new Date()
date.setDate(date.getDate() - (29 - index))
const key = date.toISOString().slice(0, 10)
return byDate.get(key) || { date: key, events: 0, visitors: 0, page_views: 0 }
})
}
function ViewersPage({ viewers }) { function ViewersPage({ viewers }) {
const data = viewers.data || {} const data = viewers.data || {}
const active = data.active || [] const active = data.active || []
const topPages = data.top_pages || [] const topPages = data.top_pages || []
const clients = data.clients || [] const clients = data.clients || []
const clients30d = data.clients_30d || []
const activity30d = filledLast30Days(data.activity_30d || [])
const countries = data.countries || []
const locations = data.locations || []
const dataTypes = data.data_types || []
const totals = data.totals || {} const totals = data.totals || {}
const maxDailyEvents = Math.max(1, ...activity30d.map((day) => Number(day.events || 0)))
const generatedAt = data.generated_at ? dateFormat.format(new Date(data.generated_at)) : 'Waiting for data' const generatedAt = data.generated_at ? dateFormat.format(new Date(data.generated_at)) : 'Waiting for data'
return ( return (
@@ -2038,6 +2158,47 @@ function ViewersPage({ viewers }) {
<Stat label="Events 24h" value={formatNumber(totals.events_24h)} /> <Stat label="Events 24h" value={formatNumber(totals.events_24h)} />
</div> </div>
<div className="rounded-lg border border-border bg-fury-white p-5 shadow-sm">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="text-lg font-semibold">Last 30 days</h2>
<p className="mt-1 text-sm text-text-soft">
Retained consented analytics over the current privacy window
</p>
</div>
<div className="grid gap-3 text-sm sm:grid-cols-4">
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
{formatNumber(totals.visitors_30d)} visitors
</span>
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
{formatNumber(totals.sessions_30d)} sessions
</span>
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
{formatNumber(totals.page_views_30d)} page views
</span>
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
{formatNumber(totals.events_30d)} events
</span>
</div>
</div>
<div className="mt-5 flex h-36 items-end gap-1 rounded-md border border-border bg-bg p-3">
{activity30d.map((day) => {
const height = `${Math.max(8, (Number(day.events || 0) / maxDailyEvents) * 100)}%`
return (
<div
aria-label={`${formatNumber(day.events)} events on ${day.date}`}
className="min-w-1 flex-1 rounded-sm bg-fury-cyan transition hover:bg-fury-aqua"
key={day.date}
style={{ height }}
title={`${shortDateFormat.format(new Date(day.date))}: ${formatNumber(day.events)} events, ${formatNumber(day.visitors)} visitors`}
/>
)
})}
</div>
</div>
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm"> <div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4"> <div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Currently viewing</h2> <h2 className="text-lg font-semibold">Currently viewing</h2>
@@ -2059,7 +2220,7 @@ function ViewersPage({ viewers }) {
{viewer.browser} on {viewer.os} {viewer.browser} on {viewer.os}
</p> </p>
<p className="truncate text-xs text-text-soft"> <p className="truncate text-xs text-text-soft">
{viewer.device} · {viewer.screen || 'unknown screen'} · {viewer.timezone || 'unknown timezone'} {viewer.device} · {viewer.screen || 'unknown screen'} · {viewer.country || viewer.timezone || 'unknown location'}
</p> </p>
</div> </div>
<p className="text-text-soft">{viewer.language || 'unknown language'}</p> <p className="text-text-soft">{viewer.language || 'unknown language'}</p>
@@ -2113,14 +2274,175 @@ function ViewersPage({ viewers }) {
</div> </div>
</div> </div>
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Collected data types</h2>
<p className="mt-1 text-sm text-text-soft">
Optional fields only appear for visitors who explicitly allow them
</p>
</div>
{dataTypes.map((item) => (
<div className="border-b border-surface px-5 py-3 text-sm" key={item.key}>
<p className="font-semibold">{item.label}</p>
<p className="mt-1 text-text-soft">{item.detail}</p>
</div>
))}
{!dataTypes.length ? <p className="px-5 py-10 text-sm text-text-soft">No collection manifest returned</p> : null}
</div>
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Location signals</h2>
<p className="mt-1 text-sm text-text-soft">
Country traffic when available, with timezone fallback from the last 30 days
</p>
</div>
<LocationSignalMap countries={countries} locations={locations} />
</div>
</div>
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Clients over 30 days</h2>
<p className="mt-1 text-sm text-text-soft">Browsers, devices, and operating systems across retained data</p>
</div>
{clients30d.map((client) => (
<div
className="grid grid-cols-[1fr_auto_auto] gap-3 border-b border-surface px-5 py-3 text-sm"
key={`${client.browser}-${client.os}-${client.device}-30d`}
>
<div className="min-w-0">
<p className="truncate font-semibold">{client.browser} on {client.os}</p>
<p className="truncate text-xs text-text-soft">{client.device}</p>
</div>
<p className="text-text-soft">{formatNumber(client.visitors)} visitors</p>
<p className="font-semibold text-fury-cyan">{formatNumber(client.events)} events</p>
</div>
))}
{!clients30d.length ? <p className="px-5 py-10 text-sm text-text-soft">No 30-day client data recorded yet</p> : null}
</div>
<p className="text-xs text-text-soft"> <p className="text-xs text-text-soft">
Analytics are opt-in, retained for {formatNumber(data.privacy?.retention_days || 30)} days, Analytics are opt-in, retained for {formatNumber(data.privacy?.retention_days || 30)} days,
and public output excludes raw IP addresses. and public output excludes raw IP addresses and precise location.
</p> </p>
</section> </section>
) )
} }
function LocationSignalMap({ countries, locations }) {
const maxCountryVisitors = Math.max(1, ...countries.map((country) => Number(country.visitors || 0)))
const maxLocationVisitors = Math.max(1, ...locations.map((location) => Number(location.visitors || 0)))
const fallbackLocations = locations.filter((location) => !location.country)
const countryMarkers = countries
.map((country) => {
const coordinates = countryMapPoints[country.country]
if (!coordinates) return null
const [left, top] = geoPoint(coordinates[0], coordinates[1])
return {
...country,
left,
top,
label: countryNames[country.country] || country.country,
}
})
.filter(Boolean)
const topLocations = countries.length
? countries.slice(0, 8).map((country) => ({
key: country.country,
label: countryNames[country.country] || country.country,
detail: 'country signal',
visitors: country.visitors,
events: country.events,
}))
: locations.slice(0, 8).map((location) => ({
key: `${location.timezone}-${location.language}`,
label: location.timezone,
detail: location.language || 'unknown language',
visitors: location.visitors,
events: location.events,
}))
return (
<div className="p-5">
<div className="relative h-[340px] overflow-hidden rounded-md border border-border bg-surface">
<iframe
className="absolute inset-0 h-full w-full grayscale-[20%] saturate-[0.85]"
loading="lazy"
referrerPolicy="no-referrer"
src="https://www.openstreetmap.org/export/embed.html?bbox=-180%2C-58%2C180%2C84&layer=mapnik"
title="OpenStreetMap traffic overview"
/>
<div className="pointer-events-none absolute inset-0 bg-fury-white/10" />
{countryMarkers.map((country) => {
const size = 16 + (Number(country.visitors || 0) / maxCountryVisitors) * 28
return (
<div
className="group pointer-events-auto absolute -translate-x-1/2 -translate-y-1/2"
key={country.country}
style={{ left: `${country.left}%`, top: `${country.top}%` }}
>
<span
className="block rounded-full border-2 border-fury-white bg-fury-cyan shadow-[0_4px_18px_rgba(232,37,23,0.35)]"
style={{ height: size, width: size }}
/>
<span className="absolute left-1/2 top-full z-10 mt-2 hidden w-max max-w-44 -translate-x-1/2 rounded-md bg-text px-2 py-1 text-xs font-semibold text-bg shadow-lg group-hover:block">
{country.label}: {formatNumber(country.visitors)} visitors
</span>
</div>
)
})}
{fallbackLocations.map((location) => {
const [x, y] = timezonePoint(location.timezone)
const radius = 5 + (Number(location.visitors || 0) / maxLocationVisitors) * 12
return (
<div
className="group pointer-events-auto absolute -translate-x-1/2 -translate-y-1/2"
key={`${location.timezone}-${location.language}`}
style={{ left: `${x}%`, top: `${y}%` }}
>
<span
className="block rounded-full border-2 border-fury-white bg-text shadow-[0_4px_18px_rgba(0,0,0,0.2)]"
style={{ height: radius * 2, width: radius * 2 }}
/>
<span className="absolute left-1/2 top-full z-10 mt-2 hidden w-max max-w-52 -translate-x-1/2 rounded-md bg-text px-2 py-1 text-xs font-semibold text-bg shadow-lg group-hover:block">
{location.timezone}: {formatNumber(location.visitors)} visitors
</span>
</div>
)
})}
<div className="pointer-events-none absolute right-3 bottom-3 rounded-sm bg-fury-white/95 px-2 py-1 text-[10px] font-semibold text-text-soft shadow-sm">
© OpenStreetMap contributors
</div>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2">
{topLocations.map((location) => (
<div className="grid grid-cols-[1fr_auto] gap-3 rounded-md bg-surface px-3 py-2 text-sm" key={`${location.key}-row`}>
<div className="min-w-0">
<p className="truncate font-semibold">{location.label}</p>
<p className="truncate text-xs text-text-soft">{location.detail}</p>
</div>
<p className="font-semibold text-fury-cyan">{formatNumber(location.visitors)}</p>
</div>
))}
</div>
{!countries.length && !locations.length ? (
<p className="mt-4 text-sm text-text-soft">
No country or timezone location signals have been shared yet.
</p>
) : null}
</div>
)
}
function UptimePage({ uptime }) { function UptimePage({ uptime }) {
const checks = uptime.checks const checks = uptime.checks
const history = uptime.history const history = uptime.history