postgres uptime

This commit is contained in:
2026-05-14 21:16:06 +01:00
parent 11e076394b
commit e58adcc716
7 changed files with 466 additions and 59 deletions
+215
View File
@@ -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()
})