"use client"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { LuSearch } from "react-icons/lu"; import { formatDateTime, formatDuration, formatElapsed, hasRunCounters, indexProfiles, indexRunsById, indexRunsBySession, outcomeLabel, parseIso, runStatusLabel, runStatusTone, StatusDot, sessionCloseReason, sessionDisplayName, sessionElapsedSeconds, sessionPhaseLabel, sessionTone, useSecondTicker, } from "@/components/cookie-bot-shared"; import { Button } from "@/components/ui/button"; import { FadingScrollArea } from "@/components/ui/fading-scroll-area"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { translateBackendError } from "@/lib/backend-errors"; import { type CookieBotRun, cancelCookieBotRun } from "@/lib/cookie-bot"; import { MOTION_EASE_OUT } from "@/lib/motion"; import { type RemoteSessionState, stopRemoteSession, } from "@/lib/remote-sessions"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { cn } from "@/lib/utils"; import type { BrowserProfile } from "@/types"; export type RunFilter = "all" | "succeeded" | "partial" | "failed"; interface CookieBotActivityProps { live: RemoteSessionState[]; streamConnected: boolean; runs: CookieBotRun[]; isLoading: boolean; profiles: BrowserProfile[]; showOperator: boolean; filter: RunFilter; onFilterChange: (filter: RunFilter) => void; onRefresh: () => void; } export function CookieBotActivity({ live, streamConnected, runs, isLoading, profiles, showOperator, filter, onFilterChange, onRefresh, }: CookieBotActivityProps) { const { t } = useTranslation(); const [search, setSearch] = useState(""); const profileIndex = useMemo(() => indexProfiles(profiles), [profiles]); const runsBySession = useMemo(() => indexRunsBySession(runs), [runs]); const runsById = useMemo(() => indexRunsById(runs), [runs]); const filtered = useMemo(() => { const needle = search.trim().toLowerCase(); return runs.filter((run) => { if (filter !== "all" && run.status !== filter) return false; if (!needle) return true; const name = run.profile_name ?? profileIndex.get(run.profile_id)?.name; return ( (name ?? "").toLowerCase().includes(needle) || (run.email ?? "").toLowerCase().includes(needle) ); }); }, [runs, filter, search, profileIndex]); return (
{ setSearch(event.target.value); }} className="h-8 pl-8 text-sm" placeholder={t("cookieBot.history.searchPlaceholder")} />
{t("cookieBot.history.columnStarted")} {t("cookieBot.history.columnProfile")} {t("cookieBot.history.columnDuration")} {t("cookieBot.history.columnSites")} {t("cookieBot.history.columnStatus")} {showOperator && ( {t("cookieBot.history.columnOperator")} )} {isLoading && runs.length === 0 ? ( Array.from({ length: 6 }, (_, i) => (
)) ) : filtered.length === 0 ? (

{runs.length === 0 ? t("cookieBot.history.empty") : t("cookieBot.history.noMatch")}

) : ( filtered.map((run) => ( )) )}
); } function RunRow({ run, profileName, showOperator, }: { run: CookieBotRun; profileName: string | null; showOperator: boolean; }) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); const reduceMotion = useReducedMotion(); const started = parseIso(run.started_at); const ended = parseIso(run.ended_at); const durationSeconds = started && ended ? Math.max(0, Math.floor((ended.getTime() - started.getTime()) / 1000)) : run.billed_seconds > 0 ? run.billed_seconds : null; const countersKnown = hasRunCounters(run); const hasDetail = Boolean(run.outcome_code) || run.sites_failed > 0; return ( <> { if (hasDetail) setExpanded((open) => !open); }} > {formatDateTime(run.started_at ?? run.scheduled_for) ?? "—"} {profileName ?? t("cookieBot.history.unknownProfile")} {durationSeconds === null ? "—" : formatDuration(t, durationSeconds)} {/* An em dash, not a confident `0/12`: the counters are not written until the fleet's figures are ingested, and printing the column default as a fact tells a paying user their run did nothing. */} {!countersKnown ? ( {t("cookieBot.history.sitesUnknown")} ) : run.sites_total > 0 ? ( `${run.sites_visited}/${run.sites_total}` ) : ( String(run.sites_visited) )} {runStatusLabel(t, run.status)} {showOperator && ( {run.email ?? "—"} )} {hasDetail && ( {expanded && ( {run.outcome_code && ( {t("cookieBot.history.outcome", { reason: outcomeLabel(t, run.outcome_code) ?? "", })} )} {run.sites_failed > 0 && ( {t("cookieBot.history.sitesFailed", { count: run.sites_failed, })} )} {run.consent_dismissed > 0 && ( {t("cookieBot.history.consentHandled", { count: run.consent_dismissed, })} )} )} )} ); } /* -------------------------------------------------------------------------- */ /* Live */ /* -------------------------------------------------------------------------- */ function LiveSessions({ live, streamConnected, profileIndex, runsBySession, runsById, onChanged, }: { live: RemoteSessionState[]; streamConnected: boolean; profileIndex: Map; runsBySession: Map; runsById: Map; onChanged: () => void; }) { const { t } = useTranslation(); const now = useSecondTicker(live.length > 0); if (live.length === 0) { return (
{streamConnected ? t("cookieBot.live.idle") : t("cookieBot.live.streamOffline")}
); } return (
{!streamConnected && (

{t("cookieBot.live.streamOfflineDetail")}

)} {live.map((session) => ( ))}
); } function LiveSessionRow({ session, now, name, run, onChanged, }: { session: RemoteSessionState; now: number; name: string | null; run: CookieBotRun | undefined; onChanged: () => void; }) { const { t } = useTranslation(); const reduceMotion = useReducedMotion(); const [isStopping, setIsStopping] = useState(false); const elapsed = sessionElapsedSeconds(session, now); const phase = sessionPhaseLabel(t, session); const tone = sessionTone(session); const closeReason = sessionCloseReason(t, session); // Only once the backend has actually written a counter. Until then the bar // sat at zero for the whole run and read as "nothing is happening". const countersKnown = run ? hasRunCounters(run) : false; const total = run?.sites_total ?? 0; const visited = run?.sites_visited ?? 0; const progress = countersKnown && total > 0 ? Math.min(1, visited / total) : null; // A night longer than one session's cap is split into chunks, and the run row // is the only place that can say which one is running. `chunk_index` counts // chunks STARTED — the server bumps it as it launches each one and treats 0 // as "never got going" — so it already reads as a 1-based position and must // not be incremented again. const chunks = run && run.chunks_total > 1 && run.chunk_index > 0 ? t("cookieBot.live.chunk", { index: Math.min(run.chunk_index, run.chunks_total), total: run.chunks_total, }) : null; const stop = useCallback(async () => { setIsStopping(true); try { if (session.run_id) { await cancelCookieBotRun(session.run_id); } else { await stopRemoteSession(session.session_id); } showSuccessToast(t("cookieBot.running.stopped")); onChanged(); } catch (error) { showErrorToast(translateBackendError(t, error)); } finally { setIsStopping(false); } }, [session.run_id, session.session_id, onChanged, t]); return (
{name ?? t("cookieBot.live.unnamedSession")} {/* The phase swaps in place: the words change, the row does not move. The slot is a fixed width so the elapsed clock beside it never shifts, and the entering label starts at 0.55 rather than 0 — if the animation never runs, the single most important live signal on the screen is still legible. */} {phase} {elapsed === null ? ( {t("cookieBot.live.notStartedYet")} ) : ( formatElapsed(elapsed) )}
{countersKnown && total > 0 ? t("cookieBot.live.sitesProgress", { visited, total }) : t("cookieBot.live.sitesUnknown")} {countersKnown && run ? t("cookieBot.live.consentHandled", { count: run.consent_dismissed, }) : t("cookieBot.live.consentUnknown")} {session.billed_seconds !== null && session.billed_seconds !== undefined ? t("cookieBot.live.billed", { duration: formatElapsed(session.billed_seconds), }) : t("cookieBot.live.billedUnknown")} {chunks && {chunks}} {closeReason && ( {closeReason} )}
{progress !== null && (
)}
); }