postgres uptime
This commit is contained in:
+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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user