postgres uptime

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