update viewers page
This commit is contained in:
+223
-77
@@ -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">
|
||||
<h2 className="text-lg font-semibold">Currently viewing</h2>
|
||||
<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">
|
||||
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">
|
||||
Heartbeats within {formatNumber(data.active_window_seconds || 75)} seconds
|
||||
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: '© 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">
|
||||
|
||||
Reference in New Issue
Block a user