aggressive data collection :PP
This commit is contained in:
@@ -5,3 +5,5 @@ dist
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
vite-dev*.log
|
vite-dev*.log
|
||||||
|
server-local*.log
|
||||||
|
.local-storage/
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Routes:
|
|||||||
- `/teams` TSS team leaderboard
|
- `/teams` TSS team leaderboard
|
||||||
- `/teams/:teamname` generated team profile with roster, summary, rating history, and battle results
|
- `/teams/:teamname` generated team profile with roster, summary, rating history, and battle results
|
||||||
- `/battle-logs` Battle Logs
|
- `/battle-logs` Battle Logs
|
||||||
|
- `/viewers` public consented viewer analytics dashboard
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
@@ -83,6 +84,30 @@ UPTIME_HISTORY_LIMIT=336
|
|||||||
The server creates the storage folder, SQLite database, and `uptime_snapshots`
|
The server creates the storage folder, SQLite database, and `uptime_snapshots`
|
||||||
table automatically.
|
table automatically.
|
||||||
|
|
||||||
|
## Viewer analytics
|
||||||
|
|
||||||
|
The site shows a GDPR-style consent banner before analytics start. If a visitor
|
||||||
|
allows analytics, the browser sends page-view and heartbeat events to
|
||||||
|
`POST /api/viewers/event`. The public `/viewers` page reads `GET /api/viewers`
|
||||||
|
and shows active pages, client/browser information, 24-hour page totals, and
|
||||||
|
top pages.
|
||||||
|
|
||||||
|
Viewer analytics are stored in SQLite under the same `UPTIME_STORAGE_DIR` by
|
||||||
|
default. Raw IP addresses are not stored in the public response; the server
|
||||||
|
stores a salted IP hash for deduplication and abuse review. Set a unique salt in
|
||||||
|
production:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ANALYTICS_DATABASE_FILE=viewers.sqlite
|
||||||
|
ANALYTICS_RETENTION_DAYS=30
|
||||||
|
ANALYTICS_ACTIVE_WINDOW_SECONDS=75
|
||||||
|
ANALYTICS_SALT=replace-with-a-random-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
This is an implementation aid, not legal advice. For production GDPR compliance,
|
||||||
|
publish a privacy notice that matches the configured retention period and data
|
||||||
|
fields, and make sure the configured salt is secret.
|
||||||
|
|
||||||
## GitHub webhook
|
## GitHub webhook
|
||||||
|
|
||||||
The webhook process listens on port `3011` at `/github`. Configure GitHub to send
|
The webhook process listens on port `3011` at `/github`. Configure GitHub to send
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ UPTIME_STORAGE_DIR=~/tsswebstorage
|
|||||||
UPTIME_DATABASE_FILE=uptime.sqlite
|
UPTIME_DATABASE_FILE=uptime.sqlite
|
||||||
UPTIME_SAMPLE_INTERVAL_MS=1800000
|
UPTIME_SAMPLE_INTERVAL_MS=1800000
|
||||||
UPTIME_HISTORY_LIMIT=336
|
UPTIME_HISTORY_LIMIT=336
|
||||||
|
ANALYTICS_DATABASE_FILE=viewers.sqlite
|
||||||
|
ANALYTICS_RETENTION_DAYS=30
|
||||||
|
ANALYTICS_ACTIVE_WINDOW_SECONDS=75
|
||||||
|
ANALYTICS_SALT=change-me-viewer-salt
|
||||||
API_CACHE_TTL_MS=15000
|
API_CACHE_TTL_MS=15000
|
||||||
API_RATE_LIMIT_WINDOW_MS=60000
|
API_RATE_LIMIT_WINDOW_MS=60000
|
||||||
API_RATE_LIMIT_MAX=120
|
API_RATE_LIMIT_MAX=120
|
||||||
|
|||||||
+316
-3
@@ -1,4 +1,5 @@
|
|||||||
const fs = require('node:fs')
|
const fs = require('node:fs')
|
||||||
|
const crypto = require('node:crypto')
|
||||||
const http = require('node:http')
|
const http = require('node:http')
|
||||||
const https = require('node:https')
|
const https = require('node:https')
|
||||||
const os = require('node:os')
|
const os = require('node:os')
|
||||||
@@ -42,6 +43,10 @@ const UPTIME_STORAGE_DIR = process.env.UPTIME_STORAGE_DIR || '~/tsswebstorage'
|
|||||||
const UPTIME_DATABASE_FILE = process.env.UPTIME_DATABASE_FILE || 'uptime.sqlite'
|
const UPTIME_DATABASE_FILE = process.env.UPTIME_DATABASE_FILE || 'uptime.sqlite'
|
||||||
const UPTIME_SAMPLE_INTERVAL_MS = Number(process.env.UPTIME_SAMPLE_INTERVAL_MS || 30 * 60 * 1000)
|
const UPTIME_SAMPLE_INTERVAL_MS = Number(process.env.UPTIME_SAMPLE_INTERVAL_MS || 30 * 60 * 1000)
|
||||||
const UPTIME_HISTORY_LIMIT = Number(process.env.UPTIME_HISTORY_LIMIT || 336)
|
const UPTIME_HISTORY_LIMIT = Number(process.env.UPTIME_HISTORY_LIMIT || 336)
|
||||||
|
const ANALYTICS_DATABASE_FILE = process.env.ANALYTICS_DATABASE_FILE || 'viewers.sqlite'
|
||||||
|
const ANALYTICS_RETENTION_DAYS = Number(process.env.ANALYTICS_RETENTION_DAYS || 30)
|
||||||
|
const ANALYTICS_ACTIVE_WINDOW_SECONDS = Number(process.env.ANALYTICS_ACTIVE_WINDOW_SECONDS || 75)
|
||||||
|
const ANALYTICS_SALT = process.env.ANALYTICS_SALT || 'change-me-viewer-salt'
|
||||||
const API_CACHE_TTL_MS = Number(process.env.API_CACHE_TTL_MS || 15000)
|
const API_CACHE_TTL_MS = Number(process.env.API_CACHE_TTL_MS || 15000)
|
||||||
const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000)
|
const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000)
|
||||||
const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120)
|
const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120)
|
||||||
@@ -49,6 +54,7 @@ const DIST_DIR = path.join(__dirname, 'dist')
|
|||||||
const MAX_TEAM_NAME_LENGTH = 80
|
const MAX_TEAM_NAME_LENGTH = 80
|
||||||
const MAX_CACHE_ENTRIES = 200
|
const MAX_CACHE_ENTRIES = 200
|
||||||
const MAX_RATE_LIMIT_KEYS = 1000
|
const MAX_RATE_LIMIT_KEYS = 1000
|
||||||
|
const MAX_ANALYTICS_BODY_BYTES = 16 * 1024
|
||||||
|
|
||||||
const mimeTypes = {
|
const mimeTypes = {
|
||||||
'.css': 'text/css; charset=utf-8',
|
'.css': 'text/css; charset=utf-8',
|
||||||
@@ -76,6 +82,7 @@ const jsonHeaders = {
|
|||||||
const apiCache = new Map()
|
const apiCache = new Map()
|
||||||
const rateLimits = new Map()
|
const rateLimits = new Map()
|
||||||
let uptimeDb = null
|
let uptimeDb = null
|
||||||
|
let analyticsDb = null
|
||||||
let latestUptimeSnapshot = null
|
let latestUptimeSnapshot = null
|
||||||
|
|
||||||
function sendJson(res, status, body, headers = {}) {
|
function sendJson(res, status, body, headers = {}) {
|
||||||
@@ -138,6 +145,67 @@ function uptimeStoragePath() {
|
|||||||
return path.resolve(expandHome(UPTIME_STORAGE_DIR))
|
return path.resolve(expandHome(UPTIME_STORAGE_DIR))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureAnalyticsDb() {
|
||||||
|
if (analyticsDb) return analyticsDb
|
||||||
|
|
||||||
|
const storageDir = uptimeStoragePath()
|
||||||
|
fs.mkdirSync(storageDir, { recursive: true })
|
||||||
|
|
||||||
|
analyticsDb = new Database(path.join(storageDir, ANALYTICS_DATABASE_FILE))
|
||||||
|
analyticsDb.pragma('journal_mode = WAL')
|
||||||
|
analyticsDb.exec(`
|
||||||
|
create table if not exists viewer_events (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
occurred_at text not null default (datetime('now')),
|
||||||
|
visitor_id text not null,
|
||||||
|
session_id text not null,
|
||||||
|
ip_hash text not null,
|
||||||
|
event_type text not null,
|
||||||
|
page_path text not null,
|
||||||
|
page_title text not null,
|
||||||
|
referrer text not null default '',
|
||||||
|
user_agent text not null default '',
|
||||||
|
browser text not null default 'Unknown',
|
||||||
|
os text not null default 'Unknown',
|
||||||
|
device text not null default 'Desktop',
|
||||||
|
screen text not null default '',
|
||||||
|
language text not null default '',
|
||||||
|
timezone text not null default '',
|
||||||
|
consent text not null default 'analytics',
|
||||||
|
metadata text not null default '{}'
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists active_viewers (
|
||||||
|
session_id text primary key,
|
||||||
|
visitor_id text not null,
|
||||||
|
ip_hash text not null,
|
||||||
|
first_seen_at text not null,
|
||||||
|
last_seen_at text not null,
|
||||||
|
page_path text not null,
|
||||||
|
page_title text not null,
|
||||||
|
referrer text not null default '',
|
||||||
|
user_agent text not null default '',
|
||||||
|
browser text not null default 'Unknown',
|
||||||
|
os text not null default 'Unknown',
|
||||||
|
device text not null default 'Desktop',
|
||||||
|
screen text not null default '',
|
||||||
|
language text not null default '',
|
||||||
|
timezone text not null default ''
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists viewer_events_occurred_at_idx
|
||||||
|
on viewer_events (occurred_at desc);
|
||||||
|
|
||||||
|
create index if not exists viewer_events_page_path_idx
|
||||||
|
on viewer_events (page_path, occurred_at desc);
|
||||||
|
|
||||||
|
create index if not exists active_viewers_last_seen_at_idx
|
||||||
|
on active_viewers (last_seen_at desc);
|
||||||
|
`)
|
||||||
|
|
||||||
|
return analyticsDb
|
||||||
|
}
|
||||||
|
|
||||||
function ensureUptimeDb() {
|
function ensureUptimeDb() {
|
||||||
if (uptimeDb) return uptimeDb
|
if (uptimeDb) return uptimeDb
|
||||||
|
|
||||||
@@ -308,6 +376,221 @@ function clientIp(req) {
|
|||||||
return req.socket.remoteAddress || 'unknown'
|
return req.socket.remoteAddress || 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hashIp(ip) {
|
||||||
|
return crypto.createHash('sha256').update(`${ANALYTICS_SALT}:${ip}`).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeText(value, maxLength = 200) {
|
||||||
|
return String(value || '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePath(value) {
|
||||||
|
const raw = sanitizeText(value, 300)
|
||||||
|
if (!raw.startsWith('/')) return '/'
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClient(userAgent = '') {
|
||||||
|
const ua = String(userAgent)
|
||||||
|
let browser = 'Unknown'
|
||||||
|
let osName = 'Unknown'
|
||||||
|
let device = 'Desktop'
|
||||||
|
|
||||||
|
if (/Edg\//.test(ua)) browser = 'Microsoft Edge'
|
||||||
|
else if (/OPR\//.test(ua)) browser = 'Opera'
|
||||||
|
else if (/Firefox\//.test(ua)) browser = 'Firefox'
|
||||||
|
else if (/Chrome\//.test(ua) && !/Chromium\//.test(ua)) browser = 'Chrome'
|
||||||
|
else if (/Safari\//.test(ua) && /Version\//.test(ua)) browser = 'Safari'
|
||||||
|
|
||||||
|
if (/Windows NT/.test(ua)) osName = 'Windows'
|
||||||
|
else if (/Android/.test(ua)) osName = 'Android'
|
||||||
|
else if (/(iPhone|iPad|iPod)/.test(ua)) osName = 'iOS'
|
||||||
|
else if (/Mac OS X/.test(ua)) osName = 'macOS'
|
||||||
|
else if (/Linux/.test(ua)) osName = 'Linux'
|
||||||
|
|
||||||
|
if (/Mobi|Android|iPhone|iPod/.test(ua)) device = 'Mobile'
|
||||||
|
else if (/iPad|Tablet/.test(ua)) device = 'Tablet'
|
||||||
|
|
||||||
|
return { browser, os: osName, device }
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = []
|
||||||
|
let size = 0
|
||||||
|
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
size += chunk.length
|
||||||
|
if (size > MAX_ANALYTICS_BODY_BYTES) {
|
||||||
|
reject(new Error('Request body too large'))
|
||||||
|
req.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunks.push(chunk)
|
||||||
|
})
|
||||||
|
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const body = Buffer.concat(chunks).toString('utf8')
|
||||||
|
resolve(body ? JSON.parse(body) : {})
|
||||||
|
} catch {
|
||||||
|
reject(new Error('Invalid JSON body'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function purgeOldAnalytics(db) {
|
||||||
|
db.prepare(`
|
||||||
|
delete from viewer_events
|
||||||
|
where occurred_at < datetime('now', ?)
|
||||||
|
`).run(`-${ANALYTICS_RETENTION_DAYS} days`)
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
delete from active_viewers
|
||||||
|
where last_seen_at < datetime('now', ?)
|
||||||
|
`).run(`-${ANALYTICS_ACTIVE_WINDOW_SECONDS * 3} seconds`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordViewerEvent(req, payload) {
|
||||||
|
const db = ensureAnalyticsDb()
|
||||||
|
purgeOldAnalytics(db)
|
||||||
|
|
||||||
|
const serverClient = parseClient(req.headers['user-agent'] || '')
|
||||||
|
const event = {
|
||||||
|
visitor_id: sanitizeText(payload.visitor_id, 80) || crypto.randomUUID(),
|
||||||
|
session_id: sanitizeText(payload.session_id, 80) || crypto.randomUUID(),
|
||||||
|
ip_hash: hashIp(clientIp(req)),
|
||||||
|
event_type: ['page_view', 'heartbeat', 'consent'].includes(payload.event_type)
|
||||||
|
? payload.event_type
|
||||||
|
: 'heartbeat',
|
||||||
|
page_path: sanitizePath(payload.page_path),
|
||||||
|
page_title: sanitizeText(payload.page_title, 160),
|
||||||
|
referrer: sanitizeText(payload.referrer, 300),
|
||||||
|
user_agent: sanitizeText(req.headers['user-agent'] || payload.user_agent, 500),
|
||||||
|
browser: sanitizeText(payload.browser || serverClient.browser, 80),
|
||||||
|
os: sanitizeText(payload.os || serverClient.os, 80),
|
||||||
|
device: sanitizeText(payload.device || serverClient.device, 80),
|
||||||
|
screen: sanitizeText(payload.screen, 40),
|
||||||
|
language: sanitizeText(payload.language, 40),
|
||||||
|
timezone: sanitizeText(payload.timezone, 80),
|
||||||
|
consent: payload.consent === 'analytics' ? 'analytics' : '',
|
||||||
|
metadata: JSON.stringify(payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.consent !== 'analytics') {
|
||||||
|
throw new Error('Analytics consent is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
db.prepare(`
|
||||||
|
insert into viewer_events
|
||||||
|
(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)
|
||||||
|
values
|
||||||
|
(@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)
|
||||||
|
`).run({ ...event, occurred_at: now })
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
insert into active_viewers
|
||||||
|
(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)
|
||||||
|
values
|
||||||
|
(@session_id, @visitor_id, @ip_hash, @now, @now, @page_path, @page_title,
|
||||||
|
@referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone)
|
||||||
|
on conflict(session_id) do update set
|
||||||
|
last_seen_at = excluded.last_seen_at,
|
||||||
|
page_path = excluded.page_path,
|
||||||
|
page_title = excluded.page_title,
|
||||||
|
referrer = excluded.referrer,
|
||||||
|
user_agent = excluded.user_agent,
|
||||||
|
browser = excluded.browser,
|
||||||
|
os = excluded.os,
|
||||||
|
device = excluded.device,
|
||||||
|
screen = excluded.screen,
|
||||||
|
language = excluded.language,
|
||||||
|
timezone = excluded.timezone
|
||||||
|
`).run({ ...event, now })
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewerDashboard() {
|
||||||
|
const db = ensureAnalyticsDb()
|
||||||
|
purgeOldAnalytics(db)
|
||||||
|
|
||||||
|
const activeSince = `-${ANALYTICS_ACTIVE_WINDOW_SECONDS} seconds`
|
||||||
|
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
|
||||||
|
from active_viewers
|
||||||
|
where last_seen_at >= datetime('now', ?)
|
||||||
|
order by last_seen_at desc
|
||||||
|
limit 100
|
||||||
|
`).all(activeSince).map((row) => ({
|
||||||
|
session: row.session_id.slice(0, 8),
|
||||||
|
visitor: row.visitor_id.slice(0, 8),
|
||||||
|
first_seen_at: row.first_seen_at,
|
||||||
|
last_seen_at: row.last_seen_at,
|
||||||
|
page_path: row.page_path,
|
||||||
|
page_title: row.page_title,
|
||||||
|
referrer: row.referrer,
|
||||||
|
browser: row.browser,
|
||||||
|
os: row.os,
|
||||||
|
device: row.device,
|
||||||
|
screen: row.screen,
|
||||||
|
language: row.language,
|
||||||
|
timezone: row.timezone,
|
||||||
|
}))
|
||||||
|
|
||||||
|
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')
|
||||||
|
group by page_path, page_title
|
||||||
|
order by views desc, page_path asc
|
||||||
|
limit 12
|
||||||
|
`).all()
|
||||||
|
|
||||||
|
const clients = db.prepare(`
|
||||||
|
select browser, os, device, count(*) as events
|
||||||
|
from viewer_events
|
||||||
|
where occurred_at >= datetime('now', '-24 hours')
|
||||||
|
group by browser, os, device
|
||||||
|
order by events desc
|
||||||
|
limit 12
|
||||||
|
`).all()
|
||||||
|
|
||||||
|
const totals = db.prepare(`
|
||||||
|
select
|
||||||
|
count(*) as events_24h,
|
||||||
|
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()
|
||||||
|
|
||||||
|
return {
|
||||||
|
active_window_seconds: ANALYTICS_ACTIVE_WINDOW_SECONDS,
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
active,
|
||||||
|
top_pages: topPages,
|
||||||
|
clients,
|
||||||
|
totals: {
|
||||||
|
active_now: active.length,
|
||||||
|
events_24h: totals?.events_24h || 0,
|
||||||
|
visitors_24h: totals?.visitors_24h || 0,
|
||||||
|
page_views_24h: totals?.page_views_24h || 0,
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
retention_days: ANALYTICS_RETENTION_DAYS,
|
||||||
|
stores_ip_hashes: true,
|
||||||
|
exposes_raw_ip: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isRateLimited(req) {
|
function isRateLimited(req) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const ip = clientIp(req)
|
const ip = clientIp(req)
|
||||||
@@ -476,8 +759,7 @@ function serveStatic(req, res) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
http
|
const server = http.createServer((req, res) => {
|
||||||
.createServer((req, res) => {
|
|
||||||
if (req.url === '/health') {
|
if (req.url === '/health') {
|
||||||
sendJson(res, 200, { ok: true })
|
sendJson(res, 200, { ok: true })
|
||||||
return
|
return
|
||||||
@@ -490,6 +772,35 @@ http
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && req.url === '/api/viewers') {
|
||||||
|
try {
|
||||||
|
sendJson(res, 200, viewerDashboard())
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 500, { error: 'Viewer analytics unavailable', detail: error.message })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/viewers/event') {
|
||||||
|
if (!isSameOriginRequest(req)) {
|
||||||
|
sendJson(res, 403, { error: 'Analytics events are restricted to this site' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRateLimited(req)) {
|
||||||
|
sendJson(res, 429, { error: 'Too many analytics events' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
readJsonBody(req)
|
||||||
|
.then((payload) => {
|
||||||
|
recordViewerEvent(req, payload)
|
||||||
|
send(res, 204, '', { 'cache-control': 'no-store' })
|
||||||
|
})
|
||||||
|
.catch((error) => sendJson(res, 400, { error: error.message }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) {
|
if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) {
|
||||||
sendJson(res, 403, { error: 'CORS requests are not allowed' })
|
sendJson(res, 403, { error: 'CORS requests are not allowed' })
|
||||||
return
|
return
|
||||||
@@ -502,10 +813,12 @@ http
|
|||||||
|
|
||||||
serveStatic(req, res)
|
serveStatic(req, res)
|
||||||
})
|
})
|
||||||
.listen(PORT, '0.0.0.0', () => {
|
|
||||||
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`tssbot-web serving http://localhost:${PORT}`)
|
console.log(`tssbot-web serving http://localhost:${PORT}`)
|
||||||
console.log(`proxying API requests to ${API_UPSTREAM}`)
|
console.log(`proxying API requests to ${API_UPSTREAM}`)
|
||||||
console.log(`sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes`)
|
console.log(`sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes`)
|
||||||
console.log(`storing uptime snapshots in ${path.join(uptimeStoragePath(), UPTIME_DATABASE_FILE)}`)
|
console.log(`storing uptime snapshots in ${path.join(uptimeStoragePath(), UPTIME_DATABASE_FILE)}`)
|
||||||
|
console.log(`storing viewer analytics in ${path.join(uptimeStoragePath(), ANALYTICS_DATABASE_FILE)}`)
|
||||||
startUptimeSampler()
|
startUptimeSampler()
|
||||||
})
|
})
|
||||||
|
|||||||
+354
@@ -11,6 +11,8 @@ const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
|||||||
const apiEndpoints = {
|
const apiEndpoints = {
|
||||||
health: '/health',
|
health: '/health',
|
||||||
uptime: '/api/uptime',
|
uptime: '/api/uptime',
|
||||||
|
viewers: '/api/viewers',
|
||||||
|
viewerEvent: '/api/viewers/event',
|
||||||
teams: '/api/tss/leaderboard/teams?limit=100',
|
teams: '/api/tss/leaderboard/teams?limit=100',
|
||||||
teamsHealth: '/api/tss/leaderboard/teams?limit=1',
|
teamsHealth: '/api/tss/leaderboard/teams?limit=1',
|
||||||
resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`,
|
resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`,
|
||||||
@@ -23,8 +25,12 @@ const navItems = [
|
|||||||
{ path: '/', label: 'Home' },
|
{ path: '/', label: 'Home' },
|
||||||
{ path: '/teams', label: 'Team leaderboard' },
|
{ path: '/teams', label: 'Team leaderboard' },
|
||||||
{ path: '/battle-logs', label: 'Battle Logs' },
|
{ path: '/battle-logs', label: 'Battle Logs' },
|
||||||
|
{ path: '/viewers', label: 'Viewers' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const analyticsConsentKey = 'tssbot.analyticsConsent'
|
||||||
|
const analyticsVisitorKey = 'tssbot.analyticsVisitor'
|
||||||
|
|
||||||
async function fetchJson(path, signal) {
|
async function fetchJson(path, signal) {
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
signal,
|
signal,
|
||||||
@@ -43,6 +49,7 @@ function parseRoute(pathname = window.location.pathname) {
|
|||||||
if (pathname === '/') return { page: 'home', teamName: '' }
|
if (pathname === '/') return { page: 'home', teamName: '' }
|
||||||
if (pathname === '/teams') return { page: 'teams', teamName: '' }
|
if (pathname === '/teams') return { page: 'teams', teamName: '' }
|
||||||
if (pathname === '/uptime') return { page: 'uptime', teamName: '' }
|
if (pathname === '/uptime') return { page: 'uptime', teamName: '' }
|
||||||
|
if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
|
||||||
if (pathname.startsWith('/teams/')) {
|
if (pathname.startsWith('/teams/')) {
|
||||||
const teamName = decodeURIComponent(pathname.slice('/teams/'.length))
|
const teamName = decodeURIComponent(pathname.slice('/teams/'.length))
|
||||||
return { page: 'team', teamName }
|
return { page: 'team', teamName }
|
||||||
@@ -68,6 +75,69 @@ function bestTeamName(team) {
|
|||||||
return team?.tag_name || team?.short_name || team?.long_name || ''
|
return team?.tag_name || team?.short_name || team?.long_name || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function storedConsent() {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(analyticsConsentKey) || ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableId(storageKey) {
|
||||||
|
try {
|
||||||
|
const existing = window.localStorage.getItem(storageKey)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const id =
|
||||||
|
window.crypto?.randomUUID?.() ||
|
||||||
|
`${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
|
||||||
|
window.localStorage.setItem(storageKey, id)
|
||||||
|
return id
|
||||||
|
} catch {
|
||||||
|
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyticsSessionId =
|
||||||
|
window.crypto?.randomUUID?.() ||
|
||||||
|
`${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
|
||||||
|
|
||||||
|
function browserName() {
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
if (ua.includes('Edg/')) return 'Microsoft Edge'
|
||||||
|
if (ua.includes('OPR/')) return 'Opera'
|
||||||
|
if (ua.includes('Firefox/')) return 'Firefox'
|
||||||
|
if (ua.includes('Chrome/') && !ua.includes('Chromium/')) return 'Chrome'
|
||||||
|
if (ua.includes('Safari/') && ua.includes('Version/')) return 'Safari'
|
||||||
|
return 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
function operatingSystem() {
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
if (ua.includes('Windows NT')) return 'Windows'
|
||||||
|
if (ua.includes('Android')) return 'Android'
|
||||||
|
if (/iPhone|iPad|iPod/.test(ua)) return 'iOS'
|
||||||
|
if (ua.includes('Mac OS X')) return 'macOS'
|
||||||
|
if (ua.includes('Linux')) return 'Linux'
|
||||||
|
return 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceType() {
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
if (/Mobi|Android|iPhone|iPod/.test(ua)) return 'Mobile'
|
||||||
|
if (/iPad|Tablet/.test(ua)) return 'Tablet'
|
||||||
|
return 'Desktop'
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeLabel(route) {
|
||||||
|
if (route.page === 'team' && route.teamName) return `Team: ${route.teamName}`
|
||||||
|
if (route.page === 'teams') return 'Team leaderboard'
|
||||||
|
if (route.page === 'battle-logs') return 'Battle Logs'
|
||||||
|
if (route.page === 'uptime') return 'Uptime'
|
||||||
|
if (route.page === 'viewers') return 'viewers'
|
||||||
|
return 'Home'
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchRecentTssGames(teams, signal) {
|
async function fetchRecentTssGames(teams, signal) {
|
||||||
const teamNames = teams.map(bestTeamName).filter(Boolean).slice(0, 12)
|
const teamNames = teams.map(bestTeamName).filter(Boolean).slice(0, 12)
|
||||||
|
|
||||||
@@ -122,6 +192,8 @@ function App() {
|
|||||||
const [leaderboard, setLeaderboard] = useState({ status: 'idle', data: null, error: null })
|
const [leaderboard, setLeaderboard] = useState({ status: 'idle', data: null, error: null })
|
||||||
const [live, setLive] = useState({ status: 'idle', data: null, error: null })
|
const [live, setLive] = useState({ status: 'idle', data: null, error: null })
|
||||||
const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null })
|
const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null })
|
||||||
|
const [viewers, setViewers] = useState({ status: 'idle', data: null, error: null, updatedAt: null })
|
||||||
|
const [analyticsConsent, setAnalyticsConsent] = useState(() => storedConsent())
|
||||||
const [teamQuery, setTeamQuery] = useState('')
|
const [teamQuery, setTeamQuery] = useState('')
|
||||||
const [searchHint, setSearchHint] = useState({ status: 'idle', name: '' })
|
const [searchHint, setSearchHint] = useState({ status: 'idle', name: '' })
|
||||||
const [profile, setProfile] = useState({
|
const [profile, setProfile] = useState({
|
||||||
@@ -157,11 +229,64 @@ function App() {
|
|||||||
? "Battle Logs | Toothless' TSS Bot"
|
? "Battle Logs | Toothless' TSS Bot"
|
||||||
: route.page === 'uptime'
|
: route.page === 'uptime'
|
||||||
? "Uptime | Toothless' TSS Bot"
|
? "Uptime | Toothless' TSS Bot"
|
||||||
|
: route.page === 'viewers'
|
||||||
|
? "viewers | Toothless' TSS Bot"
|
||||||
: "Toothless' TSS Bot"
|
: "Toothless' TSS Bot"
|
||||||
|
|
||||||
document.title = title
|
document.title = title
|
||||||
}, [route.page, route.teamName])
|
}, [route.page, route.teamName])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (analyticsConsent !== 'analytics') return
|
||||||
|
|
||||||
|
const visitorId = stableId(analyticsVisitorKey)
|
||||||
|
let stopped = false
|
||||||
|
|
||||||
|
function sendViewerEvent(eventType) {
|
||||||
|
if (stopped) return
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
consent: 'analytics',
|
||||||
|
event_type: eventType,
|
||||||
|
visitor_id: visitorId,
|
||||||
|
session_id: analyticsSessionId,
|
||||||
|
page_path: window.location.pathname,
|
||||||
|
page_title: routeLabel(route),
|
||||||
|
referrer: document.referrer,
|
||||||
|
browser: browserName(),
|
||||||
|
os: operatingSystem(),
|
||||||
|
device: deviceType(),
|
||||||
|
screen: `${window.screen.width}x${window.screen.height}`,
|
||||||
|
language: navigator.language,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
metadata: {
|
||||||
|
color_depth: window.screen.colorDepth,
|
||||||
|
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(apiEndpoints.viewerEvent, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
sendViewerEvent('page_view')
|
||||||
|
const timer = window.setInterval(() => sendViewerEvent('heartbeat'), 30000)
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') sendViewerEvent('heartbeat')
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopped = true
|
||||||
|
window.clearInterval(timer)
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
}
|
||||||
|
}, [analyticsConsent, route])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event) => {
|
const onKeyDown = (event) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
@@ -417,6 +542,44 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [route.page])
|
}, [route.page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (route.page !== 'viewers') return
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
function loadViewers() {
|
||||||
|
setViewers((current) => ({
|
||||||
|
status: current.status === 'ready' ? 'refreshing' : 'loading',
|
||||||
|
data: current.data,
|
||||||
|
error: null,
|
||||||
|
updatedAt: current.updatedAt,
|
||||||
|
}))
|
||||||
|
|
||||||
|
fetchJson(apiEndpoints.viewers, controller.signal)
|
||||||
|
.then((data) => {
|
||||||
|
setViewers({
|
||||||
|
status: 'ready',
|
||||||
|
data,
|
||||||
|
error: null,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setViewers((current) => ({ ...current, status: 'error', error: error.message }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
loadViewers()
|
||||||
|
const timer = window.setInterval(loadViewers, 5000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
}, [route.page])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (route.page !== 'team' || !route.teamName) return
|
if (route.page !== 'team' || !route.teamName) return
|
||||||
|
|
||||||
@@ -470,11 +633,22 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseAnalyticsConsent(value) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(analyticsConsentKey, value)
|
||||||
|
} catch {
|
||||||
|
// Local storage can be blocked; the in-memory choice still controls this session.
|
||||||
|
}
|
||||||
|
setAnalyticsConsent(value)
|
||||||
|
}
|
||||||
|
|
||||||
const activeNavPath =
|
const activeNavPath =
|
||||||
route.page === 'team'
|
route.page === 'team'
|
||||||
? '/teams'
|
? '/teams'
|
||||||
: route.page === 'battle-logs'
|
: route.page === 'battle-logs'
|
||||||
? '/battle-logs'
|
? '/battle-logs'
|
||||||
|
: route.page === 'viewers'
|
||||||
|
? '/viewers'
|
||||||
: window.location.pathname
|
: window.location.pathname
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -541,8 +715,10 @@ function App() {
|
|||||||
) : null}
|
) : null}
|
||||||
{route.page === 'battle-logs' ? <BattleLogsPage live={live} matches={matches} /> : null}
|
{route.page === 'battle-logs' ? <BattleLogsPage live={live} matches={matches} /> : null}
|
||||||
{route.page === 'uptime' ? <UptimePage uptime={uptime} /> : null}
|
{route.page === 'uptime' ? <UptimePage uptime={uptime} /> : null}
|
||||||
|
{route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null}
|
||||||
</section>
|
</section>
|
||||||
<Footer navigate={navigate} />
|
<Footer navigate={navigate} />
|
||||||
|
<ConsentBanner consent={analyticsConsent} onChoose={chooseAnalyticsConsent} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -552,6 +728,7 @@ function Footer({ navigate }) {
|
|||||||
<footer className="mt-14 border-t border-border bg-fury-white">
|
<footer className="mt-14 border-t border-border bg-fury-white">
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-3 px-5 py-6 text-sm text-text-soft sm:flex-row sm:items-center sm:justify-between sm:px-8">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-3 px-5 py-6 text-sm text-text-soft sm:flex-row sm:items-center sm:justify-between sm:px-8">
|
||||||
<p>Toothless' TSS Bot</p>
|
<p>Toothless' TSS Bot</p>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
<button
|
<button
|
||||||
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
|
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
|
||||||
onClick={() => navigate('/uptime')}
|
onClick={() => navigate('/uptime')}
|
||||||
@@ -559,11 +736,64 @@ function Footer({ navigate }) {
|
|||||||
>
|
>
|
||||||
Uptime
|
Uptime
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
|
||||||
|
onClick={() => navigate('/viewers')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
viewers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConsentBanner({ consent, onChoose }) {
|
||||||
|
if (consent) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="fixed right-4 bottom-4 z-50 rounded-md border border-border bg-fury-white px-3 py-2 text-xs font-semibold text-text-soft shadow-sm transition hover:text-text"
|
||||||
|
onClick={() => onChoose('')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Privacy settings
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-border bg-fury-white shadow-[0_-8px_24px_rgba(0,0,0,0.08)]">
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-5 py-4 sm:px-8 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<h2 className="text-base font-semibold">Analytics consent</h2>
|
||||||
|
<p className="mt-1 text-sm text-text-soft">
|
||||||
|
We can track page views, live viewing state, browser, device, screen size,
|
||||||
|
language, timezone, referrer, and pseudonymous identifiers so the public
|
||||||
|
viewers page works. Raw IP addresses are not shown publicly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text-soft transition hover:bg-surface hover:text-text"
|
||||||
|
onClick={() => onChoose('declined')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Decline
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-fury-cyan px-4 py-2 text-sm font-semibold text-bg transition hover:bg-fury-aqua"
|
||||||
|
onClick={() => onChoose('analytics')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Allow analytics
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Landing({ live, matches, navigate }) {
|
function Landing({ live, matches, navigate }) {
|
||||||
const treeRef = useRef(null)
|
const treeRef = useRef(null)
|
||||||
|
|
||||||
@@ -1093,6 +1323,130 @@ function BattleLogsPage({ live, matches }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function relativeSeconds(timestamp) {
|
||||||
|
if (!timestamp) return 'unknown'
|
||||||
|
const seconds = Math.max(0, Math.round((Date.now() - new Date(timestamp).getTime()) / 1000))
|
||||||
|
if (seconds < 60) return `${seconds}s ago`
|
||||||
|
return `${Math.round(seconds / 60)}m ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewersPage({ viewers }) {
|
||||||
|
const data = viewers.data || {}
|
||||||
|
const active = data.active || []
|
||||||
|
const topPages = data.top_pages || []
|
||||||
|
const clients = data.clients || []
|
||||||
|
const totals = data.totals || {}
|
||||||
|
const generatedAt = data.generated_at ? dateFormat.format(new Date(data.generated_at)) : 'Waiting for data'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-6">
|
||||||
|
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
|
||||||
|
Public analytics
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-4xl font-bold">viewers</h1>
|
||||||
|
<p className="mt-2 text-sm text-text-soft">
|
||||||
|
Live consented browser sessions and page activity. Last refreshed {generatedAt}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="w-fit rounded-md bg-surface px-3 py-2 text-sm font-semibold text-fury-cyan">
|
||||||
|
{viewers.status === 'loading' ? 'Loading' : `${formatNumber(totals.active_now)} active now`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<Stat label="Active now" value={formatNumber(totals.active_now)} />
|
||||||
|
<Stat label="Visitors 24h" value={formatNumber(totals.visitors_24h)} />
|
||||||
|
<Stat label="Page views 24h" value={formatNumber(totals.page_views_24h)} />
|
||||||
|
<Stat label="Events 24h" value={formatNumber(totals.events_24h)} />
|
||||||
|
</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>
|
||||||
|
<p className="mt-1 text-sm text-text-soft">
|
||||||
|
Heartbeats within {formatNumber(data.active_window_seconds || 75)} seconds
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{active.map((viewer) => (
|
||||||
|
<div
|
||||||
|
className="grid gap-3 border-b border-surface px-5 py-4 text-sm lg:grid-cols-[1fr_1fr_auto_auto_auto]"
|
||||||
|
key={viewer.session}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold">{viewer.page_title || viewer.page_path}</p>
|
||||||
|
<p className="truncate text-xs text-text-soft">{viewer.page_path}</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold">
|
||||||
|
{viewer.browser} on {viewer.os}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-text-soft">
|
||||||
|
{viewer.device} · {viewer.screen || 'unknown screen'} · {viewer.timezone || 'unknown timezone'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-text-soft">{viewer.language || 'unknown language'}</p>
|
||||||
|
<p className="text-text-soft">Seen {relativeSeconds(viewer.last_seen_at)}</p>
|
||||||
|
<p className="font-mono text-xs text-text-muted">#{viewer.session}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!active.length ? (
|
||||||
|
<p className="px-5 py-10 text-sm text-text-soft">
|
||||||
|
{viewers.error || 'No consented viewers are active right now'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<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">Top pages</h2>
|
||||||
|
<p className="mt-1 text-sm text-text-soft">Page views over the last 24 hours</p>
|
||||||
|
</div>
|
||||||
|
{topPages.map((page) => (
|
||||||
|
<div className="grid grid-cols-[1fr_auto] gap-3 border-b border-surface px-5 py-3 text-sm" key={page.page_path}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold">{page.page_title || page.page_path}</p>
|
||||||
|
<p className="truncate text-xs text-text-soft">{page.page_path}</p>
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold text-fury-cyan">{formatNumber(page.views)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!topPages.length ? <p className="px-5 py-10 text-sm text-text-soft">No page views recorded yet</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">Clients</h2>
|
||||||
|
<p className="mt-1 text-sm text-text-soft">Browsers, devices, and operating systems seen in 24 hours</p>
|
||||||
|
</div>
|
||||||
|
{clients.map((client) => (
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-[1fr_auto] gap-3 border-b border-surface px-5 py-3 text-sm"
|
||||||
|
key={`${client.browser}-${client.os}-${client.device}`}
|
||||||
|
>
|
||||||
|
<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="font-semibold text-fury-cyan">{formatNumber(client.events)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!clients.length ? <p className="px-5 py-10 text-sm text-text-soft">No client data recorded yet</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-text-soft">
|
||||||
|
Analytics are opt-in, retained for {formatNumber(data.privacy?.retention_days || 30)} days,
|
||||||
|
and public output excludes raw IP addresses.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function UptimePage({ uptime }) {
|
function UptimePage({ uptime }) {
|
||||||
const checks = uptime.checks
|
const checks = uptime.checks
|
||||||
const history = uptime.history
|
const history = uptime.history
|
||||||
|
|||||||
+5
-2
@@ -5,11 +5,14 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
const MAX_TEAM_NAME_LENGTH = 80
|
const MAX_TEAM_NAME_LENGTH = 80
|
||||||
|
|
||||||
function isAllowedApiUrl(req) {
|
function isAllowedApiUrl(req) {
|
||||||
if (req.method !== 'GET' && req.method !== 'HEAD') return false
|
|
||||||
|
|
||||||
const url = new URL(req.url, 'http://localhost')
|
const url = new URL(req.url, 'http://localhost')
|
||||||
const params = url.searchParams
|
const params = url.searchParams
|
||||||
|
|
||||||
|
if (url.pathname === '/api/viewers' && (req.method === 'GET' || req.method === 'HEAD')) return true
|
||||||
|
if (url.pathname === '/api/viewers/event' && req.method === 'POST') return true
|
||||||
|
|
||||||
|
if (req.method !== 'GET' && req.method !== 'HEAD') return false
|
||||||
|
|
||||||
if (url.pathname === '/api/tss/leaderboard/teams') {
|
if (url.pathname === '/api/tss/leaderboard/teams') {
|
||||||
const keys = [...params.keys()]
|
const keys = [...params.keys()]
|
||||||
const limit = Number(params.get('limit') || 100)
|
const limit = Number(params.get('limit') || 100)
|
||||||
|
|||||||
Reference in New Issue
Block a user