Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 85 additions & 22 deletions apps/web/src/components/api-health-gate.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { useCallback, useEffect, useState } from "react";
import { Link, Outlet, useLocation } from "react-router";
import { CheckIcon } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { checkApiHealth, requestWake } from "@/lib/api";

const CONTACT_EMAIL = "murugu2001@gmail.com";
const POLL_INTERVAL_MS = 8000;

// Heuristic cold-start phases (keyed to elapsed seconds). The box can't report
// real boot progress to a page that can't reach it, so this is an honest estimate
// matching the actual sequence; the gate flips to the app the instant /health 200s.
const BOOT_PHASES = [
{ at: 0, label: "Waking up a server" },
{ at: 20, label: "Pulling container images (~3 GB)" },
{ at: 110, label: "Starting the pipeline — Postgres, Redpanda, Debezium, API" },
{ at: 165, label: "Reconnecting" },
] as const;

type Status = "checking" | "up" | "down";

/**
Expand Down Expand Up @@ -91,29 +102,80 @@ function InstanceStarting({
retrying: boolean;
}) {
const isPublicForm = variant === "publicForm";
// Count elapsed seconds so the screen shows motion + an honest "how long" cue.
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const id = window.setInterval(() => setElapsed((e) => e + 1), 1000);
return () => window.clearInterval(id);
}, []);
const phaseIdx = BOOT_PHASES.reduce((acc, p, i) => (elapsed >= p.at ? i : acc), 0);
const overtime = elapsed > 210;
const mmss = `${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, "0")}`;

return (
<div className="flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-md rounded-xl border border-border bg-card p-8 text-center shadow-sm">
<div className="mx-auto mb-5 flex size-12 items-center justify-center rounded-full bg-muted">
<div className="size-5 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
</div>
<h1 className="text-xl font-semibold">
{isPublicForm ? "This form is waking up" : "Waking up the backend"}
</h1>
{isPublicForm ? (
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
To save resources, EventForm&rsquo;s backend powers down when idle.
Ask whoever shared this link to <strong>sign in</strong> — that starts
it back up (a couple of minutes). If that&rsquo;s you, sign in below;
this page loads the form on its own once it&rsquo;s running.
</p>
) : (
<div className="w-full max-w-md rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="text-center">
<div className="mx-auto mb-5 flex size-12 items-center justify-center rounded-full bg-muted">
<div className="size-5 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
</div>
<h1 className="text-xl font-semibold">
{isPublicForm ? "This form is waking up" : "Waking up EventForm"}
</h1>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
To save resources, EventForm&rsquo;s backend powers down when idle, so
the first visit takes a minute. <strong>Sign in</strong> to wake it —
this page reconnects automatically once it&rsquo;s up.
To stay (nearly) free to host, EventForm runs on a single small server
that <strong>powers down when idle</strong>. Your visit is waking it —
the first load takes about <strong>2&ndash;3 minutes</strong> while it
boots and pulls ~3&nbsp;GB of container images, then it&rsquo;s instant
until it idles again.
</p>
)}
{isPublicForm && (
<p className="mt-2 text-xs text-muted-foreground">
Only a signed-in user can start it — if it doesn&rsquo;t come up, ask
whoever shared this link to sign in.
</p>
)}
</div>

{/* Heuristic progress — advances on elapsed time; gives a sense of motion
and explains where the 2–3 min goes (the image pull is the long part). */}
<ol className="mt-6 space-y-2.5">
{BOOT_PHASES.map((p, i) => {
const done = i < phaseIdx;
const active = i === phaseIdx;
return (
<li key={p.label} className="flex items-center gap-3 text-sm">
<span className="flex size-5 shrink-0 items-center justify-center">
{done ? (
<CheckIcon className="size-4 text-green-600 dark:text-green-400" />
) : active ? (
<span className="size-3 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
) : (
<span className="size-1.5 rounded-full bg-muted-foreground/30" />
)}
</span>
<span
className={
done
? "text-muted-foreground"
: active
? "font-medium text-foreground"
: "text-muted-foreground/50"
}
>
{p.label}
</span>
</li>
);
})}
</ol>

