aggressive data collection :PP

This commit is contained in:
2026-05-14 22:52:33 +01:00
parent 98f374a300
commit ef10da8b0b
6 changed files with 735 additions and 34 deletions
+2
View File
@@ -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/
+25
View File
@@ -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
+4
View File
@@ -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
+338 -25
View File
@@ -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,36 +759,66 @@ 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
}
if (req.method === 'GET' && req.url === '/api/uptime') {
uptimeHistory()
.then((data) => sendJson(res, 200, data))
.catch((error) => sendJson(res, 500, { error: 'Uptime history unavailable', detail: error.message }))
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 return
} }
if (req.method === 'GET' && req.url === '/api/uptime') { if (isRateLimited(req)) {
uptimeHistory() sendJson(res, 429, { error: 'Too many analytics events' })
.then((data) => sendJson(res, 200, data))
.catch((error) => sendJson(res, 500, { error: 'Uptime history unavailable', detail: error.message }))
return return
} }
if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) { readJsonBody(req)
sendJson(res, 403, { error: 'CORS requests are not allowed' }) .then((payload) => {
return recordViewerEvent(req, payload)
} send(res, 204, '', { 'cache-control': 'no-store' })
})
.catch((error) => sendJson(res, 400, { error: error.message }))
return
}
if (req.url.startsWith('/api/')) { if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) {
proxyRequest(req, res) sendJson(res, 403, { error: 'CORS requests are not allowed' })
return return
} }
serveStatic(req, res) if (req.url.startsWith('/api/')) {
}) proxyRequest(req, res)
.listen(PORT, '0.0.0.0', () => { return
console.log(`tssbot-web serving http://localhost:${PORT}`) }
console.log(`proxying API requests to ${API_UPSTREAM}`)
console.log(`sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes`) serveStatic(req, res)
console.log(`storing uptime snapshots in ${path.join(uptimeStoragePath(), UPTIME_DATABASE_FILE)}`) })
startUptimeSampler()
}) server.listen(PORT, '0.0.0.0', () => {
console.log(`tssbot-web serving http://localhost:${PORT}`)
console.log(`proxying API requests to ${API_UPSTREAM}`)
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 viewer analytics in ${path.join(uptimeStoragePath(), ANALYTICS_DATABASE_FILE)}`)
startUptimeSampler()
})
+361 -7
View File
@@ -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,18 +728,72 @@ 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&apos; TSS Bot</p> <p>Toothless&apos; TSS Bot</p>
<button <div className="flex flex-wrap gap-4">
className="w-fit font-semibold text-fury-cyan transition hover:text-text" <button
onClick={() => navigate('/uptime')} className="w-fit font-semibold text-fury-cyan transition hover:text-text"
type="button" onClick={() => navigate('/uptime')}
> type="button"
Uptime >
</button> Uptime
</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
View File
@@ -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)