postgres uptime
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+147
@@ -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",
|
||||
|
||||
+3
-2
@@ -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",
|
||||
|
||||
+215
@@ -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()
|
||||
})
|
||||
|
||||
+78
-57
@@ -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'}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-text-soft">
|
||||
Last checked {updatedAt}. Refreshes every 30 seconds while this page is open.
|
||||
Last server snapshot {updatedAt}. The page refreshes once a minute.
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
@@ -1112,7 +1133,7 @@ function UptimePage({ uptime }) {
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Availability timeline</h2>
|
||||
<p className="mt-1 text-sm text-text-soft">
|
||||
Last {formatNumber(history.length)} checks from this browser session
|
||||
Last {formatNumber(history.length)} persisted server snapshots
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-fury-cyan">
|
||||
|
||||
Reference in New Issue
Block a user