| 1 | import { useEffect } from "react"; |
| 2 | import { Link, useRevalidator } from "react-router"; |
| 3 | |
| 4 | import type { Route } from "./+types/status"; |
| 5 | import { TrustPage } from "../components/trust-page"; |
| 6 | import { StatusDot } from "../components/footer"; |
| 7 | import { TimeAgo } from "../components/ui"; |
| 8 | import { CONTACT } from "../lib/legal"; |
| 9 | import { page } from "../lib/meta"; |
| 10 | import { type ComponentStatus, INCIDENT_DAYS, type Incident, STATE_LABEL, dotClass } from "../lib/status"; |
| 11 | import { currentStatus } from "../lib/status.server"; |
| 12 | |
| 13 | export function meta({ loaderData, ...args }: Route.MetaArgs) { |
| 14 | return page(args, { |
| 15 | title: "Status · g1t", |
| 16 | description: loaderData |
| 17 | ? `${loaderData.overall.line}. Live status of g1t: the website, API, git, MCP, docs, deployments, agents and billing.` |
| 18 | : "Live status of g1t: the website, API, git, MCP, docs, deployments, agents and billing.", |
| 19 | }); |
| 20 | } |
| 21 | |
| 22 | export async function loader() { |
| 23 | return currentStatus(); |
| 24 | } |
| 25 | |
| 26 | /** How often an open page checks again: as often as the checks run. */ |
| 27 | const REFRESH_MS = 60_000; |
| 28 | |
| 29 | const BANNER: Record<string, string> = { |
| 30 | up: "border-accent/30 bg-accent/5", |
| 31 | degraded: "border-warn/40 bg-warn/5", |
| 32 | down: "border-danger/40 bg-danger/5", |
| 33 | unknown: "border-line bg-surface", |
| 34 | }; |
| 35 | |
| 36 | const STATE_TEXT: Record<ComponentStatus["state"], string> = { |
| 37 | up: "text-accent", |
| 38 | degraded: "text-warn", |
| 39 | down: "text-danger", |
| 40 | unmonitored: "text-faint", |
| 41 | }; |
| 42 | |
| 43 | function ComponentRow({ component }: { component: ComponentStatus }) { |
| 44 | return ( |
| 45 | <li className="flex flex-col gap-2 px-4 py-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6"> |
| 46 | <div className="min-w-0"> |
| 47 | <p className="flex flex-wrap items-center gap-x-2 font-medium"> |
| 48 | <span aria-hidden className={`size-2 shrink-0 rounded-full ${dotClass(component.state)}`} /> |
| 49 | {component.name} |
| 50 | {/* Beside the name when there's room, under it on a phone. */} |
| 51 | <span className="w-full truncate pl-4 font-mono text-xs font-normal text-faint sm:w-auto sm:pl-0"> |
| 52 | {component.address} |
| 53 | </span> |
| 54 | </p> |
| 55 | <p className="mt-1 pl-4 text-sm text-muted">{component.checks}</p> |
| 56 | </div> |
| 57 | <div className="shrink-0 pl-4 sm:pl-0 sm:text-right"> |
| 58 | <p className={`text-sm font-medium ${STATE_TEXT[component.state]}`}>{STATE_LABEL[component.state]}</p> |
| 59 | {component.state !== "unmonitored" && <p className="text-xs text-faint">{component.detail}</p>} |
| 60 | </div> |
| 61 | </li> |
| 62 | ); |
| 63 | } |
| 64 | |
| 65 | function IncidentItem({ incident }: { incident: Incident }) { |
| 66 | const open = incident.resolved_at == null; |
| 67 | return ( |
| 68 | <li className="px-4 py-4"> |
| 69 | <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1"> |
| 70 | <p className="font-medium">{incident.title}</p> |
| 71 | <p className={`text-xs font-medium ${open ? (incident.impact === "down" ? "text-danger" : "text-warn") : "text-faint"}`}> |
| 72 | {open ? "Ongoing" : "Resolved"} |
| 73 | </p> |
| 74 | </div> |
| 75 | <p className="mt-0.5 text-xs text-faint"> |
| 76 | Started <TimeAgo at={incident.started_at} /> |
| 77 | {incident.resolved_at && ( |
| 78 | <> |
| 79 | {" "}· resolved <TimeAgo at={incident.resolved_at} /> |
| 80 | </> |
| 81 | )} |
| 82 | </p> |
| 83 | {incident.updates.length > 0 && ( |
| 84 | <ol className="mt-3 space-y-2 border-l border-line pl-4 text-sm"> |
| 85 | {incident.updates.map((update) => ( |
| 86 | <li key={update.at}> |
| 87 | <span className="text-faint"> |
| 88 | <TimeAgo at={update.at} />: |
| 89 | </span>{" "} |
| 90 | <span className="text-fg/90">{update.text}</span> |
| 91 | </li> |
| 92 | ))} |
| 93 | </ol> |
| 94 | )} |
| 95 | </li> |
| 96 | ); |
| 97 | } |
| 98 | |
| 99 | export default function Status({ loaderData }: Route.ComponentProps) { |
| 100 | const { overall, components, incidents, checked_at } = loaderData; |
| 101 | const revalidator = useRevalidator(); |
| 102 | // Keep an open page current, without the visitor reloading it. |
| 103 | useEffect(() => { |
| 104 | const timer = setInterval(() => { |
| 105 | if (document.visibilityState === "visible" && revalidator.state === "idle") revalidator.revalidate(); |
| 106 | }, REFRESH_MS); |
| 107 | return () => clearInterval(timer); |
| 108 | }, [revalidator]); |
| 109 | const monitored = components.filter((c) => c.state !== "unmonitored").length; |
| 110 | return ( |
| 111 | <TrustPage eyebrow="Status" title="g1t status"> |
| 112 | <div className="max-w-3xl"> |
| 113 | <div className={`flex items-start gap-3 rounded-xl border px-5 py-4 ${BANNER[overall.state]}`}> |
| 114 | <StatusDot state={overall.state} className="mt-2" /> |
| 115 | <div className="min-w-0"> |
| 116 | <p className="text-lg font-semibold tracking-tight">{overall.line}</p> |
| 117 | <p className="mt-0.5 text-sm text-muted"> |
| 118 | {monitored} of {components.length} parts checked, last <TimeAgo at={checked_at} />. Checked again every minute. |
| 119 | </p> |
| 120 | </div> |
| 121 | </div> |
| 122 | |
| 123 | <ul className="mt-8 divide-y divide-line rounded-xl border border-line"> |
| 124 | {components.map((component) => ( |
| 125 | <ComponentRow key={component.key} component={component} /> |
| 126 | ))} |
| 127 | </ul> |
| 128 | <p className="mt-3 text-xs text-faint"> |
| 129 | Each check is one quick request, the same a visitor's would make, from g1t's own servers. A part that |
| 130 | answers in over 1.5 seconds shows as degraded; one that fails or takes over 3 seconds, as down. Parts we |
| 131 | can't check yet say so, rather than showing green. The same data is at{" "} |
| 132 | <a href="/status.json" className="font-mono hover:text-fg"> |
| 133 | /status.json |
| 134 | </a> |
| 135 | . |
| 136 | </p> |
| 137 | |
| 138 | <section className="mt-14"> |
| 139 | <h2 className="text-xl font-semibold tracking-tight">Incidents</h2> |
| 140 | {incidents.length === 0 ? ( |
| 141 | <p className="mt-3 text-sm text-muted">No incidents reported in the last {INCIDENT_DAYS} days.</p> |
| 142 | ) : ( |
| 143 | <ul className="mt-4 divide-y divide-line rounded-xl border border-line"> |
| 144 | {incidents.map((incident) => ( |
| 145 | <IncidentItem key={incident.id} incident={incident} /> |
| 146 | ))} |
| 147 | </ul> |
| 148 | )} |
| 149 | <p className="mt-4 text-sm text-muted"> |
| 150 | Something broken that this page doesn't show? Tell us at{" "} |
| 151 | <a href={`mailto:${CONTACT.support}`} className="text-accent hover:underline"> |
| 152 | {CONTACT.support} |
| 153 | </a> |
| 154 | , or see <Link to="/support" className="text-accent hover:underline">support</Link>. |
| 155 | </p> |
| 156 | </section> |
| 157 | </div> |
| 158 | </TrustPage> |
| 159 | ); |
| 160 | } |