page.tsx275 lines · main
1import { redirect } from 'next/navigation';
2
3import { DocsShell } from '../../components/shell';
4import { fetchIncidents } from '../../lib/incidents';
5
6export const metadata = { title: 'status' };
7export const dynamic = 'force-dynamic';
8export const revalidate = 0;
9
10interface 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).
23const 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
37interface 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
49async 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
97export 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}