update viewers page
This commit is contained in:
+102
-7
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user