postgres uptime
This commit is contained in:
@@ -67,6 +67,22 @@ API_RATE_LIMIT_WINDOW_MS=60000
|
|||||||
API_RATE_LIMIT_MAX=120
|
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
|
## 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
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ module.exports = {
|
|||||||
PORT: process.env.PORT || 3010,
|
PORT: process.env.PORT || 3010,
|
||||||
API_UPSTREAM: process.env.API_UPSTREAM || 'http://127.0.0.1:6000',
|
API_UPSTREAM: process.env.API_UPSTREAM || 'http://127.0.0.1:6000',
|
||||||
PUBLIC_ORIGIN: process.env.PUBLIC_ORIGIN || '',
|
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_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_WINDOW_MS: process.env.API_RATE_LIMIT_WINDOW_MS || 60000,
|
||||||
API_RATE_LIMIT_MAX: process.env.API_RATE_LIMIT_MAX || 120,
|
API_RATE_LIMIT_MAX: process.env.API_RATE_LIMIT_MAX || 120,
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ NODE_ENV=production
|
|||||||
PORT=3010
|
PORT=3010
|
||||||
API_UPSTREAM=http://127.0.0.1:6000
|
API_UPSTREAM=http://127.0.0.1:6000
|
||||||
PUBLIC_ORIGIN=https://example.com
|
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_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
|
||||||
|
|||||||
Generated
+147
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.8",
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
"@vitejs/plugin-react": "^4.5.0",
|
"@vitejs/plugin-react": "^4.5.0",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.5"
|
||||||
@@ -2975,6 +2976,95 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -3021,6 +3111,45 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -3178,6 +3307,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||||
@@ -3392,6 +3530,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
+3
-2
@@ -14,9 +14,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.8",
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
"@vitejs/plugin-react": "^4.5.0",
|
"@vitejs/plugin-react": "^4.5.0",
|
||||||
"vite": "^6.3.5",
|
"pg": "^8.20.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0",
|
||||||
|
"vite": "^6.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.27.0",
|
"@eslint/js": "^9.27.0",
|
||||||
|
|||||||
+215
@@ -1,10 +1,45 @@
|
|||||||
const fs = require('node:fs')
|
const fs = require('node:fs')
|
||||||
const http = require('node:http')
|
const http = require('node:http')
|
||||||
|
const https = require('node:https')
|
||||||
const path = require('node:path')
|
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 PORT = Number(process.env.PORT || 3001)
|
||||||
const API_UPSTREAM = process.env.API_UPSTREAM || 'http://127.0.0.1:6000'
|
const API_UPSTREAM = process.env.API_UPSTREAM || 'http://127.0.0.1:6000'
|
||||||
const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || ''
|
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_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)
|
||||||
@@ -38,11 +73,178 @@ const jsonHeaders = {
|
|||||||
|
|
||||||
const apiCache = new Map()
|
const apiCache = new Map()
|
||||||
const rateLimits = 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 = {}) {
|
function sendJson(res, status, body, headers = {}) {
|
||||||
send(res, status, JSON.stringify(body), { ...jsonHeaders, ...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) {
|
function publicOrigins(req) {
|
||||||
const origins = PUBLIC_ORIGIN.split(',')
|
const origins = PUBLIC_ORIGIN.split(',')
|
||||||
.map((origin) => origin.trim())
|
.map((origin) => origin.trim())
|
||||||
@@ -266,6 +468,13 @@ http
|
|||||||
return
|
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/')) {
|
if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) {
|
||||||
sendJson(res, 403, { error: 'CORS requests are not allowed' })
|
sendJson(res, 403, { error: 'CORS requests are not allowed' })
|
||||||
return
|
return
|
||||||
@@ -281,4 +490,10 @@ http
|
|||||||
.listen(PORT, '0.0.0.0', () => {
|
.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`tssbot-web serving http://localhost:${PORT}`)
|
console.log(`tssbot-web serving http://localhost:${PORT}`)
|
||||||
console.log(`proxying API requests to ${API_UPSTREAM}`)
|
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 = {
|
const apiEndpoints = {
|
||||||
health: '/health',
|
health: '/health',
|
||||||
|
uptime: '/api/uptime',
|
||||||
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)}`,
|
||||||
@@ -322,7 +323,7 @@ function App() {
|
|||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
||||||
async function checkUptime() {
|
async function loadUptime() {
|
||||||
setUptime((current) => ({
|
setUptime((current) => ({
|
||||||
status: current.status === 'ready' ? 'refreshing' : 'loading',
|
status: current.status === 'ready' ? 'refreshing' : 'loading',
|
||||||
checks: current.checks,
|
checks: current.checks,
|
||||||
@@ -330,65 +331,85 @@ function App() {
|
|||||||
updatedAt: current.updatedAt,
|
updatedAt: current.updatedAt,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const startedAt = performance.now()
|
fetchJson(apiEndpoints.uptime, controller.signal)
|
||||||
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)
|
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const teamCount = data.teams?.length || data.squadrons?.length || 0
|
const latest = data.latest
|
||||||
return { ok: true, label: `${teamCount} sample team${teamCount === 1 ? '' : 's'} returned` }
|
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({
|
||||||
|
status: 'error',
|
||||||
setUptime((current) => {
|
updatedAt: null,
|
||||||
const checks = [
|
configured: false,
|
||||||
{
|
history: [],
|
||||||
name: 'Website',
|
checks: [
|
||||||
detail: 'App shell and static assets',
|
{
|
||||||
ok: true,
|
name: 'Website',
|
||||||
label: 'Online',
|
detail: 'App shell and static assets',
|
||||||
latency: Math.round(performance.now() - startedAt),
|
ok: true,
|
||||||
},
|
label: 'Online',
|
||||||
{
|
latency: 0,
|
||||||
name: 'Health endpoint',
|
},
|
||||||
detail: apiEndpoints.health,
|
{
|
||||||
ok: healthResult.ok,
|
name: 'Health endpoint',
|
||||||
label: healthResult.label,
|
detail: apiEndpoints.health,
|
||||||
latency: Math.round(performance.now() - startedAt),
|
ok: false,
|
||||||
},
|
label: error.message,
|
||||||
{
|
latency: 0,
|
||||||
name: 'TSS data proxy',
|
},
|
||||||
detail: apiEndpoints.teamsHealth,
|
{
|
||||||
ok: apiResult.ok,
|
name: 'TSS data proxy',
|
||||||
label: apiResult.label,
|
detail: apiEndpoints.teamsHealth,
|
||||||
latency: Math.round(performance.now() - startedAt),
|
ok: false,
|
||||||
},
|
label: 'Uptime history unavailable',
|
||||||
]
|
latency: 0,
|
||||||
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),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
checkUptime()
|
loadUptime()
|
||||||
const timer = window.setInterval(checkUptime, 30000)
|
const timer = window.setInterval(loadUptime, 60000)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearInterval(timer)
|
window.clearInterval(timer)
|
||||||
@@ -1093,7 +1114,7 @@ function UptimePage({ uptime }) {
|
|||||||
{allOperational ? 'All systems operational' : 'Status check'}
|
{allOperational ? 'All systems operational' : 'Status check'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-text-soft">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -1112,7 +1133,7 @@ function UptimePage({ uptime }) {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">Availability timeline</h2>
|
<h2 className="text-lg font-semibold">Availability timeline</h2>
|
||||||
<p className="mt-1 text-sm text-text-soft">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-semibold text-fury-cyan">
|
<p className="text-sm font-semibold text-fury-cyan">
|
||||||
|
|||||||
Reference in New Issue
Block a user