diff --git a/README.md b/README.md index 9cd1d37..8c9d3e6 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,22 @@ API_RATE_LIMIT_WINDOW_MS=60000 API_RATE_LIMIT_MAX=120 ``` +## Uptime snapshots + +The production server samples uptime every 30 minutes and exposes the history at +`/api/uptime`. Set `DATABASE_URL` or `UPTIME_DATABASE_URL` to persist snapshots +in Postgres: + +```sh +DATABASE_URL=postgres://user:password@127.0.0.1:5432/tssbot +UPTIME_SAMPLE_INTERVAL_MS=1800000 +UPTIME_HISTORY_LIMIT=336 +``` + +The server creates the `uptime_snapshots` table automatically. Without a +database URL, uptime sampling still runs but only the latest in-memory snapshot +is available. + ## GitHub webhook The webhook process listens on port `3011` at `/github`. Configure GitHub to send diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index e43f6f0..beb07f0 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -9,6 +9,10 @@ module.exports = { PORT: process.env.PORT || 3010, API_UPSTREAM: process.env.API_UPSTREAM || 'http://127.0.0.1:6000', PUBLIC_ORIGIN: process.env.PUBLIC_ORIGIN || '', + DATABASE_URL: process.env.DATABASE_URL || '', + UPTIME_DATABASE_URL: process.env.UPTIME_DATABASE_URL || '', + UPTIME_SAMPLE_INTERVAL_MS: process.env.UPTIME_SAMPLE_INTERVAL_MS || 1800000, + UPTIME_HISTORY_LIMIT: process.env.UPTIME_HISTORY_LIMIT || 336, API_CACHE_TTL_MS: process.env.API_CACHE_TTL_MS || 15000, API_RATE_LIMIT_WINDOW_MS: process.env.API_RATE_LIMIT_WINDOW_MS || 60000, API_RATE_LIMIT_MAX: process.env.API_RATE_LIMIT_MAX || 120, diff --git a/example.env b/example.env index e9a392f..06a0000 100644 --- a/example.env +++ b/example.env @@ -3,6 +3,9 @@ NODE_ENV=production PORT=3010 API_UPSTREAM=http://127.0.0.1:6000 PUBLIC_ORIGIN=https://example.com +DATABASE_URL=postgres://user:password@127.0.0.1:5432/tssbot +UPTIME_SAMPLE_INTERVAL_MS=1800000 +UPTIME_HISTORY_LIMIT=336 API_CACHE_TTL_MS=15000 API_RATE_LIMIT_WINDOW_MS=60000 API_RATE_LIMIT_MAX=120 diff --git a/package-lock.json b/package-lock.json index cdfc25b..4183d5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tailwindcss/vite": "^4.1.8", "@vitejs/plugin-react": "^4.5.0", + "pg": "^8.20.0", "react": "^19.1.0", "react-dom": "^19.1.0", "vite": "^6.3.5" @@ -2975,6 +2976,95 @@ "node": ">=8" } }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3021,6 +3111,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3178,6 +3307,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3392,6 +3530,15 @@ "node": ">=0.10.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 53256e7..d0ef7ab 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,10 @@ "dependencies": { "@tailwindcss/vite": "^4.1.8", "@vitejs/plugin-react": "^4.5.0", - "vite": "^6.3.5", + "pg": "^8.20.0", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "vite": "^6.3.5" }, "devDependencies": { "@eslint/js": "^9.27.0", diff --git a/server.cjs b/server.cjs index d6c18ea..a37d3f8 100644 --- a/server.cjs +++ b/server.cjs @@ -1,10 +1,45 @@ const fs = require('node:fs') const http = require('node:http') +const https = require('node:https') const path = require('node:path') +const { Pool } = require('pg') + +function loadEnvFile() { + const envPath = path.join(__dirname, '.env') + if (!fs.existsSync(envPath)) return + + const lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/) + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + + const separatorIndex = trimmed.indexOf('=') + if (separatorIndex === -1) continue + + const key = trimmed.slice(0, separatorIndex).trim() + let value = trimmed.slice(separatorIndex + 1).trim() + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + + if (key && (!process.env[key] || process.env[key] === '')) { + process.env[key] = value + } + } +} + +loadEnvFile() const PORT = Number(process.env.PORT || 3001) const API_UPSTREAM = process.env.API_UPSTREAM || 'http://127.0.0.1:6000' const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || '' +const UPTIME_DATABASE_URL = process.env.UPTIME_DATABASE_URL || process.env.DATABASE_URL || '' +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 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_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120) @@ -38,11 +73,178 @@ const jsonHeaders = { const apiCache = new Map() const rateLimits = new Map() +const uptimePool = UPTIME_DATABASE_URL + ? new Pool({ + connectionString: UPTIME_DATABASE_URL, + ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : undefined, + }) + : null +let uptimeReady = false +let latestUptimeSnapshot = null function sendJson(res, status, body, headers = {}) { send(res, status, JSON.stringify(body), { ...jsonHeaders, ...headers }) } +function requestJson(url, timeoutMs = 10000) { + return new Promise((resolve, reject) => { + const target = new URL(url) + const client = target.protocol === 'https:' ? https : http + const startedAt = Date.now() + const req = client.request( + target, + { + method: 'GET', + headers: { + accept: 'application/json', + 'user-agent': 'tssbot-uptime-sampler', + }, + timeout: timeoutMs, + }, + (response) => { + const chunks = [] + + response.on('data', (chunk) => chunks.push(chunk)) + response.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8') + const latency = Date.now() - startedAt + + if ((response.statusCode || 0) < 200 || (response.statusCode || 0) >= 300) { + reject(new Error(`HTTP ${response.statusCode || 0}`)) + return + } + + try { + resolve({ body: body ? JSON.parse(body) : null, latency }) + } catch { + resolve({ body: null, latency }) + } + }) + }, + ) + + req.on('timeout', () => { + req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)) + }) + req.on('error', reject) + req.end() + }) +} + +async function ensureUptimeSchema() { + if (!uptimePool || uptimeReady) return Boolean(uptimePool) + + await uptimePool.query(` + create table if not exists uptime_snapshots ( + id bigserial primary key, + checked_at timestamptz not null default now(), + website_ok boolean not null, + health_ok boolean not null, + tss_ok boolean not null, + ok boolean not null, + latency_ms integer not null, + details jsonb not null default '{}'::jsonb + ) + `) + await uptimePool.query(` + create index if not exists uptime_snapshots_checked_at_idx + on uptime_snapshots (checked_at desc) + `) + + uptimeReady = true + return true +} + +async function takeUptimeSnapshot() { + const startedAt = Date.now() + const details = { + website: { label: 'Online' }, + health: { label: 'Operational' }, + tss: { label: 'Not checked' }, + } + + const websiteOk = fs.existsSync(path.join(DIST_DIR, 'index.html')) + if (!websiteOk) details.website.label = 'Build not found' + + const healthOk = true + + let tssOk = false + try { + const tssUrl = new URL('/api/tss/leaderboard/teams?limit=1', API_UPSTREAM) + const result = await requestJson(tssUrl.toString()) + const teamCount = result.body?.teams?.length || result.body?.squadrons?.length || 0 + tssOk = true + details.tss = { + label: `${teamCount} sample team${teamCount === 1 ? '' : 's'} returned`, + latency_ms: result.latency, + } + } catch (error) { + details.tss = { label: error.message } + } + + const snapshot = { + checked_at: new Date().toISOString(), + website_ok: websiteOk, + health_ok: healthOk, + tss_ok: tssOk, + ok: websiteOk && healthOk && tssOk, + latency_ms: Date.now() - startedAt, + details, + } + + latestUptimeSnapshot = snapshot + + if (uptimePool) { + await ensureUptimeSchema() + await uptimePool.query( + `insert into uptime_snapshots + (website_ok, health_ok, tss_ok, ok, latency_ms, details) + values ($1, $2, $3, $4, $5, $6)`, + [snapshot.website_ok, snapshot.health_ok, snapshot.tss_ok, snapshot.ok, snapshot.latency_ms, snapshot.details], + ) + } + + return snapshot +} + +async function uptimeHistory() { + if (!uptimePool) { + return { + configured: false, + latest: latestUptimeSnapshot, + history: latestUptimeSnapshot ? [latestUptimeSnapshot] : [], + } + } + + await ensureUptimeSchema() + const result = await uptimePool.query( + `select checked_at, website_ok, health_ok, tss_ok, ok, latency_ms, details + from uptime_snapshots + order by checked_at desc + limit $1`, + [UPTIME_HISTORY_LIMIT], + ) + const history = result.rows.reverse() + + return { + configured: true, + latest: history.at(-1) || latestUptimeSnapshot, + history, + } +} + +function startUptimeSampler() { + takeUptimeSnapshot().catch((error) => { + console.error('Initial uptime snapshot failed:', error) + }) + + setInterval(() => { + takeUptimeSnapshot().catch((error) => { + console.error('Uptime snapshot failed:', error) + }) + }, UPTIME_SAMPLE_INTERVAL_MS).unref() +} + function publicOrigins(req) { const origins = PUBLIC_ORIGIN.split(',') .map((origin) => origin.trim()) @@ -266,6 +468,13 @@ http 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 === 'OPTIONS' && req.url.startsWith('/api/')) { sendJson(res, 403, { error: 'CORS requests are not allowed' }) return @@ -281,4 +490,10 @@ http .listen(PORT, '0.0.0.0', () => { console.log(`tssbot-web serving http://localhost:${PORT}`) console.log(`proxying API requests to ${API_UPSTREAM}`) + console.log( + uptimePool + ? `sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes` + : 'uptime database disabled; set DATABASE_URL or UPTIME_DATABASE_URL to persist snapshots', + ) + startUptimeSampler() }) diff --git a/src/App.jsx b/src/App.jsx index 65d3e29..ec3a1f9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -10,6 +10,7 @@ const dateFormat = new Intl.DateTimeFormat('en-GB', { const apiEndpoints = { health: '/health', + uptime: '/api/uptime', teams: '/api/tss/leaderboard/teams?limit=100', teamsHealth: '/api/tss/leaderboard/teams?limit=1', resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`, @@ -322,7 +323,7 @@ function App() { const controller = new AbortController() - async function checkUptime() { + async function loadUptime() { setUptime((current) => ({ status: current.status === 'ready' ? 'refreshing' : 'loading', checks: current.checks, @@ -330,65 +331,85 @@ function App() { updatedAt: current.updatedAt, })) - const startedAt = performance.now() - const healthResult = await fetchJson(apiEndpoints.health, controller.signal) - .then(() => ({ ok: true, label: 'Operational' })) - .catch((error) => ({ ok: false, label: error.message })) - - const apiResult = await fetchJson(apiEndpoints.teamsHealth, controller.signal) + fetchJson(apiEndpoints.uptime, controller.signal) .then((data) => { - const teamCount = data.teams?.length || data.squadrons?.length || 0 - return { ok: true, label: `${teamCount} sample team${teamCount === 1 ? '' : 's'} returned` } + const latest = data.latest + const checks = latest + ? [ + { + name: 'Website', + detail: 'App shell and static assets', + ok: latest.website_ok, + label: latest.details?.website?.label || (latest.website_ok ? 'Online' : 'Issue'), + latency: latest.latency_ms, + }, + { + name: 'Health endpoint', + detail: apiEndpoints.health, + ok: latest.health_ok, + label: latest.details?.health?.label || (latest.health_ok ? 'Operational' : 'Issue'), + latency: latest.latency_ms, + }, + { + name: 'TSS data proxy', + detail: apiEndpoints.teamsHealth, + ok: latest.tss_ok, + label: latest.details?.tss?.label || (latest.tss_ok ? 'Operational' : 'Issue'), + latency: latest.details?.tss?.latency_ms || latest.latency_ms, + }, + ] + : [] + + setUptime({ + status: 'ready', + checks, + history: (data.history || []).map((sample) => ({ + timestamp: new Date(sample.checked_at).getTime(), + onlineChecks: [sample.website_ok, sample.health_ok, sample.tss_ok].filter(Boolean).length, + totalChecks: 3, + ok: sample.ok, + })), + updatedAt: latest ? new Date(latest.checked_at).getTime() : null, + configured: data.configured, + }) }) - .catch((error) => ({ ok: false, label: error.message })) + .catch((error) => { + if (controller.signal.aborted) return - if (controller.signal.aborted) return - - setUptime((current) => { - const checks = [ - { - name: 'Website', - detail: 'App shell and static assets', - ok: true, - label: 'Online', - latency: Math.round(performance.now() - startedAt), - }, - { - name: 'Health endpoint', - detail: apiEndpoints.health, - ok: healthResult.ok, - label: healthResult.label, - latency: Math.round(performance.now() - startedAt), - }, - { - name: 'TSS data proxy', - detail: apiEndpoints.teamsHealth, - ok: apiResult.ok, - label: apiResult.label, - latency: Math.round(performance.now() - startedAt), - }, - ] - const onlineChecks = checks.filter((check) => check.ok).length - - return { - status: 'ready', - updatedAt: Date.now(), - checks, - history: [ - ...current.history, - { - timestamp: Date.now(), - onlineChecks, - totalChecks: checks.length, - ok: onlineChecks === checks.length, - }, - ].slice(-48), - } - }) + setUptime({ + status: 'error', + updatedAt: null, + configured: false, + history: [], + checks: [ + { + name: 'Website', + detail: 'App shell and static assets', + ok: true, + label: 'Online', + latency: 0, + }, + { + name: 'Health endpoint', + detail: apiEndpoints.health, + ok: false, + label: error.message, + latency: 0, + }, + { + name: 'TSS data proxy', + detail: apiEndpoints.teamsHealth, + ok: false, + label: 'Uptime history unavailable', + latency: 0, + }, + ], + }) + }) } - checkUptime() - const timer = window.setInterval(checkUptime, 30000) + loadUptime() + const timer = window.setInterval(loadUptime, 60000) return () => { window.clearInterval(timer) @@ -1093,7 +1114,7 @@ function UptimePage({ uptime }) { {allOperational ? 'All systems operational' : 'Status check'}

- Last checked {updatedAt}. Refreshes every 30 seconds while this page is open. + Last server snapshot {updatedAt}. The page refreshes once a minute.

Availability timeline

- Last {formatNumber(history.length)} checks from this browser session + Last {formatNumber(history.length)} persisted server snapshots