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
109 changes: 106 additions & 3 deletions src/components/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
Zap,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { motion } from "framer-motion";
import {
LatencyChart,
Expand Down Expand Up @@ -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<Range>("24h");
Expand All @@ -193,8 +242,14 @@ export function Dashboard() {
const [trafficShare, setTrafficShare] = useState(68);
const [selectedGeneration, setSelectedGeneration] =
useState<Generation | null>(null);
const [replayScenarioId, setReplayScenarioId] = useState<string | null>(null);
const [replayStepIndex, setReplayStepIndex] = useState(0);
// Seed from a shareable replay link (?replay=<id>&step=<n>) on first render
// so the replay rail shows the right step immediately (no mount flash).
const [replayScenarioId, setReplayScenarioId] = useState<string | null>(
() => readReplayParams().scenarioId,
);
const [replayStepIndex, setReplayStepIndex] = useState(() =>
readReplayParams().step,
);

const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ["ops-snapshot", range],
Expand Down Expand Up @@ -417,6 +472,54 @@ 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
}, []);

// 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);
}, [replayScenarioId, replayStepIndex]);

const applyRuleMutation = useMutation({
mutationKey: ["routing-rule", range],
mutationFn: async () => {
Expand Down
59 changes: 56 additions & 3 deletions src/components/incident-replay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -395,6 +397,54 @@ function ScenarioLauncher({
);
}

function CopyLinkButton() {
const [copied, setCopied] = useState(false);
const resetTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<button
type="button"
onClick={handleCopy}
title="Copy a shareable link to this replay step"
className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-lg border border-[var(--border)] bg-[var(--surface)] px-2.5 text-[11.5px] font-medium text-[var(--text-dim)] transition-colors hover:bg-[var(--surface-mute)]"
>
{copied ? (
<CheckCircle2 className="size-3.5 text-[var(--success)]" />
) : (
<Link2 className="size-3.5" />
)}
{copied ? "Copied" : "Copy link"}
</button>
);
}

function ReplayPlayer({
scenario,
stepIndex,
Expand Down Expand Up @@ -425,9 +475,12 @@ function ReplayPlayer({
<span className="text-[12.5px] font-semibold text-[var(--text)]">
{scenario.name}
</span>
<span className="font-mono text-xs text-[var(--mute)]">
Step {stepIndex + 1} of {total}
</span>
<div className="flex items-center gap-2">
<CopyLinkButton />
<span className="font-mono text-xs text-[var(--mute)]">
Step {stepIndex + 1} of {total}
</span>
</div>
</div>

<ol className="mt-3 grid grid-cols-5 gap-1.5">
Expand Down