From d8587a166cb72f3f4eeeb7545a32164c1e2de6a1 Mon Sep 17 00:00:00 2001 From: Moskovkin Ilya Date: Sun, 21 Jun 2026 10:45:14 +0700 Subject: [PATCH 1/2] feat: shareable replay deep-link (?replay=&step=) on /cockpit Hydrate the guided replay from a shareable URL on mount, keep the URL in sync per step via history.replaceState, and add a Copy-link control to the replay rail. Navigating to /cockpit?replay=flux-retry&step=3 now arms the replay at that beat; invalid/missing params degrade gracefully. Closes #6 agent:clawd --- src/components/dashboard.tsx | 85 ++++++++++++++++++++++++++++-- src/components/incident-replay.tsx | 59 +++++++++++++++++++-- 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/components/dashboard.tsx b/src/components/dashboard.tsx index 550a109..97f6d58 100644 --- a/src/components/dashboard.tsx +++ b/src/components/dashboard.tsx @@ -21,7 +21,7 @@ import { Zap, } from "lucide-react"; import Link from "next/link"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { motion } from "framer-motion"; import { LatencyChart, @@ -176,6 +176,55 @@ function downloadCsv(filename: string, csv: string) { window.setTimeout(() => URL.revokeObjectURL(url), 0); } +const REPLAY_PARAM = "replay"; +const STEP_PARAM = "step"; + +/** Read an incoming shareable replay link from the URL (client only). */ +function readReplayParams(): { scenarioId: string | null; step: number } { + if (typeof window === "undefined") { + return { scenarioId: null, step: 0 }; + } + + const params = new URLSearchParams(window.location.search); + const replay = params.get(REPLAY_PARAM); + + if (!replay) { + return { scenarioId: null, step: 0 }; + } + + const scenario = replayScenarios.find((item) => item.id === replay); + + if (!scenario) { + return { scenarioId: null, step: 0 }; + } + + const rawStep = Number(params.get(STEP_PARAM) ?? "0"); + const step = Number.isFinite(rawStep) + ? Math.max(0, Math.min(Math.floor(rawStep), scenario.steps.length - 1)) + : 0; + + return { scenarioId: replay, step }; +} + +/** Keep the URL in sync with the active replay (no history entries, no scroll). */ +function syncReplayUrl(scenarioId: string | null, step: number) { + if (typeof window === "undefined") { + return; + } + + const url = new URL(window.location.href); + + if (scenarioId) { + url.searchParams.set(REPLAY_PARAM, scenarioId); + url.searchParams.set(STEP_PARAM, String(step)); + } else { + url.searchParams.delete(REPLAY_PARAM); + url.searchParams.delete(STEP_PARAM); + } + + window.history.replaceState(window.history.state, "", url); +} + export function Dashboard() { const queryClient = useQueryClient(); const [range, setRange] = useState("24h"); @@ -193,8 +242,14 @@ export function Dashboard() { const [trafficShare, setTrafficShare] = useState(68); const [selectedGeneration, setSelectedGeneration] = useState(null); - const [replayScenarioId, setReplayScenarioId] = useState(null); - const [replayStepIndex, setReplayStepIndex] = useState(0); + // Seed from a shareable replay link (?replay=&step=) on first render + // so the replay rail shows the right step immediately (no mount flash). + const [replayScenarioId, setReplayScenarioId] = useState( + () => readReplayParams().scenarioId, + ); + const [replayStepIndex, setReplayStepIndex] = useState(() => + readReplayParams().step, + ); const { data, isLoading, isFetching, refetch } = useQuery({ queryKey: ["ops-snapshot", range], @@ -417,6 +472,30 @@ export function Dashboard() { setSelectedGeneration(null); } + // The replay scenario/step are seeded from the URL via lazy state init above. + // This deferred effect applies the rest of the dashboard controls (incident + // selection, views, routing rule, scroll) so the cockpit matches the linked + // beat. rAF keeps the setState calls out of the synchronous effect body and + // lets the scroll target settle into the DOM first. + useEffect(() => { + if (!replayScenarioId) { + return; + } + + const id = requestAnimationFrame(() => + goToReplayStep(replayScenarioId, replayStepIndex), + ); + + return () => cancelAnimationFrame(id); + // Mount-only: apply the incoming replay link to dashboard controls. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Keep the URL in sync with the active replay so every beat is copy-shareable. + useEffect(() => { + syncReplayUrl(replayScenarioId, replayStepIndex); + }, [replayScenarioId, replayStepIndex]); + const applyRuleMutation = useMutation({ mutationKey: ["routing-rule", range], mutationFn: async () => { diff --git a/src/components/incident-replay.tsx b/src/components/incident-replay.tsx index ec71412..cd761f5 100644 --- a/src/components/incident-replay.tsx +++ b/src/components/incident-replay.tsx @@ -7,10 +7,12 @@ import { Clock3, Download, Gauge, + Link2, Sparkles, SlidersHorizontal, Target, } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import type { LucideIcon } from "lucide-react"; import type { GenerationStatus, ProviderId } from "@/lib/mock-data"; @@ -395,6 +397,54 @@ function ScenarioLauncher({ ); } +function CopyLinkButton() { + const [copied, setCopied] = useState(false); + const resetTimer = useRef | null>(null); + + useEffect(() => { + return () => { + if (resetTimer.current) { + clearTimeout(resetTimer.current); + } + }; + }, []); + + const handleCopy = async () => { + if (typeof window === "undefined" || !navigator.clipboard) { + return; + } + + try { + // The dashboard keeps window.location in sync with the active step, so + // the current URL is already the exact shareable beat to hand off. + await navigator.clipboard.writeText(window.location.href); + setCopied(true); + if (resetTimer.current) { + clearTimeout(resetTimer.current); + } + resetTimer.current = setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard unavailable (permissions/unsupported); ignore silently. + } + }; + + return ( + + ); +} + function ReplayPlayer({ scenario, stepIndex, @@ -425,9 +475,12 @@ function ReplayPlayer({ {scenario.name} - - Step {stepIndex + 1} of {total} - +
+ + + Step {stepIndex + 1} of {total} + +
    From 36e2c7614ab200e217bfeb1272539085a2acf140 Mon Sep 17 00:00:00 2001 From: Moskovkin Ilya Date: Sun, 21 Jun 2026 10:52:48 +0700 Subject: [PATCH 2/2] fix: re-apply routing rule when snapshot arrives for deep-linked steps The mount effect may run before the ops snapshot resolves, so setReplayRoutingRule has no cache entry to update and the routing-applied state is lost on first load. Add a [data] effect that re-applies the routing rule once for the deep-linked step as soon as data is available. Addresses autoreview [P2] finding. agent:clawd --- src/components/dashboard.tsx | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/components/dashboard.tsx b/src/components/dashboard.tsx index 97f6d58..124368b 100644 --- a/src/components/dashboard.tsx +++ b/src/components/dashboard.tsx @@ -21,7 +21,7 @@ import { Zap, } from "lucide-react"; import Link from "next/link"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { motion } from "framer-motion"; import { LatencyChart, @@ -491,6 +491,30 @@ export function Dashboard() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // The mount effect above may run before the ops snapshot resolves, so + // setReplayRoutingRule had no cache entry to update. Re-apply the routing + // rule once for the deep-linked step as soon as data is available. + const deepLinkRoutingApplied = useRef(false); + + useEffect(() => { + if (deepLinkRoutingApplied.current || !data || !replayScenarioId) { + return; + } + + deepLinkRoutingApplied.current = true; + + const scenario = replayScenarios.find( + (item) => item.id === replayScenarioId, + ); + const step = scenario?.steps[replayStepIndex]; + + if (scenario && step) { + setReplayRoutingRule(scenario, step); + } + // Fires once when the snapshot first arrives for a URL-hydrated replay. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + // Keep the URL in sync with the active replay so every beat is copy-shareable. useEffect(() => { syncReplayUrl(replayScenarioId, replayStepIndex);