update viewers page

This commit is contained in:
2026-05-16 07:42:53 +01:00
parent eec70b39aa
commit a586282b3f
4 changed files with 305 additions and 100 deletions
+7
View File
@@ -11,6 +11,7 @@
"@tailwindcss/vite": "^4.1.8",
"@vitejs/plugin-react": "^4.5.0",
"better-sqlite3": "^12.10.0",
"leaflet": "^1.9.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"vite": "^6.3.5"
@@ -2715,6 +2716,12 @@
"json-buffer": "3.0.1"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+1
View File
@@ -15,6 +15,7 @@
"@tailwindcss/vite": "^4.1.8",
"@vitejs/plugin-react": "^4.5.0",
"better-sqlite3": "^12.10.0",
"leaflet": "^1.9.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"vite": "^6.3.5"
+74 -23
View File
@@ -500,15 +500,18 @@ function readJsonBody(req) {
}
function purgeOldAnalytics(db) {
const eventCutoff = new Date(Date.now() - ANALYTICS_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString()
const activeCutoff = new Date(Date.now() - ANALYTICS_ACTIVE_WINDOW_SECONDS * 3 * 1000).toISOString()
db.prepare(`
delete from viewer_events
where occurred_at < datetime('now', ?)
`).run(`-${ANALYTICS_RETENTION_DAYS} days`)
where occurred_at < ?
`).run(eventCutoff)
db.prepare(`
delete from active_viewers
where last_seen_at < datetime('now', ?)
`).run(`-${ANALYTICS_ACTIVE_WINDOW_SECONDS * 3} seconds`)
where last_seen_at < ?
`).run(activeCutoff)
}
function recordViewerEvent(req, payload) {
@@ -612,12 +615,14 @@ function viewerDashboard() {
const db = ensureAnalyticsDb()
purgeOldAnalytics(db)
const activeSince = `-${ANALYTICS_ACTIVE_WINDOW_SECONDS} seconds`
const activeSince = new Date(Date.now() - ANALYTICS_ACTIVE_WINDOW_SECONDS * 1000).toISOString()
const daySince = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
const thirtyDaysSince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
const active = db.prepare(`
select session_id, visitor_id, first_seen_at, last_seen_at, page_path, page_title,
referrer, browser, os, device, screen, language, timezone, country
from active_viewers
where last_seen_at >= datetime('now', ?)
where last_seen_at >= ?
order by last_seen_at desc
limit 100
`).all(activeSince).map((row) => ({
@@ -637,65 +642,110 @@ function viewerDashboard() {
country: row.country,
}))
const activePageMap = new Map()
for (const viewer of active) {
const key = `${viewer.page_path}|${viewer.page_title}`
const existing = activePageMap.get(key) || {
page_path: viewer.page_path,
page_title: viewer.page_title,
viewers: 0,
visitors: new Set(),
clients: new Map(),
countries: new Set(),
last_seen_at: viewer.last_seen_at,
}
existing.viewers += 1
existing.visitors.add(viewer.visitor)
if (viewer.country) existing.countries.add(viewer.country)
const clientKey = `${viewer.browser} on ${viewer.os}`
existing.clients.set(clientKey, (existing.clients.get(clientKey) || 0) + 1)
if (new Date(viewer.last_seen_at).getTime() > new Date(existing.last_seen_at).getTime()) {
existing.last_seen_at = viewer.last_seen_at
}
activePageMap.set(key, existing)
}
const activePages = Array.from(activePageMap.values())
.map((page) => ({
page_path: page.page_path,
page_title: page.page_title,
viewers: page.viewers,
visitors: page.visitors.size,
countries: Array.from(page.countries).sort(),
clients: Array.from(page.clients.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 4)
.map(([label, count]) => ({ label, count })),
last_seen_at: page.last_seen_at,
}))
.sort((a, b) => b.viewers - a.viewers || new Date(b.last_seen_at) - new Date(a.last_seen_at))
const topPages = db.prepare(`
select page_path, page_title, count(*) as views
from viewer_events
where event_type = 'page_view'
and occurred_at >= datetime('now', '-24 hours')
and occurred_at >= ?
group by page_path, page_title
order by views desc, page_path asc
limit 12
`).all()
`).all(daySince)
const clients = db.prepare(`
select browser, os, device, count(*) as events
from viewer_events
where occurred_at >= datetime('now', '-24 hours')
where occurred_at >= ?
group by browser, os, device
order by events desc
limit 12
`).all()
`).all(daySince)
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')
where occurred_at >= ?
group by browser, os, device
order by events desc
limit 12
`).all()
`).all(thirtyDaysSince)
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
sum(case when event_type = 'page_view' then 1 else 0 end) as page_views,
count(distinct browser || '|' || os || '|' || device) as clients,
count(distinct case
when country != '' then country
when timezone != '' and timezone != 'Not shared' then timezone
else null
end) as locations
from viewer_events
where occurred_at >= datetime('now', '-30 days')
where occurred_at >= ?
group by date(occurred_at)
order by date asc
`).all()
`).all(thirtyDaysSince)
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')
where occurred_at >= ?
and country != ''
group by country
order by visitors desc, events desc
limit 80
`).all()
`).all(thirtyDaysSince)
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')
where occurred_at >= ?
and (country != '' or (timezone != '' and timezone != 'Not shared'))
group by country, timezone, language
order by visitors desc, events desc
limit 32
`).all()
`).all(thirtyDaysSince)
const totals = db.prepare(`
select
@@ -703,8 +753,8 @@ function viewerDashboard() {
count(distinct visitor_id) as visitors_24h,
sum(case when event_type = 'page_view' then 1 else 0 end) as page_views_24h
from viewer_events
where occurred_at >= datetime('now', '-24 hours')
`).get()
where occurred_at >= ?
`).get(daySince)
const totals30d = db.prepare(`
select
@@ -713,13 +763,14 @@ function viewerDashboard() {
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()
where occurred_at >= ?
`).get(thirtyDaysSince)
return {
active_window_seconds: ANALYTICS_ACTIVE_WINDOW_SECONDS,
generated_at: new Date().toISOString(),
active,
active_pages: activePages,
top_pages: topPages,
clients,
clients_30d: clients30d,
+221 -75
View File
@@ -1,4 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import Tree from '../Tree/Tree'
import FallingLeaves from '../Tree/FallingLeaves'
@@ -2090,10 +2092,10 @@ const countryMapPoints = {
ZA: [24, -29],
}
function geoPoint(lon, lat) {
function mapPointFromPercent(x, y) {
return [
((lon + 180) / 360) * 100,
((85 - lat) / 145) * 100,
85 - (y / 100) * 145,
(x / 100) * 360 - 180,
]
}
@@ -2114,13 +2116,68 @@ function filledLast30Days(rows) {
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 }
return byDate.get(key) || {
date: key,
events: 0,
visitors: 0,
page_views: 0,
clients: 0,
locations: 0,
}
})
}
function MiniLineChart({ accent = 'text-fury-cyan', data, label, metric, stroke = '#e82517' }) {
const width = 320
const height = 112
const padding = 12
const maxValue = Math.max(1, ...data.map((item) => Number(item[metric] || 0)))
const points = data.map((item, index) => {
const x = padding + (index / Math.max(1, data.length - 1)) * (width - padding * 2)
const y = height - padding - (Number(item[metric] || 0) / maxValue) * (height - padding * 2)
return { ...item, x, y, value: Number(item[metric] || 0) }
})
const pathData = points
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`)
.join(' ')
const latest = points.at(-1)?.value || 0
return (
<div className="rounded-md border border-border bg-bg p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold">{label}</p>
<p className="mt-1 text-xs text-text-soft">Daily count</p>
</div>
<p className={`text-sm font-semibold ${accent}`}>{formatNumber(latest)}</p>
</div>
<svg className="mt-3 h-28 w-full overflow-visible" viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`${label} line chart`}>
<path d={`M ${padding} ${height - padding} H ${width - padding}`} fill="none" stroke="#fee5cd" strokeWidth="1" />
<path d={pathData} fill="none" stroke={stroke} strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" />
{points.map((point) => (
<circle
cx={point.x}
cy={point.y}
fill={stroke}
key={`${metric}-${point.date}`}
r="4"
tabIndex="0"
>
<title>
{`${shortDateFormat.format(new Date(point.date))}: ${formatNumber(point.value)} ${label.toLowerCase()}, ${formatNumber(point.visitors || 0)} visitors`}
</title>
</circle>
))}
</svg>
</div>
)
}
function ViewersPage({ viewers }) {
const data = viewers.data || {}
const active = data.active || []
const activePages = data.active_pages || []
const topPages = data.top_pages || []
const clients = data.clients || []
const clients30d = data.clients_30d || []
@@ -2129,7 +2186,6 @@ function ViewersPage({ viewers }) {
const locations = data.locations || []
const dataTypes = data.data_types || []
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'
return (
@@ -2182,28 +2238,98 @@ function ViewersPage({ viewers }) {
</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 className="mt-5 grid gap-4 lg:grid-cols-2 xl:grid-cols-5">
<MiniLineChart
data={activity30d}
label="Events"
metric="events"
stroke="#e82517"
/>
<MiniLineChart
accent="text-fury-violet"
data={activity30d}
label="Visitors"
metric="visitors"
stroke="#fb7b04"
/>
<MiniLineChart
accent="text-fury-cyan"
data={activity30d}
label="Page views"
metric="page_views"
stroke="#ed5145"
/>
<MiniLineChart
accent="text-text"
data={activity30d}
label="Clients"
metric="clients"
stroke="#000000"
/>
<MiniLineChart
accent="text-fury-cyan"
data={activity30d}
label="Locations"
metric="locations"
stroke="#009ccc"
/>
)
})}
</div>
</div>
<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="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-lg font-semibold">Currently viewing</h2>
<p className="mt-1 text-sm text-text-soft">
Heartbeats within {formatNumber(data.active_window_seconds || 75)} seconds
Pages with active heartbeats within {formatNumber(data.active_window_seconds || 75)} seconds
</p>
</div>
<p className="text-sm font-semibold text-fury-cyan">
{formatNumber(active.length)} active sessions
</p>
</div>
</div>
{activePages.map((page) => (
<div
className="grid gap-4 border-b border-surface px-5 py-4 text-sm lg:grid-cols-[1fr_auto_auto]"
key={`${page.page_path}-${page.page_title}`}
>
<div className="min-w-0">
<p className="truncate text-lg font-semibold">{page.page_title || page.page_path}</p>
<p className="truncate text-xs text-text-soft">{page.page_path}</p>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-text-soft">
{(page.clients || []).map((client) => (
<span className="rounded-md bg-surface px-2 py-1" key={client.label}>
{client.label} ({formatNumber(client.count)})
</span>
))}
{(page.countries || []).map((country) => (
<span className="rounded-md bg-surface px-2 py-1" key={country}>
{country}
</span>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-right sm:grid-cols-1">
<p className="font-semibold text-fury-cyan">{formatNumber(page.viewers)} viewing</p>
<p className="text-text-soft">{formatNumber(page.visitors)} visitors</p>
</div>
<p className="text-right text-text-soft">Seen {relativeSeconds(page.last_seen_at)}</p>
</div>
))}
{!activePages.length ? (
<p className="px-5 py-10 text-sm text-text-soft">
{viewers.error || 'No pages are actively being viewed right now'}
</p>
) : null}
</div>
<div className="overflow-hidden 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">Active sessions</h2>
<p className="mt-1 text-sm text-text-soft">
Individual sessions currently contributing to the active page counts
</p>
</div>
{active.map((viewer) => (
@@ -2332,6 +2458,8 @@ function ViewersPage({ viewers }) {
}
function LocationSignalMap({ countries, locations }) {
const mapRef = useRef(null)
const markersRef = useRef(null)
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)
@@ -2339,11 +2467,10 @@ function LocationSignalMap({ countries, locations }) {
.map((country) => {
const coordinates = countryMapPoints[country.country]
if (!coordinates) return null
const [left, top] = geoPoint(coordinates[0], coordinates[1])
return {
...country,
left,
top,
lat: coordinates[1],
lon: coordinates[0],
label: countryNames[country.country] || country.country,
}
})
@@ -2364,62 +2491,81 @@ function LocationSignalMap({ countries, locations }) {
events: location.events,
}))
useEffect(() => {
if (!mapRef.current || markersRef.current) return undefined
const map = L.map(mapRef.current, {
center: [25, 0],
maxBounds: [[-85, -220], [85, 220]],
minZoom: 1,
scrollWheelZoom: true,
worldCopyJump: true,
zoom: 1,
})
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 8,
}).addTo(map)
markersRef.current = {
layer: L.layerGroup().addTo(map),
map,
}
return () => {
markersRef.current = null
map.remove()
}
}, [])
useEffect(() => {
if (!markersRef.current) return
const { layer } = markersRef.current
layer.clearLayers()
countryMarkers.forEach((country) => {
const radius = 10 + (Number(country.visitors || 0) / maxCountryVisitors) * 22
L.circleMarker([country.lat, country.lon], {
color: '#e82517',
fillColor: '#e82517',
fillOpacity: 0.4,
opacity: 0,
radius,
stroke: false,
})
.bindTooltip(`${country.label}: ${formatNumber(country.visitors)} visitors`, {
direction: 'top',
opacity: 0.95,
})
.addTo(layer)
})
fallbackLocations.forEach((location) => {
const [x, y] = timezonePoint(location.timezone)
const [lat, lon] = mapPointFromPercent(x, y)
const radius = 7 + (Number(location.visitors || 0) / maxLocationVisitors) * 16
L.circleMarker([lat, lon], {
color: '#000000',
fillColor: '#000000',
fillOpacity: 0.4,
opacity: 0,
radius,
stroke: false,
})
.bindTooltip(`${location.timezone}: ${formatNumber(location.visitors)} visitors`, {
direction: 'top',
opacity: 0.95,
})
.addTo(layer)
})
}, [countryMarkers, fallbackLocations, maxCountryVisitors, maxLocationVisitors])
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 ref={mapRef} className="h-full w-full" />
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2">