Spaces:
Running
Running
File size: 6,050 Bytes
88d2f2a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | "use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Play, Check, Loader2 } from "lucide-react";
import { triggerEvent } from "@/lib/api";
import { useDemoMode } from "@/contexts/ModeContext";
// W16-A: button no longer waits for lifecycle terminal status before
// navigating. The detail page renders the phases in "pending" state
// immediately and animates them live via its own SSE subscription. This
// flips the perceived latency from 40+s (in dev mode) down to whatever the
// POST round-trip takes (~50-300ms). The progressive labels that used to
// drive the busy state are gone because they're invisible after we push.
const TRIGGER_LABEL_LIVE = "Trigger live demo";
const TRIGGER_LABEL_MOCK = "Trigger mock demo";
const TRIGGER_LABEL_BUSY = "Triggering…";
const TRIGGER_LABEL_SLOW = "Backend slow — still trying…";
// Soft hint timer: if the POST hasn't returned within 5s we swap the busy
// label to a "Backend slow…" reassurance. The user can still navigate away
// manually; we don't actually unblock or auto-redirect anywhere because
// without an event_id there's nothing to navigate to.
const SLOW_HINT_MS = 5_000;
// Hard fallback: if POST hangs for this long, give up and reset the button
// so the user can retry without reloading the page.
const POST_HARD_TIMEOUT_MS = 30_000;
export function TriggerButton() {
const router = useRouter();
const { mode, isHydrated } = useDemoMode();
// W7-E: keep the label SSR-safe ("Trigger live demo") until the mode
// context has hydrated so the server-rendered button text matches the
// first client paint. After hydration the label switches to reflect the
// resolved mode without producing a React hydration mismatch warning.
const effectiveMode: typeof mode = isHydrated ? mode : "live";
const [busy, setBusy] = useState(false);
const [triggered, setTriggered] = useState(false);
const [slow, setSlow] = useState(false);
const [errorHint, setErrorHint] = useState<string | null>(null);
const slowTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hardTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Clean up any leftover timers on unmount so they can't fire after
// navigation.
useEffect(() => {
return () => {
if (slowTimerRef.current) clearTimeout(slowTimerRef.current);
if (hardTimerRef.current) clearTimeout(hardTimerRef.current);
};
}, []);
const label = useMemo(() => {
if (!busy) {
if (triggered) return "Triggered";
return effectiveMode === "mock" ? TRIGGER_LABEL_MOCK : TRIGGER_LABEL_LIVE;
}
return slow ? TRIGGER_LABEL_SLOW : TRIGGER_LABEL_BUSY;
}, [busy, triggered, slow, effectiveMode]);
const clearTimers = () => {
if (slowTimerRef.current) {
clearTimeout(slowTimerRef.current);
slowTimerRef.current = null;
}
if (hardTimerRef.current) {
clearTimeout(hardTimerRef.current);
hardTimerRef.current = null;
}
};
const onClick = async () => {
setBusy(true);
setTriggered(false);
setSlow(false);
setErrorHint(null);
// W7-A fallback safety net — these timers only fire if the POST never
// returns within their window. In the happy path (POST in 50-300ms) the
// success branch clears them before they get a chance to run.
slowTimerRef.current = setTimeout(() => setSlow(true), SLOW_HINT_MS);
hardTimerRef.current = setTimeout(() => {
// POST is wedged. Don't auto-navigate (no event id). Reset so the
// user can retry. The slow label stays visible briefly as feedback.
setBusy(false);
setErrorHint(
"Backend didn't respond in 30s — try again or see /events for cached runs.",
);
}, POST_HARD_TIMEOUT_MS);
try {
// Forward the user-selected demo mode (W5-B) so the backend can pick
// the synthetic mock lifecycle vs. the real RSS pipeline. Pass the
// resolved `mode` (hydrated context) — `effectiveMode` exists only
// so the SSR/CSR labels match before hydration.
const result = await triggerEvent(undefined, mode);
clearTimers();
if (result?.event_id) {
// Push immediately. The detail page has its own SSE subscription
// and renders phases in "pending" state until the first event
// lands, so the user sees the DAG animate live instead of staring
// at the trigger button.
setTriggered(true);
router.push(`/events/${String(result.event_id)}`);
// Leave `busy=true` for a tick so the spinner stays visible until
// Next.js commits the route transition. The component unmounts on
// navigation anyway; the cleanup effect handles any stragglers.
setBusy(false);
} else {
// Unexpected — POST succeeded but no id. Fall back to the events
// list so the user can still find their run.
setBusy(false);
setTriggered(true);
router.push(`/events`);
}
} catch {
clearTimers();
setBusy(false);
setSlow(false);
setErrorHint("Backend unreachable — see /events for cached runs.");
}
};
return (
<div className="flex flex-col gap-2">
<Button
variant="outline"
disabled={busy}
onClick={onClick}
aria-label="Trigger a live demo event"
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin text-primary" aria-hidden />
) : triggered ? (
<Check className="h-4 w-4 text-emerald-400" aria-hidden />
) : (
<Play className="h-4 w-4" aria-hidden />
)}
{label}
</Button>
{(busy || errorHint) && (
<p
className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground"
aria-live="polite"
>
{errorHint ?? (slow ? TRIGGER_LABEL_SLOW : TRIGGER_LABEL_BUSY)}
</p>
)}
</div>
);
}
|