page.tsx275 lines · main
| 1 | import { redirect } from 'next/navigation'; |
| 2 | |
| 3 | import { DocsShell } from '../../components/shell'; |
| 4 | import { fetchIncidents } from '../../lib/incidents'; |
| 5 | |
| 6 | export const metadata = { title: 'status' }; |
| 7 | export const dynamic = 'force-dynamic'; |
| 8 | export const revalidate = 0; |
| 9 | |
| 10 | interface ServiceProbe { |
| 11 | name: string; |
| 12 | url: string; |
| 13 | description: string; |
| 14 | // For api /ready we extract per-dependency status from the response |
| 15 | // body. For simple /health pings this stays false. |
| 16 | expandChecks?: boolean; |
| 17 | } |
| 18 | |
| 19 | // Runtime isn't probed directly — it lives on an internal network and |
| 20 | // the api /ready endpoint already reports its reachability under |
| 21 | // `checks.runtime`. The status page extracts that field when api/ready |
| 22 | // returns. Realtime is on its own public subdomain (realtime.briven.tech). |
| 23 | const PROBES: readonly ServiceProbe[] = [ |
| 24 | { |
| 25 | name: 'api', |
| 26 | url: process.env.BRIVEN_STATUS_API_URL ?? 'https://api.briven.tech/ready', |
| 27 | description: 'control plane — accounts, projects, billing, deploy intake', |
| 28 | expandChecks: true, |
| 29 | }, |
| 30 | { |
| 31 | name: 'realtime', |
| 32 | url: process.env.BRIVEN_STATUS_REALTIME_URL ?? 'https://realtime.briven.tech/ready', |
| 33 | description: 'reactive query websocket service', |
| 34 | }, |
| 35 | ]; |
| 36 | |
| 37 | interface ProbeResult { |
| 38 | name: string; |
| 39 | description: string; |
| 40 | ok: boolean; |
| 41 | status: number | null; |
| 42 | durationMs: number; |
| 43 | detail: string | null; |
| 44 | // For probes with expandChecks=true: per-dependency states extracted |
| 45 | // from the api /ready body. null when the probe didn't ask for them. |
| 46 | checks: Record<string, 'ok' | 'unreachable' | 'not_configured' | 'degraded'> | null; |
| 47 | } |
| 48 | |
| 49 | async function probe(svc: ServiceProbe): Promise<ProbeResult> { |
| 50 | const t0 = Date.now(); |
| 51 | try { |
| 52 | const res = await fetch(svc.url, { |
| 53 | // Tight timeout — the status page renders fast even when a service |
| 54 | // is hung. AbortSignal.timeout is supported by node 20+ / bun. |
| 55 | signal: AbortSignal.timeout(3000), |
| 56 | headers: { accept: 'application/json' }, |
| 57 | cache: 'no-store', |
| 58 | }); |
| 59 | const durationMs = Date.now() - t0; |
| 60 | const body = await res.text().catch(() => ''); |
| 61 | let detail: string | null = null; |
| 62 | let checks: ProbeResult['checks'] = null; |
| 63 | if (svc.expandChecks) { |
| 64 | try { |
| 65 | const parsed = JSON.parse(body) as { checks?: Record<string, string> }; |
| 66 | if (parsed.checks) checks = parsed.checks as ProbeResult['checks']; |
| 67 | } catch { |
| 68 | // non-JSON body; checks stay null |
| 69 | } |
| 70 | } |
| 71 | if (!res.ok && !detail) { |
| 72 | detail = body.slice(0, 200) || null; |
| 73 | if (detail && body.length > 200) detail = `${detail}…`; |
| 74 | } |
| 75 | return { |
| 76 | name: svc.name, |
| 77 | description: svc.description, |
| 78 | ok: res.ok, |
| 79 | status: res.status, |
| 80 | durationMs, |
| 81 | detail, |
| 82 | checks, |
| 83 | }; |
| 84 | } catch (err) { |
| 85 | return { |
| 86 | name: svc.name, |
| 87 | description: svc.description, |
| 88 | ok: false, |
| 89 | status: null, |
| 90 | durationMs: Date.now() - t0, |
| 91 | detail: err instanceof Error ? err.message : 'unreachable', |
| 92 | checks: null, |
| 93 | }; |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | export default async function StatusPage() { |
| 98 | // Once status.briven.tech is the canonical home, redirect bookmarks |
| 99 | // + RSS readers that still point at docs.briven.tech/status to the |
| 100 | // dedicated subdomain. Gated by env so the redirect only fires after |
| 101 | // the operator has verified the new host is serving. See |
| 102 | // docs/runbooks/status-domain-cutover.md §3. |
| 103 | if (process.env.BRIVEN_STATUS_CANONICAL === 'status') { |
| 104 | redirect('https://status.briven.tech'); |
| 105 | } |
| 106 | const [probes, incidents] = await Promise.all([ |
| 107 | Promise.all(PROBES.map(probe)), |
| 108 | fetchIncidents({ limit: 50, fresh: true }), |
| 109 | ]); |
| 110 | const allOk = probes.every((p) => p.ok); |
| 111 | const anyDown = probes.some((p) => !p.ok); |
| 112 | |
| 113 | return ( |
| 114 | <DocsShell> |
| 115 | <h1 className="font-mono text-2xl tracking-tight">status</h1> |
| 116 | <p className="mt-2 font-mono text-sm text-[var(--color-text-muted)]"> |
| 117 | live health of every briven service. probes run when this page is rendered (no cache). |
| 118 | for incident history + post-mortems, see the changelog. |
| 119 | </p> |
| 120 | |
| 121 | <div className="mt-6 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4 font-mono text-xs text-[var(--color-text-muted)]"> |
| 122 | <p className="text-[var(--color-text)]">if sign-in feels broken</p> |
| 123 | <ul className="mt-2 list-disc space-y-1 pl-5"> |
| 124 | <li> |
| 125 | look at api → <code>redis</code>: abuse limits need it <code>ok</code>. logins can still |
| 126 | work if only the cache is down (limits may be softer). |
| 127 | </li> |
| 128 | <li>open sessions usually keep working; new magic links need mail delivery.</li> |
| 129 | <li> |
| 130 | ops notes: repo <code>docs/S6-RELIABILITY.md</code> (Redis behavior + isolation). |
| 131 | </li> |
| 132 | </ul> |
| 133 | </div> |
| 134 | |
| 135 | <div |
| 136 | className={`mt-8 rounded-md border p-4 font-mono text-sm ${ |
| 137 | allOk |
| 138 | ? 'border-[var(--color-primary)] text-[var(--color-text)]' |
| 139 | : 'border-[var(--color-text-error)] text-[var(--color-text)]' |
| 140 | }`} |
| 141 | > |
| 142 | <p> |
| 143 | <strong> |
| 144 | {allOk ? 'all systems operational' : anyDown ? 'incident in progress' : 'partial degradation'} |
| 145 | </strong> |
| 146 | </p> |
| 147 | <p className="mt-1 text-xs text-[var(--color-text-muted)]"> |
| 148 | last checked {new Date().toISOString()} |
| 149 | </p> |
| 150 | </div> |
| 151 | |
| 152 | <ul className="mt-8 flex flex-col gap-3"> |
| 153 | {probes.map((p) => ( |
| 154 | <li |
| 155 | key={p.name} |
| 156 | className="flex items-start justify-between gap-4 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4" |
| 157 | > |
| 158 | <div className="min-w-0 flex-1"> |
| 159 | <div className="flex items-center gap-2"> |
| 160 | <span |
| 161 | aria-hidden |
| 162 | className={`inline-block h-2 w-2 shrink-0 rounded-full ${ |
| 163 | p.ok ? 'bg-[var(--color-primary)]' : 'bg-[var(--color-text-error)]' |
| 164 | }`} |
| 165 | /> |
| 166 | <p className="font-mono text-sm">{p.name}</p> |
| 167 | <span className="font-mono text-xs text-[var(--color-text-subtle)]"> |
| 168 | {p.ok ? 'operational' : 'down'} |
| 169 | </span> |
| 170 | </div> |
| 171 | <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">{p.description}</p> |
| 172 | {p.checks ? ( |
| 173 | <ul className="mt-2 flex flex-wrap gap-2 font-mono text-xs"> |
| 174 | {Object.entries(p.checks).map(([name, state]) => { |
| 175 | const stateClass = |
| 176 | state === 'ok' |
| 177 | ? 'text-[var(--color-primary)] bg-[var(--color-primary-subtle)]' |
| 178 | : state === 'not_configured' |
| 179 | ? 'text-[var(--color-text-subtle)] bg-[var(--color-surface)]' |
| 180 | : 'text-[var(--color-text-error)] bg-red-500/10'; |
| 181 | return ( |
| 182 | <li key={name} className={`inline-flex rounded-md px-2 py-0.5 ${stateClass}`}> |
| 183 | {name.replace(/_/g, ' ')} · {state} |
| 184 | </li> |
| 185 | ); |
| 186 | })} |
| 187 | </ul> |
| 188 | ) : null} |
| 189 | {p.detail ? ( |
| 190 | <p className="mt-2 truncate font-mono text-xs text-[var(--color-text-error)]" title={p.detail}> |
| 191 | {p.detail} |
| 192 | </p> |
| 193 | ) : null} |
| 194 | </div> |
| 195 | <div className="text-right font-mono text-xs text-[var(--color-text-subtle)]"> |
| 196 | <p>{p.status ?? '—'}</p> |
| 197 | <p className="mt-1">{p.durationMs}ms</p> |
| 198 | </div> |
| 199 | </li> |
| 200 | ))} |
| 201 | </ul> |
| 202 | |
| 203 | <section className="mt-12"> |
| 204 | <div className="flex items-center justify-between"> |
| 205 | <h2 className="font-mono text-lg">incident history</h2> |
| 206 | <a |
| 207 | href="/api/status/incidents.xml" |
| 208 | className="font-mono text-xs text-[var(--color-text-subtle)] hover:text-[var(--color-text-muted)]" |
| 209 | > |
| 210 | rss |
| 211 | </a> |
| 212 | </div> |
| 213 | {incidents.length === 0 ? ( |
| 214 | <p className="mt-3 font-mono text-sm text-[var(--color-text-muted)]"> |
| 215 | no incidents on record. when one happens, an entry lands here and on the rss feed. |
| 216 | </p> |
| 217 | ) : ( |
| 218 | <ul className="mt-4 flex flex-col gap-3"> |
| 219 | {incidents.map((inc) => ( |
| 220 | <li |
| 221 | key={inc.id} |
| 222 | id={inc.id} |
| 223 | className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4" |
| 224 | > |
| 225 | <div className="flex items-start justify-between gap-3"> |
| 226 | <div className="min-w-0 flex-1"> |
| 227 | <div className="flex items-center gap-2 font-mono text-xs"> |
| 228 | <span |
| 229 | className={`rounded-md px-2 py-0.5 ${ |
| 230 | inc.severity === 'critical' |
| 231 | ? 'bg-red-500/15 text-red-300' |
| 232 | : inc.severity === 'major' |
| 233 | ? 'bg-yellow-500/15 text-yellow-300' |
| 234 | : inc.severity === 'minor' |
| 235 | ? 'bg-[var(--color-surface-raised)] text-[var(--color-text-subtle)]' |
| 236 | : 'bg-[var(--color-primary-subtle)] text-[var(--color-primary)]' |
| 237 | }`} |
| 238 | > |
| 239 | {inc.severity} |
| 240 | </span> |
| 241 | <span className="text-[var(--color-text-muted)]"> |
| 242 | {inc.services.join(', ')} |
| 243 | </span> |
| 244 | <span className="text-[var(--color-text-subtle)]"> |
| 245 | {inc.resolvedAt ? 'resolved' : 'ongoing'} |
| 246 | </span> |
| 247 | </div> |
| 248 | <p className="mt-2 font-mono text-sm text-[var(--color-text)]"> |
| 249 | {inc.summary} |
| 250 | </p> |
| 251 | {inc.postmortem ? ( |
| 252 | <pre className="mt-2 whitespace-pre-wrap font-mono text-xs text-[var(--color-text-muted)]"> |
| 253 | {inc.postmortem} |
| 254 | </pre> |
| 255 | ) : null} |
| 256 | </div> |
| 257 | <div className="text-right font-mono text-[10px] text-[var(--color-text-subtle)]"> |
| 258 | <p>{inc.startedAt.slice(0, 10)}</p> |
| 259 | {inc.resolvedAt ? <p className="mt-1">→ {inc.resolvedAt.slice(0, 10)}</p> : null} |
| 260 | </div> |
| 261 | </div> |
| 262 | </li> |
| 263 | ))} |
| 264 | </ul> |
| 265 | )} |
| 266 | </section> |
| 267 | |
| 268 | <p className="mt-12 font-mono text-xs text-[var(--color-text-subtle)]"> |
| 269 | page renders fresh on every request (no cache). probe timeout: 3000ms. incidents are |
| 270 | operator-curated and read live from the api (/v1/status/incidents); if the api is |
| 271 | unreachable, the list renders empty rather than failing the page. |
| 272 | </p> |
| 273 | </DocsShell> |
| 274 | ); |
| 275 | } |