<p className="mt-4 text-center text-xs tabular-nums text-muted-foreground">
{overtime
? "Taking longer than usual — it should still come up; you can retry below."
: `${mmss} elapsed · usually ~2–3 min · reconnects on its own`}
</p>

<div className="mt-6 flex flex-col gap-3">
{/* Signing in is what fires the wake (the wake endpoint is Cognito-only),
so the sign-in CTA shows on every down screen — a logged-out visitor
Expand All @@ -126,13 +188,14 @@ function InstanceStarting({
</Button>
<Link
to="/"
className="text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
className="text-center text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
Back to home
</Link>
</div>
<p className="mt-6 border-t border-border pt-4 text-xs text-muted-foreground">
Still not loading after a few minutes?{" "}

<p className="mt-6 border-t border-border pt-4 text-center text-xs text-muted-foreground">
Still nothing after a few minutes?{" "}
<a
href={`mailto:${CONTACT_EMAIL}?subject=EventForm%20appears%20to%20be%20down`}
className="font-medium text-foreground underline underline-offset-4"
Expand Down
18 changes: 17 additions & 1 deletion infra/cdk/lib/compute-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,15 @@ export class ComputeStack extends cdk.Stack {

const userData = ec2.UserData.forLinux();
userData.addCommands(
"set -euxo pipefail",
"set -euo pipefail",
// Tee all userdata output to a tail-able log, and timestamp every traced
// command (PS4) so you can `tail -f /var/log/eventform-userdata.log` and
// see how long each boot step (dnf, image pull, compose up) takes. The
// duration of a step = the gap to the next line's timestamp.
`export PS4='+ [$(date "+%T")] '`,
"exec > >(tee -a /var/log/eventform-userdata.log) 2>&1",
`echo "=== eventform userdata start: $(date -u) ==="`,
"set -x",
"dnf update -y",
"dnf install -y docker git awscli",
// Cap container logs daemon-wide (json-file is unbounded by default, and
Expand All @@ -92,6 +100,10 @@ export class ComputeStack extends cdk.Stack {
// Materialize .env from SSM SecureString params (region from the stack)
`REGION=${this.region}`,
"get() { aws ssm get-parameter --region \"$REGION\" --name \"$1\" --with-decryption --query Parameter.Value --output text; }",
// Disable xtrace around secret materialization — with set -x, the trace
// would print the expanded $(get ...) (the actual Neon URLs, enc key,
// tunnel token) into the userdata log. Re-enable after .env is written.
"set +x",
"{",
' echo "DATABASE_URL=$(get /eventform/database-url)"',
' echo "DATABASE_URL_API=$(get /eventform/database-url-api)"',
Expand All @@ -102,6 +114,7 @@ export class ComputeStack extends cdk.Stack {
' echo "COGNITO_CLIENT_ID=$(get /eventform/cognito-client-id)"',
"} > .env",
"chmod 600 .env",
"set -x",
// Scale-to-zero: the API (non-root `node`, uid 1000) stamps its
// last-activity file into this bind-mounted dir; pre-create it owned by
// 1000 so the container can write and the idle-check (root) can read.
Expand All @@ -118,6 +131,9 @@ export class ComputeStack extends cdk.Stack {
"cp /opt/eventform/infra/systemd/eventform-idle.timer /etc/systemd/system/",
"systemctl daemon-reload",
"systemctl enable --now eventform-idle.timer",
// Completion marker — if this line isn't in the log, boot failed earlier
// (set -e exits on the first error), and the last timestamp shows where.
`echo "=== eventform userdata done: $(date -u) ==="`,
);

const launchTemplate = new ec2.LaunchTemplate(this, "LaunchTemplate", {
Expand Down
Loading