mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-18 00:47:19 +02:00
feat: cookie bot
This commit is contained in:
@@ -11,7 +11,13 @@ import {
|
||||
LuRefreshCw,
|
||||
LuUser,
|
||||
} from "react-icons/lu";
|
||||
import {
|
||||
formatDate,
|
||||
formatHours,
|
||||
RemoteHoursMeter,
|
||||
} from "@/components/cookie-bot-shared";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { TeamUsagePanel } from "@/components/team-usage-panel";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
AnimatedTabsContent,
|
||||
@@ -24,8 +30,13 @@ import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getEntitlements } from "@/lib/entitlements";
|
||||
import {
|
||||
canUseCookieBot,
|
||||
getEntitlements,
|
||||
isTeamOwner,
|
||||
} from "@/lib/entitlements";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SyncSettings } from "@/types";
|
||||
@@ -56,6 +67,25 @@ export function AccountPage({
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
||||
// Remote hours are plan truth, so they belong here rather than only next to
|
||||
// the controls that spend them. Until this landed, `remote-sessions/quota`
|
||||
// had no caller anywhere and a customer's first sight of their allowance was
|
||||
// a refused launch.
|
||||
const remoteHoursVisible = isLoggedIn && canUseCookieBot(user);
|
||||
const showTeamUsage = remoteHoursVisible && isTeamOwner(user);
|
||||
const { quota, isLoading: isQuotaLoading } = useCookieBot(
|
||||
remoteHoursVisible,
|
||||
cookieBotScopeFor(user),
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState("account");
|
||||
|
||||
// Signing out (or losing the team) removes the tab while it is the selected
|
||||
// one, which would leave the page showing an empty panel with no trigger to
|
||||
// click back to.
|
||||
useEffect(() => {
|
||||
if (!showTeamUsage && activeTab === "team-usage") setActiveTab("account");
|
||||
}, [showTeamUsage, activeTab]);
|
||||
|
||||
// Self-hosted server state. Loaded once when the dialog opens and persisted
|
||||
// via `save_sync_settings` so the rest of the app picks up the new URL/token
|
||||
// from `SettingsManager`.
|
||||
@@ -201,11 +231,16 @@ export function AccountPage({
|
||||
<DialogContent className="flex max-h-[calc(100vh-5rem)] max-w-3xl flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className={cn(subPage && "mx-auto w-full max-w-4xl")}>
|
||||
<AnimatedTabs defaultValue="account">
|
||||
<AnimatedTabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="account">
|
||||
{t("account.tabs.account")}
|
||||
</AnimatedTabsTrigger>
|
||||
{showTeamUsage && (
|
||||
<AnimatedTabsTrigger value="team-usage">
|
||||
{t("account.tabs.teamUsage")}
|
||||
</AnimatedTabsTrigger>
|
||||
)}
|
||||
<AnimatedTabsTrigger
|
||||
value="self-hosted"
|
||||
disabled={selfHostedDisabled}
|
||||
@@ -251,6 +286,63 @@ export function AccountPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{remoteHoursVisible && (
|
||||
// A headline block, not one field among six: the allowance
|
||||
// is the number a customer needs before a launch is
|
||||
// refused, which is the only way they ever saw it before.
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2.5">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("cookieBot.hours.label")}
|
||||
</p>
|
||||
{formatDate(quota?.period_end) && (
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("cookieBot.hours.resets", {
|
||||
date: formatDate(quota?.period_end),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-lg leading-none font-semibold tabular-nums">
|
||||
{quota ? formatHours(quota.remaining_hours) : "—"}
|
||||
<span className="ml-1 text-sm font-normal text-muted-foreground">
|
||||
{t("cookieBot.hours.remainingOf", {
|
||||
total: quota
|
||||
? formatHours(quota.granted_hours)
|
||||
: "—",
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<RemoteHoursMeter
|
||||
quota={quota}
|
||||
isLoading={isQuotaLoading}
|
||||
variant="inline"
|
||||
className="mt-2"
|
||||
/>
|
||||
<div className="mt-2 flex items-baseline justify-between gap-3">
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("cookieBot.hours.used", {
|
||||
used: quota ? formatHours(quota.used_hours) : "—",
|
||||
total: quota
|
||||
? formatHours(quota.granted_hours)
|
||||
: "—",
|
||||
})}
|
||||
</p>
|
||||
{showTeamUsage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTab("team-usage");
|
||||
}}
|
||||
className="text-xs text-muted-foreground underline underline-offset-2 transition-colors duration-100 hover:text-foreground"
|
||||
>
|
||||
{t("account.viewTeamUsage")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoggedIn && user && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
@@ -362,6 +454,12 @@ export function AccountPage({
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
{showTeamUsage && (
|
||||
<AnimatedTabsContent value="team-usage" className="mt-4">
|
||||
<TeamUsagePanel quota={quota} />
|
||||
</AnimatedTabsContent>
|
||||
)}
|
||||
|
||||
<AnimatedTabsContent value="self-hosted" className="mt-4">
|
||||
{selfHostedDisabled ? (
|
||||
// Defensive: the tab trigger is disabled while the user is
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
LuBadgeInfo,
|
||||
LuCircleStop,
|
||||
LuCloud,
|
||||
LuCookie,
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuPlay,
|
||||
@@ -67,6 +68,7 @@ const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
|
||||
goProxies: FiWifi,
|
||||
goExtensions: LuPuzzle,
|
||||
goGroups: LuUsers,
|
||||
goCookieBot: LuCookie,
|
||||
goIntegrations: LuPlug,
|
||||
goAccount: LuCloud,
|
||||
goSettings: GoGear,
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
"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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<LiveSessions
|
||||
live={live}
|
||||
streamConnected={streamConnected}
|
||||
profileIndex={profileIndex}
|
||||
runsBySession={runsBySession}
|
||||
runsById={runsById}
|
||||
onChanged={onRefresh}
|
||||
/>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<LuSearch className="absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
}}
|
||||
className="h-8 pl-8 text-sm"
|
||||
placeholder={t("cookieBot.history.searchPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={filter}
|
||||
onValueChange={(value) => {
|
||||
onFilterChange(value as RunFilter);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("cookieBot.history.filterAll")}
|
||||
</SelectItem>
|
||||
<SelectItem value="succeeded">
|
||||
{t("cookieBot.history.filterComplete")}
|
||||
</SelectItem>
|
||||
<SelectItem value="partial">
|
||||
{t("cookieBot.history.filterPartial")}
|
||||
</SelectItem>
|
||||
<SelectItem value="failed">
|
||||
{t("cookieBot.history.filterFailed")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<FadingScrollArea
|
||||
className="min-h-0 flex-1"
|
||||
style={{ "--scroll-fade-top-offset": "32px" } as React.CSSProperties}
|
||||
>
|
||||
<Table
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableRow>
|
||||
<TableHead className="w-40">
|
||||
{t("cookieBot.history.columnStarted")}
|
||||
</TableHead>
|
||||
<TableHead className="max-w-0">
|
||||
{t("cookieBot.history.columnProfile")}
|
||||
</TableHead>
|
||||
<TableHead className="hidden w-24 @2xl:table-cell">
|
||||
{t("cookieBot.history.columnDuration")}
|
||||
</TableHead>
|
||||
<TableHead className="hidden w-20 text-right @3xl:table-cell">
|
||||
{t("cookieBot.history.columnSites")}
|
||||
</TableHead>
|
||||
<TableHead className="w-32">
|
||||
{t("cookieBot.history.columnStatus")}
|
||||
</TableHead>
|
||||
{showOperator && (
|
||||
<TableHead className="hidden max-w-0 @4xl:table-cell">
|
||||
{t("cookieBot.history.columnOperator")}
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && runs.length === 0 ? (
|
||||
Array.from({ length: 6 }, (_, i) => (
|
||||
<TableRow key={`skeleton-${i}`}>
|
||||
<TableCell colSpan={showOperator ? 6 : 5}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-3 w-28" />
|
||||
<Skeleton
|
||||
className="h-3"
|
||||
style={{ width: `${30 + ((i * 17) % 40)}%` }}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-10" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : filtered.length === 0 ? (
|
||||
<TableRow className="border-0! hover:bg-transparent">
|
||||
<TableCell colSpan={showOperator ? 6 : 5} className="py-16">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{runs.length === 0
|
||||
? t("cookieBot.history.empty")
|
||||
: t("cookieBot.history.noMatch")}
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filtered.map((run) => (
|
||||
<RunRow
|
||||
key={run.id}
|
||||
run={run}
|
||||
profileName={
|
||||
run.profile_name ??
|
||||
profileIndex.get(run.profile_id)?.name ??
|
||||
null
|
||||
}
|
||||
showOperator={showOperator}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</FadingScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<TableRow
|
||||
className={cn("hover:bg-muted/30", hasDetail && "cursor-pointer")}
|
||||
onClick={() => {
|
||||
if (hasDetail) setExpanded((open) => !open);
|
||||
}}
|
||||
>
|
||||
<TableCell className="tabular-nums text-muted-foreground">
|
||||
{formatDateTime(run.started_at ?? run.scheduled_for) ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-0 truncate">
|
||||
{profileName ?? t("cookieBot.history.unknownProfile")}
|
||||
</TableCell>
|
||||
<TableCell className="hidden tabular-nums @2xl:table-cell">
|
||||
{durationSeconds === null ? "—" : formatDuration(t, durationSeconds)}
|
||||
</TableCell>
|
||||
{/* 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. */}
|
||||
<TableCell className="hidden text-right tabular-nums text-muted-foreground @3xl:table-cell">
|
||||
{!countersKnown ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default">—</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("cookieBot.history.sitesUnknown")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : run.sites_total > 0 ? (
|
||||
`${run.sites_visited}/${run.sites_total}`
|
||||
) : (
|
||||
String(run.sites_visited)
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-2 text-xs">
|
||||
<StatusDot tone={runStatusTone(run.status)} />
|
||||
{runStatusLabel(t, run.status)}
|
||||
</span>
|
||||
</TableCell>
|
||||
{showOperator && (
|
||||
<TableCell className="hidden max-w-0 truncate text-muted-foreground @4xl:table-cell">
|
||||
{run.email ?? "—"}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
{hasDetail && (
|
||||
<TableRow className="border-0! hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={showOperator ? 6 : 5}
|
||||
className={cn("p-0", !expanded && "border-0!")}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
|
||||
transition={{
|
||||
duration: reduceMotion ? 0.15 : 0.16,
|
||||
ease: MOTION_EASE_OUT,
|
||||
}}
|
||||
className="flex flex-col gap-1 px-2 pb-3 text-xs text-muted-foreground"
|
||||
>
|
||||
{run.outcome_code && (
|
||||
<span>
|
||||
{t("cookieBot.history.outcome", {
|
||||
reason: outcomeLabel(t, run.outcome_code) ?? "",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{run.sites_failed > 0 && (
|
||||
<span>
|
||||
{t("cookieBot.history.sitesFailed", {
|
||||
count: run.sites_failed,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{run.consent_dismissed > 0 && (
|
||||
<span>
|
||||
{t("cookieBot.history.consentHandled", {
|
||||
count: run.consent_dismissed,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Live */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function LiveSessions({
|
||||
live,
|
||||
streamConnected,
|
||||
profileIndex,
|
||||
runsBySession,
|
||||
runsById,
|
||||
onChanged,
|
||||
}: {
|
||||
live: RemoteSessionState[];
|
||||
streamConnected: boolean;
|
||||
profileIndex: Map<string, BrowserProfile>;
|
||||
runsBySession: Map<string, CookieBotRun>;
|
||||
runsById: Map<string, CookieBotRun>;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const now = useSecondTicker(live.length > 0);
|
||||
|
||||
if (live.length === 0) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 rounded-md border border-border bg-card px-3 py-2.5">
|
||||
<StatusDot tone="muted" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{streamConnected
|
||||
? t("cookieBot.live.idle")
|
||||
: t("cookieBot.live.streamOffline")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col gap-2">
|
||||
{!streamConnected && (
|
||||
<p className="text-xs text-warning-text">
|
||||
{t("cookieBot.live.streamOfflineDetail")}
|
||||
</p>
|
||||
)}
|
||||
{live.map((session) => (
|
||||
<LiveSessionRow
|
||||
key={session.session_id}
|
||||
session={session}
|
||||
now={now}
|
||||
name={sessionDisplayName(
|
||||
session,
|
||||
profileIndex,
|
||||
session.run_id
|
||||
? runsById.get(session.run_id)
|
||||
: runsBySession.get(session.session_id),
|
||||
)}
|
||||
run={
|
||||
session.run_id
|
||||
? runsById.get(session.run_id)
|
||||
: runsBySession.get(session.session_id)
|
||||
}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-border bg-card px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<StatusDot tone={tone} pulse={session.state === "provisioning"} />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
|
||||
{name ?? t("cookieBot.live.unnamedSession")}
|
||||
</span>
|
||||
|
||||
{/* 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. */}
|
||||
<span className="w-36 shrink-0 text-right">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={phase}
|
||||
initial={{ opacity: reduceMotion ? 1 : 0.55 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: reduceMotion ? 0.01 : 0.12 }}
|
||||
className="block truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{phase}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 text-xs tabular-nums text-foreground">
|
||||
{elapsed === null ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default text-muted-foreground">—</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("cookieBot.live.notStartedYet")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
formatElapsed(elapsed)
|
||||
)}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 text-xs"
|
||||
disabled={isStopping}
|
||||
onClick={() => {
|
||||
void stop();
|
||||
}}
|
||||
>
|
||||
{t("cookieBot.running.stop")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">
|
||||
{countersKnown && total > 0
|
||||
? t("cookieBot.live.sitesProgress", { visited, total })
|
||||
: t("cookieBot.live.sitesUnknown")}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{countersKnown && run
|
||||
? t("cookieBot.live.consentHandled", {
|
||||
count: run.consent_dismissed,
|
||||
})
|
||||
: t("cookieBot.live.consentUnknown")}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{session.billed_seconds !== null &&
|
||||
session.billed_seconds !== undefined
|
||||
? t("cookieBot.live.billed", {
|
||||
duration: formatElapsed(session.billed_seconds),
|
||||
})
|
||||
: t("cookieBot.live.billedUnknown")}
|
||||
</span>
|
||||
{chunks && <span className="tabular-nums">{chunks}</span>}
|
||||
{closeReason && (
|
||||
<span className="text-destructive-text">{closeReason}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{progress !== null && (
|
||||
<div className="h-1 overflow-hidden rounded-full bg-muted">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ scaleX: progress }}
|
||||
transition={
|
||||
reduceMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.22, ease: MOTION_EASE_OUT }
|
||||
}
|
||||
style={{ transformOrigin: "left", willChange: "transform" }}
|
||||
className="h-full w-full bg-success"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuChevronRight, LuInfo } from "react-icons/lu";
|
||||
import {
|
||||
CADENCES,
|
||||
type CadenceId,
|
||||
cadenceForMask,
|
||||
clockToMinutes,
|
||||
enableProfileSync,
|
||||
formatHours,
|
||||
minutesToClock,
|
||||
nightsPerWeek,
|
||||
type PreflightResult,
|
||||
preflight,
|
||||
preflightFixLabel,
|
||||
preflightReason,
|
||||
profileTimezone,
|
||||
RemoteHoursMeter,
|
||||
resolvedOs,
|
||||
} from "@/components/cookie-bot-shared";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
AnimatedTabsList,
|
||||
AnimatedTabsTrigger,
|
||||
} from "@/components/ui/animated-tabs";
|
||||
import { AutoHeight } from "@/components/ui/auto-height";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RippleButton } from "@/components/ui/ripple";
|
||||
import { StepTransition } from "@/components/ui/step-transition";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import {
|
||||
type CookieBotConflict,
|
||||
type CookieBotPlatform,
|
||||
type CookieBotPreset,
|
||||
type CookieBotPresetList,
|
||||
type CookieBotSchedule,
|
||||
type CookieBotScheduleInput,
|
||||
checkCookieBotConflicts,
|
||||
getCookieBotPresets,
|
||||
saveCookieBotSchedule,
|
||||
} from "@/lib/cookie-bot";
|
||||
import { SCHEDULE_BOUNDS } from "@/lib/cookie-bot-limits";
|
||||
import { canUseCookieBot } from "@/lib/entitlements";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
|
||||
/**
|
||||
* The cap when the server has not published one for the chosen preset. It is a
|
||||
* user-facing ceiling on machine time, not a description of what the bot does
|
||||
* with it — the contract allows 5..120 and this sits comfortably inside.
|
||||
*/
|
||||
const FALLBACK_MAX_MINUTES = 40;
|
||||
|
||||
/**
|
||||
* The server's schedule bounds. Mirrored, never re-declared: see
|
||||
* `src/lib/cookie-bot-limits.ts` and the test that pins them.
|
||||
*/
|
||||
const {
|
||||
minMaxMinutes: MIN_MAX_MINUTES,
|
||||
maxMaxMinutes: MAX_MAX_MINUTES,
|
||||
minSites: MIN_SITES,
|
||||
maxSites: MAX_SITES,
|
||||
} = SCHEDULE_BOUNDS;
|
||||
|
||||
/** The default start: deep enough into the night to be plausible anywhere. */
|
||||
const DEFAULT_RUN_AT_MINUTE = 2 * 60;
|
||||
|
||||
const PRESET_LABEL_KEYS: Record<string, string> = {
|
||||
light: "cookieBot.preset.light",
|
||||
balanced: "cookieBot.preset.balanced",
|
||||
deep: "cookieBot.preset.deep",
|
||||
};
|
||||
|
||||
interface EnrolTarget {
|
||||
profile: BrowserProfile;
|
||||
check: PreflightResult;
|
||||
}
|
||||
|
||||
interface ConflictNotice {
|
||||
email: string;
|
||||
time: string;
|
||||
profileIds: string[];
|
||||
}
|
||||
|
||||
export interface CookieBotEnrolDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** The profiles being enrolled. One for the fast path, many for a bulk enrol. */
|
||||
profiles: BrowserProfile[];
|
||||
/** Pre-fills the form when editing an existing enrolment. */
|
||||
existing?: CookieBotSchedule | null;
|
||||
/** Extra work after the shared store has already been refreshed. */
|
||||
onSaved?: () => void;
|
||||
/**
|
||||
* Opens the profile's sync settings, for an end-to-end encrypted profile.
|
||||
* Omitted where there is no sub-page to hand off to; the reason still shows,
|
||||
* only the one-click repair is absent.
|
||||
*/
|
||||
onOpenProfileSync?: (profile: BrowserProfile) => void;
|
||||
/** Opens proxy assignment for profiles with no exit node. */
|
||||
onAssignProxy?: (profileIds: string[]) => void;
|
||||
}
|
||||
|
||||
export function CookieBotEnrolDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profiles,
|
||||
existing,
|
||||
onSaved,
|
||||
onOpenProfileSync,
|
||||
onAssignProxy,
|
||||
}: CookieBotEnrolDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const { user } = useCloudAuth();
|
||||
// The same entitlement answer every other consumer of the shared store
|
||||
// passes; see the note in cookie-bot-page.tsx.
|
||||
const { quota, refresh: refreshCookieBot } = useCookieBot(
|
||||
canUseCookieBot(user),
|
||||
cookieBotScopeFor(user),
|
||||
);
|
||||
const canReplaceOthers =
|
||||
!user?.teamId || user.teamRole === "owner" || user.teamRole === "admin";
|
||||
|
||||
const [presets, setPresets] = useState<CookieBotPresetList | null>(null);
|
||||
const [isLoadingPresets, setIsLoadingPresets] = useState(false);
|
||||
const presetList = useMemo(() => presets?.presets ?? [], [presets]);
|
||||
const defaultPreset = useMemo(() => pickDefaultPreset(presets), [presets]);
|
||||
|
||||
/**
|
||||
* Read the server's catalogue of intensities.
|
||||
*
|
||||
* An imperative loader rather than an effect keyed off an attempt counter,
|
||||
* because the enrolment cannot name a preset without this: a transient
|
||||
* failure of a secondary request disables the PRIMARY action, so the retry
|
||||
* has to be a real call the button can make, not a state flip a lint fix can
|
||||
* quietly drop from a dependency array.
|
||||
*/
|
||||
const loadPresets = useCallback(async () => {
|
||||
setIsLoadingPresets(true);
|
||||
try {
|
||||
setPresets(await getCookieBotPresets());
|
||||
} catch {
|
||||
// Losing the catalogue costs the depth control and blocks the save; the
|
||||
// note beside the retry button says so.
|
||||
setPresets(null);
|
||||
} finally {
|
||||
setIsLoadingPresets(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
void loadPresets();
|
||||
}, [isOpen, loadPresets]);
|
||||
|
||||
const [preset, setPreset] = useState<string>("");
|
||||
const [runAt, setRunAt] = useState<string>(
|
||||
minutesToClock(DEFAULT_RUN_AT_MINUTE),
|
||||
);
|
||||
const [daysMask, setDaysMask] = useState<number>(CADENCES[0].mask);
|
||||
const [maxMinutes, setMaxMinutes] = useState<number>(FALLBACK_MAX_MINUTES);
|
||||
const [maxMinutesTouched, setMaxMinutesTouched] = useState(false);
|
||||
const [sitesText, setSitesText] = useState("");
|
||||
const [adjustOpen, setAdjustOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [fixingId, setFixingId] = useState<string | null>(null);
|
||||
const [conflict, setConflict] = useState<ConflictNotice | null>(null);
|
||||
const [conflictAcknowledged, setConflictAcknowledged] = useState(false);
|
||||
|
||||
const isEdit = Boolean(existing);
|
||||
const single = profiles.length === 1 ? profiles[0] : null;
|
||||
|
||||
// Reset to the defaults every time the dialog is opened, so a previous
|
||||
// enrolment's answers never leak into the next one.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setPreset(existing?.preset ?? defaultPreset?.id ?? "");
|
||||
setRunAt(minutesToClock(existing?.run_at_minute ?? DEFAULT_RUN_AT_MINUTE));
|
||||
setDaysMask(existing?.days_mask ?? CADENCES[0].mask);
|
||||
setMaxMinutes(
|
||||
existing?.max_minutes ??
|
||||
defaultPreset?.typical_minutes ??
|
||||
FALLBACK_MAX_MINUTES,
|
||||
);
|
||||
setMaxMinutesTouched(Boolean(existing));
|
||||
setSitesText((existing?.sites ?? []).join("\n"));
|
||||
setAdjustOpen(false);
|
||||
setConflict(null);
|
||||
setConflictAcknowledged(false);
|
||||
setIsSaving(false);
|
||||
}, [isOpen, existing, defaultPreset]);
|
||||
|
||||
// Switching depth moves the cap with it, until the operator sets their own.
|
||||
useEffect(() => {
|
||||
if (maxMinutesTouched) return;
|
||||
const chosen = presetList.find((p) => p.id === preset);
|
||||
if (chosen?.typical_minutes) setMaxMinutes(chosen.typical_minutes);
|
||||
}, [preset, presetList, maxMinutesTouched]);
|
||||
|
||||
const targets: EnrolTarget[] = useMemo(
|
||||
() => profiles.map((profile) => ({ profile, check: preflight(profile) })),
|
||||
[profiles],
|
||||
);
|
||||
const eligible = useMemo(
|
||||
() => targets.filter((target) => target.check.eligible),
|
||||
[targets],
|
||||
);
|
||||
const blocked = useMemo(
|
||||
() => targets.filter((target) => !target.check.eligible),
|
||||
[targets],
|
||||
);
|
||||
|
||||
const runAtMinute = clockToMinutes(runAt);
|
||||
const sites = useMemo(() => normaliseSites(sitesText), [sitesText]);
|
||||
const sitesTooMany = sites.length > MAX_SITES;
|
||||
// v1 browses the user's declared sites and nothing else, so an empty list is
|
||||
// not a schedule the server can accept — it 400s with COOKIE_BOT_SITE_LIMIT
|
||||
// and, before this, `canSubmit` did not ask. The three-click happy path
|
||||
// (bot cell -> Enrol -> "Enrol tonight") posted `sites: []` and failed every
|
||||
// single time, with the only input the bot cannot run without hidden inside a
|
||||
// collapsed disclosure.
|
||||
const sitesTooFew = sites.length < MIN_SITES;
|
||||
const maxMinutesValid =
|
||||
Number.isFinite(maxMinutes) &&
|
||||
maxMinutes >= MIN_MAX_MINUTES &&
|
||||
maxMinutes <= MAX_MAX_MINUTES;
|
||||
const presetsUnavailable = presets === null;
|
||||
|
||||
// A week's machine time from the operator's own two numbers. The budget it is
|
||||
// compared against is the server's; nothing here decides entitlement.
|
||||
const weeklyHours = (nightsPerWeek(daysMask) * maxMinutes) / 60;
|
||||
const remainingHours = quota?.remaining_hours ?? null;
|
||||
const overBudget =
|
||||
remainingHours !== null && weeklyHours > remainingHours && !isEdit;
|
||||
|
||||
const canSubmit =
|
||||
eligible.length > 0 &&
|
||||
preset.length > 0 &&
|
||||
runAtMinute !== null &&
|
||||
maxMinutesValid &&
|
||||
!sitesTooMany &&
|
||||
!sitesTooFew &&
|
||||
!isSaving;
|
||||
|
||||
// A single-profile enrolment asks the server up front whether a teammate
|
||||
// already owns this profile's night, so the one decision that matters is
|
||||
// made before the user commits rather than after.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !single || isEdit) return;
|
||||
let cancelled = false;
|
||||
void checkCookieBotConflicts(single.id, {})
|
||||
.then((found) => {
|
||||
if (cancelled) return;
|
||||
const overlapping = found.filter((c) => c.enabled);
|
||||
if (overlapping.length === 0) return;
|
||||
setConflict(toNotice(overlapping[0], [single.id]));
|
||||
})
|
||||
.catch(() => {
|
||||
// A conflict check that cannot run is not a reason to block enrolment;
|
||||
// the save path re-detects the same 409 and shows the same block.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, single, isEdit]);
|
||||
|
||||
const buildInput = useCallback(
|
||||
(profile: BrowserProfile): CookieBotScheduleInput | null => {
|
||||
const minute = clockToMinutes(runAt);
|
||||
const platform = resolvedOs(profile);
|
||||
if (minute === null || !platform) return null;
|
||||
return {
|
||||
profile_name: profile.name,
|
||||
platform: platform as CookieBotPlatform,
|
||||
enabled: true,
|
||||
run_at_minute: minute,
|
||||
days_mask: daysMask,
|
||||
timezone: profileTimezone(profile),
|
||||
preset,
|
||||
max_minutes: Math.round(maxMinutes),
|
||||
sites,
|
||||
};
|
||||
},
|
||||
[runAt, daysMask, preset, maxMinutes, sites],
|
||||
);
|
||||
|
||||
const submit = useCallback(
|
||||
async (acknowledge: boolean, only?: string[]) => {
|
||||
const list = only
|
||||
? eligible.filter((target) => only.includes(target.profile.id))
|
||||
: eligible;
|
||||
if (list.length === 0) return;
|
||||
setIsSaving(true);
|
||||
let saved = 0;
|
||||
const conflicted: string[] = [];
|
||||
let firstError: unknown = null;
|
||||
let conflictParams: { email: string; time: string } | null = null;
|
||||
|
||||
for (const target of list) {
|
||||
const input = buildInput(target.profile);
|
||||
if (!input) continue;
|
||||
try {
|
||||
await saveCookieBotSchedule(target.profile.id, input, acknowledge);
|
||||
saved += 1;
|
||||
} catch (error) {
|
||||
const parsed = parseBackendError(error);
|
||||
if (parsed?.code === "COOKIE_BOT_SCHEDULE_CONFLICT") {
|
||||
conflicted.push(target.profile.id);
|
||||
if (!conflictParams) {
|
||||
conflictParams = {
|
||||
email: parsed.params?.email ?? "",
|
||||
time: parsed.params?.time ?? minutesToClock(runAtMinute ?? 0),
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!firstError) firstError = error;
|
||||
}
|
||||
}
|
||||
|
||||
setIsSaving(false);
|
||||
|
||||
if (conflicted.length > 0 && conflictParams) {
|
||||
setConflict({
|
||||
email: conflictParams.email,
|
||||
time: conflictParams.time,
|
||||
profileIds: conflicted,
|
||||
});
|
||||
if (saved > 0) {
|
||||
void refreshCookieBot();
|
||||
onSaved?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstError) {
|
||||
showErrorToast(translateBackendError(t, firstError));
|
||||
if (saved > 0) {
|
||||
void refreshCookieBot();
|
||||
onSaved?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (saved > 0) {
|
||||
showSuccessToast(
|
||||
isEdit
|
||||
? t("cookieBot.enrol.saved")
|
||||
: t("cookieBot.enrol.enrolled", { count: saved }),
|
||||
);
|
||||
void refreshCookieBot();
|
||||
onSaved?.();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[
|
||||
eligible,
|
||||
buildInput,
|
||||
isEdit,
|
||||
onSaved,
|
||||
onClose,
|
||||
t,
|
||||
runAtMinute,
|
||||
refreshCookieBot,
|
||||
],
|
||||
);
|
||||
|
||||
const applyFix = useCallback(
|
||||
async (target: EnrolTarget) => {
|
||||
const { profile, check } = target;
|
||||
if (check.fix === "proxy") {
|
||||
onAssignProxy?.([profile.id]);
|
||||
return;
|
||||
}
|
||||
if (check.fix === "syncSettings") {
|
||||
onOpenProfileSync?.(profile);
|
||||
return;
|
||||
}
|
||||
if (check.fix !== "sync") return;
|
||||
setFixingId(profile.id);
|
||||
try {
|
||||
await enableProfileSync(profile.id);
|
||||
} catch (error) {
|
||||
showErrorToast(
|
||||
parseBackendError(error)
|
||||
? translateBackendError(t, error)
|
||||
: t("cookieBot.preflight.fixFailed"),
|
||||
);
|
||||
} finally {
|
||||
setFixingId(null);
|
||||
}
|
||||
},
|
||||
[onAssignProxy, onOpenProfileSync, t],
|
||||
);
|
||||
|
||||
const showConflict = conflict !== null && !conflictAcknowledged;
|
||||
|
||||
const title = isEdit
|
||||
? t("cookieBot.enrol.editTitle")
|
||||
: single
|
||||
? t("cookieBot.enrol.titleOne", { name: single.name })
|
||||
: t("cookieBot.enrol.titleCount", { count: profiles.length });
|
||||
|
||||
const cadenceId = cadenceForMask(daysMask);
|
||||
// One complete sentence per cadence rather than a label spliced into a
|
||||
// fragment: "Runs Nightly at 02:00" only reads correctly in English, and a
|
||||
// translator needs the whole clause to reorder.
|
||||
const summaryKey = cadenceId
|
||||
? `cookieBot.enrol.summary${cadenceId[0].toUpperCase()}${cadenceId.slice(1)}`
|
||||
: "cookieBot.enrol.summaryCustom";
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-md flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("cookieBot.enrol.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
|
||||
{/* The whole default, said once, as one sentence. One key, not five
|
||||
fragments: a translator has to be free to reorder it. */}
|
||||
<p className="text-sm tabular-nums text-foreground">
|
||||
{t(summaryKey, {
|
||||
count: nightsPerWeek(daysMask),
|
||||
time: minutesToClock(runAtMinute ?? DEFAULT_RUN_AT_MINUTE),
|
||||
minutes: Math.round(maxMinutes),
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
overBudget ? "text-warning-text" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{remainingHours === null
|
||||
? t("cookieBot.hours.estimateOnly", {
|
||||
hours: formatHours(weeklyHours),
|
||||
})
|
||||
: overBudget
|
||||
? t("cookieBot.hours.estimateOverBudget", {
|
||||
hours: formatHours(weeklyHours),
|
||||
remaining: formatHours(remainingHours),
|
||||
})
|
||||
: t("cookieBot.hours.estimate", {
|
||||
hours: formatHours(weeklyHours),
|
||||
remaining: formatHours(remainingHours),
|
||||
})}
|
||||
</p>
|
||||
<RemoteHoursMeter
|
||||
quota={quota}
|
||||
isLoading={false}
|
||||
variant="inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{blocked.length > 0 && (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-warning/50 bg-warning/10 p-3">
|
||||
<p className="text-xs font-medium text-warning-text">
|
||||
{t("cookieBot.preflight.ineligible", {
|
||||
count: blocked.length,
|
||||
})}
|
||||
</p>
|
||||
{blocked.map((target) => {
|
||||
const reachable =
|
||||
target.check.fix === "sync" ||
|
||||
(target.check.fix === "proxy" && Boolean(onAssignProxy)) ||
|
||||
(target.check.fix === "syncSettings" &&
|
||||
Boolean(onOpenProfileSync));
|
||||
const fixLabel = reachable
|
||||
? preflightFixLabel(t, target.check.fix)
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={target.profile.id}
|
||||
className="flex h-7 items-center gap-2 text-xs"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-foreground">
|
||||
{target.profile.name}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1 text-muted-foreground">
|
||||
{preflightReason(t, target.check)}
|
||||
{target.check.code === "noExitNode" && <ExitNodeHint />}
|
||||
</span>
|
||||
{fixLabel && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 shrink-0 text-[11px]"
|
||||
disabled={fixingId === target.profile.id}
|
||||
onClick={() => {
|
||||
void applyFix(target);
|
||||
}}
|
||||
>
|
||||
{fixLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sites is not an adjustment: v1 browses the user's declared list
|
||||
and nothing else, so it is the one input without which there is no
|
||||
run. It sat inside the collapsed "Adjust schedule" disclosure,
|
||||
which made the default path a form the server always refused. */}
|
||||
<Field label={t("cookieBot.enrol.sitesLabel")}>
|
||||
<Textarea
|
||||
value={sitesText}
|
||||
onChange={(event) => {
|
||||
setSitesText(event.target.value);
|
||||
}}
|
||||
rows={4}
|
||||
placeholder={t("cookieBot.enrol.sitesPlaceholder")}
|
||||
className="text-xs"
|
||||
aria-invalid={sitesTooMany || sitesTooFew}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-[11px]",
|
||||
sitesTooMany
|
||||
? "text-destructive-text"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{sitesTooMany
|
||||
? t("cookieBot.enrol.sitesTooMany", { max: MAX_SITES })
|
||||
: sitesTooFew
|
||||
? t("cookieBot.enrol.sitesRequired")
|
||||
: t("cookieBot.enrol.sitesHint", { count: sites.length })}
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
<div className="rounded-md border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAdjustOpen((open) => !open);
|
||||
}}
|
||||
aria-expanded={adjustOpen}
|
||||
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs font-medium text-foreground transition-colors duration-100 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
animate={{ rotate: adjustOpen ? 90 : 0 }}
|
||||
transition={{
|
||||
duration: reduceMotion ? 0 : 0.16,
|
||||
ease: MOTION_EASE_OUT,
|
||||
}}
|
||||
className="inline-flex shrink-0"
|
||||
>
|
||||
<LuChevronRight className="size-3.5" />
|
||||
</motion.span>
|
||||
{t("cookieBot.enrol.adjust")}
|
||||
</button>
|
||||
|
||||
<AutoHeight deps={[adjustOpen, presetList.length]}>
|
||||
<AnimatePresence initial={false}>
|
||||
{adjustOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
|
||||
transition={{
|
||||
duration: reduceMotion ? 0.15 : 0.16,
|
||||
ease: MOTION_EASE_OUT,
|
||||
}}
|
||||
className="flex flex-col gap-3 border-t border-border px-3 py-3"
|
||||
>
|
||||
<Field label={t("cookieBot.enrol.cadenceLabel")}>
|
||||
<AnimatedTabs
|
||||
value={cadenceId ?? "custom"}
|
||||
onValueChange={(value) => {
|
||||
const match = CADENCES.find(
|
||||
(c) => c.id === (value as CadenceId),
|
||||
);
|
||||
if (match) setDaysMask(match.mask);
|
||||
}}
|
||||
>
|
||||
<AnimatedTabsList>
|
||||
{CADENCES.map((cadence) => (
|
||||
<AnimatedTabsTrigger
|
||||
key={cadence.id}
|
||||
value={cadence.id}
|
||||
className="h-7 px-2.5 text-xs"
|
||||
>
|
||||
{t(cadence.labelKey)}
|
||||
</AnimatedTabsTrigger>
|
||||
))}
|
||||
</AnimatedTabsList>
|
||||
</AnimatedTabs>
|
||||
</Field>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Field label={t("cookieBot.enrol.timeLabel")}>
|
||||
<Input
|
||||
type="time"
|
||||
value={runAt}
|
||||
onChange={(event) => {
|
||||
setRunAt(event.target.value);
|
||||
}}
|
||||
className="h-8 w-28 font-mono tabular-nums"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("cookieBot.enrol.maxMinutesLabel")}>
|
||||
<Input
|
||||
type="number"
|
||||
min={MIN_MAX_MINUTES}
|
||||
max={MAX_MAX_MINUTES}
|
||||
value={String(maxMinutes)}
|
||||
onChange={(event) => {
|
||||
setMaxMinutesTouched(true);
|
||||
setMaxMinutes(Number(event.target.value));
|
||||
}}
|
||||
className="h-8 w-24 tabular-nums"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("cookieBot.enrol.timeHint")}
|
||||
</p>
|
||||
|
||||
{presetList.length > 0 && (
|
||||
<Field label={t("cookieBot.enrol.intensityLabel")}>
|
||||
<AnimatedTabs
|
||||
value={preset}
|
||||
onValueChange={(value) => {
|
||||
setPreset(value);
|
||||
setMaxMinutesTouched(false);
|
||||
}}
|
||||
>
|
||||
<AnimatedTabsList>
|
||||
{presetList.map((item) => (
|
||||
<AnimatedTabsTrigger
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
className="h-7 px-2.5 text-xs"
|
||||
>
|
||||
{presetLabel(t, item)}
|
||||
</AnimatedTabsTrigger>
|
||||
))}
|
||||
</AnimatedTabsList>
|
||||
</AnimatedTabs>
|
||||
</Field>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</AutoHeight>
|
||||
</div>
|
||||
|
||||
{presetsUnavailable && (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
{t("cookieBot.enrol.presetsUnavailable")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 shrink-0 text-[11px]"
|
||||
disabled={isLoadingPresets}
|
||||
onClick={() => {
|
||||
void loadPresets();
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The footer is one slot: either the actions, or the single decision
|
||||
a teammate's existing enrolment forces. StepTransition is the shell's
|
||||
own forward/back language, reused rather than reinvented, and this
|
||||
genuinely is a step. */}
|
||||
<StepTransition
|
||||
transitionKey={showConflict ? "conflict" : "actions"}
|
||||
direction={showConflict ? 1 : -1}
|
||||
className="shrink-0"
|
||||
>
|
||||
{showConflict && conflict ? (
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("cookieBot.conflict.title", {
|
||||
email: conflict.email,
|
||||
time: conflict.time,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("cookieBot.conflict.detail")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{t("cookieBot.conflict.keepTheirs")}
|
||||
</Button>
|
||||
{canReplaceOthers ? (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
disabled={isSaving}
|
||||
onClick={() => {
|
||||
setConflictAcknowledged(true);
|
||||
void submit(true, conflict.profileIds);
|
||||
}}
|
||||
>
|
||||
{t("cookieBot.conflict.replace")}
|
||||
</RippleButton>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Button size="sm" disabled>
|
||||
{t("cookieBot.conflict.replace")}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("cookieBot.conflict.replaceForbidden")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{conflict.email.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
void navigator.clipboard
|
||||
.writeText(conflict.email)
|
||||
.then(() => {
|
||||
showSuccessToast(t("cookieBot.conflict.emailCopied"));
|
||||
})
|
||||
.catch(() => {
|
||||
showErrorToast(t("cookieBot.conflict.copyFailed"));
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("cookieBot.conflict.askThem", { email: conflict.email })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
autoFocus
|
||||
disabled={!canSubmit}
|
||||
onClick={() => {
|
||||
void submit(conflictAcknowledged);
|
||||
}}
|
||||
>
|
||||
{confirmLabel(t, {
|
||||
isEdit,
|
||||
eligible: eligible.length,
|
||||
total: targets.length,
|
||||
saving: isSaving,
|
||||
needsSites: sitesTooFew,
|
||||
needsPreset: preset.length === 0,
|
||||
})}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
</StepTransition>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a proxy is not optional, at the point where the refusal happens. A run
|
||||
* without one leaves the fleet's own datacenter address, and hours of traffic
|
||||
* from a hosting ASN costs the profile more than never warming it.
|
||||
*/
|
||||
function ExitNodeHint() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-default text-warning-text">
|
||||
<LuInfo className="size-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-64">
|
||||
{t("cookieBot.preflight.exitNodeHint")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the confirm button says, including WHY it is disabled.
|
||||
*
|
||||
* A greyed "Enrol tonight" with the explanation twelve pixels away in a
|
||||
* different colour is a dead end: the user clicks a button that answers
|
||||
* nothing. Each blocked state names itself instead.
|
||||
*/
|
||||
function confirmLabel(
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
state: {
|
||||
isEdit: boolean;
|
||||
eligible: number;
|
||||
total: number;
|
||||
saving: boolean;
|
||||
needsSites: boolean;
|
||||
needsPreset: boolean;
|
||||
},
|
||||
): string {
|
||||
if (state.saving) return t("cookieBot.enrol.saving");
|
||||
if (state.eligible === 0 && !state.isEdit)
|
||||
return t("cookieBot.enrol.fixFirst");
|
||||
if (state.needsSites) return t("cookieBot.enrol.addSitesFirst");
|
||||
if (state.needsPreset) return t("cookieBot.enrol.presetsMissing");
|
||||
if (state.isEdit) return t("common.buttons.save");
|
||||
if (state.eligible < state.total) {
|
||||
return t("cookieBot.enrol.confirmSome", {
|
||||
eligible: state.eligible,
|
||||
total: state.total,
|
||||
});
|
||||
}
|
||||
return t("cookieBot.enrol.confirm");
|
||||
}
|
||||
|
||||
function presetLabel(
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
preset: CookieBotPreset,
|
||||
): string {
|
||||
const key = PRESET_LABEL_KEYS[preset.id];
|
||||
if (key) return t(key);
|
||||
// A preset newer than this build still renders: the server ships an English
|
||||
// label with it, which beats printing a bare id.
|
||||
return preset.name ?? preset.id;
|
||||
}
|
||||
|
||||
function pickDefaultPreset(
|
||||
presets: CookieBotPresetList | null,
|
||||
): CookieBotPreset | null {
|
||||
if (!presets) return null;
|
||||
const byId = presets.default_preset
|
||||
? presets.presets.find((p) => p.id === presets.default_preset)
|
||||
: undefined;
|
||||
return (
|
||||
byId ??
|
||||
presets.presets.find((p) => p.recommended) ??
|
||||
presets.presets[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The operator's own list, tidied: one entry per line, a bare host promoted to
|
||||
* https, duplicates dropped. No entry is ever added that the user did not type.
|
||||
*/
|
||||
function normaliseSites(text: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const withScheme = /^https?:\/\//i.test(line) ? line : `https://${line}`;
|
||||
if (seen.has(withScheme)) continue;
|
||||
seen.add(withScheme);
|
||||
out.push(withScheme);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toNotice(
|
||||
conflict: CookieBotConflict,
|
||||
profileIds: string[],
|
||||
): ConflictNotice {
|
||||
return {
|
||||
email: conflict.email,
|
||||
time: minutesToClock(conflict.run_at_minute),
|
||||
profileIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCookie, LuPencil, LuTrash2 } from "react-icons/lu";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Tooltip as ChartTooltip,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { RunFilter } from "@/components/cookie-bot-activity";
|
||||
import {
|
||||
describeCadence,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
minutesToClock,
|
||||
parseIso,
|
||||
StatusDot,
|
||||
scheduleBlockedReason,
|
||||
scheduleTone,
|
||||
sessionDisplayName,
|
||||
sessionPhaseLabel,
|
||||
sessionTone,
|
||||
useNextDue,
|
||||
} from "@/components/cookie-bot-shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
|
||||
import { RippleButton } from "@/components/ui/ripple";
|
||||
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 type {
|
||||
CookieBotRun,
|
||||
CookieBotSchedule,
|
||||
RemoteHoursQuota,
|
||||
} from "@/lib/cookie-bot";
|
||||
import type { RemoteSessionState } from "@/lib/remote-sessions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
|
||||
const CHART_NIGHTS = 30;
|
||||
|
||||
interface CookieBotOverviewProps {
|
||||
schedules: CookieBotSchedule[];
|
||||
runs: CookieBotRun[];
|
||||
live: RemoteSessionState[];
|
||||
quota: RemoteHoursQuota | null;
|
||||
profiles: BrowserProfile[];
|
||||
isLoading: boolean;
|
||||
currentUserId: string | null;
|
||||
onEnrol: () => void;
|
||||
onEditSchedule: (schedule: CookieBotSchedule) => void;
|
||||
onRemoveSchedule: (schedule: CookieBotSchedule) => void;
|
||||
onJumpToActivity: (filter: RunFilter) => void;
|
||||
}
|
||||
|
||||
export function CookieBotOverview({
|
||||
schedules,
|
||||
runs,
|
||||
live,
|
||||
quota,
|
||||
profiles,
|
||||
isLoading,
|
||||
currentUserId,
|
||||
onEnrol,
|
||||
onEditSchedule,
|
||||
onRemoveSchedule,
|
||||
onJumpToActivity,
|
||||
}: CookieBotOverviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { next, nextAt, dueCount } = useNextDue(schedules);
|
||||
const profileIndex = useMemo(
|
||||
() => new Map(profiles.map((p) => [p.id, p])),
|
||||
[profiles],
|
||||
);
|
||||
|
||||
const recent = useMemo(() => summariseRecent(runs), [runs]);
|
||||
const chartData = useMemo(() => nightlyMinutes(runs), [runs]);
|
||||
const exhausted =
|
||||
quota !== null && quota.granted_hours > 0 && quota.remaining_hours <= 0;
|
||||
const resetDate = formatDate(quota?.period_end);
|
||||
|
||||
if (!isLoading && schedules.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<LuCookie className="size-12 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("cookieBot.empty.title")}
|
||||
</p>
|
||||
<p className="mt-1 max-w-md text-xs text-muted-foreground">
|
||||
{t("cookieBot.empty.hint")}
|
||||
</p>
|
||||
</div>
|
||||
<RippleButton size="sm" onClick={onEnrol}>
|
||||
{t("cookieBot.empty.cta")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<TonightStrip
|
||||
live={live}
|
||||
nextAt={nextAt}
|
||||
nextMinute={next?.run_at_minute ?? null}
|
||||
dueCount={dueCount}
|
||||
profileIndex={profileIndex}
|
||||
/>
|
||||
|
||||
{exhausted && (
|
||||
<div className="shrink-0 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning-text">
|
||||
{resetDate
|
||||
? t("cookieBot.hours.exhaustedOn", { date: resetDate })
|
||||
: t("cookieBot.hours.exhausted")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("cookieBot.lastDay.label")}
|
||||
</span>
|
||||
{recent.total === 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{t("cookieBot.lastDay.none")}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<RecentSegment
|
||||
label={t("cookieBot.lastDay.ran", { count: recent.succeeded })}
|
||||
tone="text-foreground"
|
||||
onClick={() => {
|
||||
onJumpToActivity("succeeded");
|
||||
}}
|
||||
/>
|
||||
{recent.partial > 0 && (
|
||||
<RecentSegment
|
||||
label={t("cookieBot.lastDay.partial", {
|
||||
count: recent.partial,
|
||||
})}
|
||||
tone="text-warning-text"
|
||||
onClick={() => {
|
||||
onJumpToActivity("partial");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{recent.failed > 0 && (
|
||||
<RecentSegment
|
||||
label={t("cookieBot.lastDay.failed", { count: recent.failed })}
|
||||
tone="text-destructive-text"
|
||||
onClick={() => {
|
||||
onJumpToActivity("failed");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<p className="mb-1 text-xs font-medium text-foreground">
|
||||
{t("cookieBot.chart.machineTime")}
|
||||
</p>
|
||||
<div className="h-[clamp(140px,20vh,260px)] w-full">
|
||||
{isLoading && runs.length === 0 ? (
|
||||
<Skeleton className="size-full" />
|
||||
) : (
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={1}
|
||||
minHeight={1}
|
||||
>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 6, right: 8, bottom: 0, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="cookieBotMinutesGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.5}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
className="text-xs"
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-xs"
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
width={36}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const minutes = Number(payload[0]?.value ?? 0);
|
||||
return (
|
||||
<div className="rounded-lg border bg-popover px-3 py-2 shadow-lg">
|
||||
<p className="text-xs font-medium text-popover-foreground">
|
||||
{String(label)}
|
||||
</p>
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("cookieBot.chart.minutes", { minutes })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="minutes"
|
||||
stroke="var(--chart-1)"
|
||||
fill="url(#cookieBotMinutesGradient)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FadingScrollArea
|
||||
className="min-h-0 flex-1"
|
||||
style={{ "--scroll-fade-top-offset": "32px" } as React.CSSProperties}
|
||||
>
|
||||
<Table
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableRow>
|
||||
<TableHead className="max-w-0">
|
||||
{t("cookieBot.enrolled.columnProfile")}
|
||||
</TableHead>
|
||||
<TableHead className="hidden w-32 @2xl:table-cell">
|
||||
{t("cookieBot.enrolled.columnCadence")}
|
||||
</TableHead>
|
||||
<TableHead className="w-20">
|
||||
{t("cookieBot.enrolled.columnTime")}
|
||||
</TableHead>
|
||||
<TableHead className="hidden w-40 @3xl:table-cell">
|
||||
{t("cookieBot.enrolled.columnNextRun")}
|
||||
</TableHead>
|
||||
<TableHead className="hidden w-40 @4xl:table-cell">
|
||||
{t("cookieBot.enrolled.columnLastRun")}
|
||||
</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && schedules.length === 0
|
||||
? Array.from({ length: 5 }, (_, i) => (
|
||||
<TableRow key={`enrolled-skeleton-${i}`}>
|
||||
<TableCell colSpan={6}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton
|
||||
className="h-3"
|
||||
style={{ width: `${30 + ((i * 17) % 40)}%` }}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-10" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
: schedules.map((schedule) => (
|
||||
<EnrolledRow
|
||||
key={`${schedule.owner_user_id ?? "me"}-${schedule.profile_id}`}
|
||||
schedule={schedule}
|
||||
mine={
|
||||
!schedule.owner_user_id ||
|
||||
schedule.owner_user_id === currentUserId
|
||||
}
|
||||
exhausted={exhausted}
|
||||
onEdit={onEditSchedule}
|
||||
onRemove={onRemoveSchedule}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</FadingScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentSegment({
|
||||
label,
|
||||
tone,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
tone: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"cursor-pointer tabular-nums underline-offset-2 transition-colors duration-100 hover:underline",
|
||||
tone,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TonightStrip({
|
||||
live,
|
||||
nextAt,
|
||||
nextMinute,
|
||||
dueCount,
|
||||
profileIndex,
|
||||
}: {
|
||||
live: RemoteSessionState[];
|
||||
nextAt: Date | null;
|
||||
nextMinute: number | null;
|
||||
dueCount: number;
|
||||
profileIndex: Map<string, BrowserProfile>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const running = live.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-3 rounded-md border border-border bg-card px-3 py-2.5">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{running ? t("cookieBot.running.label") : t("cookieBot.tonight.label")}
|
||||
</span>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={running ? `running-${live.length}` : "idle"}
|
||||
initial={{ opacity: reduceMotion ? 1 : 0.55 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: reduceMotion ? 0.01 : 0.12 }}
|
||||
className="flex min-w-0 flex-1 items-center gap-2"
|
||||
>
|
||||
{running ? (
|
||||
<>
|
||||
<StatusDot
|
||||
tone={sessionTone(live[0])}
|
||||
pulse={live[0].state === "provisioning"}
|
||||
/>
|
||||
<span className="min-w-0 truncate text-sm font-medium text-foreground">
|
||||
{sessionDisplayName(live[0], profileIndex, undefined) ??
|
||||
t("cookieBot.live.unnamedSession")}
|
||||
</span>
|
||||
<span className="shrink-0 text-sm text-muted-foreground">
|
||||
{sessionPhaseLabel(t, live[0])}
|
||||
</span>
|
||||
{live.length > 1 && (
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{t("cookieBot.running.more", { count: live.length - 1 })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : nextMinute === null ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("cookieBot.tonight.nothingScheduled")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm tabular-nums text-foreground">
|
||||
{t("cookieBot.tonight.nextRun", {
|
||||
time: minutesToClock(nextMinute),
|
||||
})}
|
||||
{nextAt ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="ml-2 cursor-default text-muted-foreground">
|
||||
{t("cookieBot.tonight.dueCount", { count: dueCount })}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{formatDateTime(nextAt.toISOString()) ?? ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{t("cookieBot.tonight.dueCount", { count: dueCount })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnrolledRow({
|
||||
schedule,
|
||||
mine,
|
||||
exhausted,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
schedule: CookieBotSchedule;
|
||||
mine: boolean;
|
||||
exhausted: boolean;
|
||||
onEdit: (schedule: CookieBotSchedule) => void;
|
||||
onRemove: (schedule: CookieBotSchedule) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const blocked = scheduleBlockedReason(t, schedule);
|
||||
|
||||
return (
|
||||
<TableRow className="hover:bg-muted/30">
|
||||
<TableCell className="max-w-0 truncate">
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusDot tone={scheduleTone(schedule)} />
|
||||
<span className="min-w-0 truncate">{schedule.profile_name}</span>
|
||||
{!mine && schedule.owner_email && (
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{schedule.owner_email}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground @2xl:table-cell">
|
||||
{describeCadence(t, schedule.days_mask)}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{minutesToClock(schedule.run_at_minute)}
|
||||
</TableCell>
|
||||
{/* The server publishes why tonight would be refused on every read. A
|
||||
next-run time the enrolment cannot keep is worse than no time. */}
|
||||
<TableCell className="hidden tabular-nums text-muted-foreground @3xl:table-cell">
|
||||
{blocked ? (
|
||||
<span className="text-warning-text">{blocked}</span>
|
||||
) : exhausted ? (
|
||||
<span className="text-warning-text">
|
||||
{t("cookieBot.enrolled.pausedNoHours")}
|
||||
</span>
|
||||
) : (
|
||||
(formatDateTime(schedule.next_run_at) ?? "—")
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden tabular-nums text-muted-foreground @4xl:table-cell">
|
||||
{formatDateTime(schedule.last_run_at) ??
|
||||
t("cookieBot.enrolled.neverRun")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={t("cookieBot.enrolled.edit")}
|
||||
onClick={() => {
|
||||
onEdit(schedule);
|
||||
}}
|
||||
>
|
||||
<LuPencil className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("cookieBot.enrolled.edit")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-destructive-text hover:bg-destructive/10"
|
||||
aria-label={t("cookieBot.schedule.unenrol")}
|
||||
onClick={() => {
|
||||
onRemove(schedule);
|
||||
}}
|
||||
>
|
||||
<LuTrash2 className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("cookieBot.schedule.unenrol")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Derivations — all of these read run rows the server wrote. Nothing here */
|
||||
/* predicts, estimates or fills in a value the backend did not report. */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function summariseRecent(runs: CookieBotRun[]) {
|
||||
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||
let succeeded = 0;
|
||||
let partial = 0;
|
||||
let failed = 0;
|
||||
let total = 0;
|
||||
for (const run of runs) {
|
||||
const at = parseIso(run.started_at ?? run.scheduled_for);
|
||||
if (!at || at.getTime() < cutoff) continue;
|
||||
total += 1;
|
||||
if (run.status === "succeeded") succeeded += 1;
|
||||
else if (run.status === "partial" || run.status === "skipped") partial += 1;
|
||||
else if (run.status === "failed") failed += 1;
|
||||
}
|
||||
return { succeeded, partial, failed, total };
|
||||
}
|
||||
|
||||
function nightlyMinutes(runs: CookieBotRun[]) {
|
||||
const buckets = new Map<string, number>();
|
||||
const days: { key: string; label: string }[] = [];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
for (let i = CHART_NIGHTS - 1; i >= 0; i -= 1) {
|
||||
const day = new Date(today);
|
||||
day.setDate(day.getDate() - i);
|
||||
const key = dayKey(day);
|
||||
buckets.set(key, 0);
|
||||
days.push({
|
||||
key,
|
||||
label: day.toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
}),
|
||||
});
|
||||
}
|
||||
for (const run of runs) {
|
||||
const at = parseIso(run.started_at ?? run.scheduled_for);
|
||||
if (!at) continue;
|
||||
const key = dayKey(at);
|
||||
if (!buckets.has(key)) continue;
|
||||
buckets.set(key, (buckets.get(key) ?? 0) + run.billed_seconds / 60);
|
||||
}
|
||||
return days.map(({ key, label }) => ({
|
||||
label,
|
||||
minutes: Math.round(buckets.get(key) ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
function dayKey(date: Date): string {
|
||||
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuCookie, LuSearch } from "react-icons/lu";
|
||||
import {
|
||||
CookieBotActivity,
|
||||
type RunFilter,
|
||||
} from "@/components/cookie-bot-activity";
|
||||
import { CookieBotEnrolDialog } from "@/components/cookie-bot-enrol-dialog";
|
||||
import { CookieBotOverview } from "@/components/cookie-bot-overview";
|
||||
import { CookieBotScheduleTab } from "@/components/cookie-bot-schedule";
|
||||
import {
|
||||
preflight,
|
||||
preflightReason,
|
||||
RemoteHoursMeter,
|
||||
} from "@/components/cookie-bot-shared";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { TeamUsagePanel } from "@/components/team-usage-panel";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
AnimatedTabsContent,
|
||||
AnimatedTabsList,
|
||||
AnimatedTabsTrigger,
|
||||
} from "@/components/ui/animated-tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { RippleButton } from "@/components/ui/ripple";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import {
|
||||
type CookieBotRun,
|
||||
type CookieBotSchedule,
|
||||
deleteCookieBotSchedule,
|
||||
getCookieBotRuns,
|
||||
} from "@/lib/cookie-bot";
|
||||
import { canUseCookieBot, getEntitlements } from "@/lib/entitlements";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BrowserProfile, CloudUser } from "@/types";
|
||||
|
||||
export type CookieBotTab = "overview" | "schedule" | "activity" | "team";
|
||||
|
||||
/** How often the run rows are re-read while something is running. A run's site
|
||||
* counter advances without a session transition, so the stream alone would show
|
||||
* a frozen number for an hour. */
|
||||
const LIVE_POLL_MS = 15_000;
|
||||
const RUN_PAGE_SIZE = 100;
|
||||
|
||||
interface CookieBotPageProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
subPage?: boolean;
|
||||
initialTab?: CookieBotTab;
|
||||
profiles: BrowserProfile[];
|
||||
cloudUser: CloudUser | null;
|
||||
/** Opens the profile's sync settings for an end-to-end encrypted profile. */
|
||||
onOpenProfileSync: (profile: BrowserProfile) => void;
|
||||
/** Opens proxy assignment for profiles with no exit node. */
|
||||
onAssignProxy: (profileIds: string[]) => void;
|
||||
}
|
||||
|
||||
export function CookieBotPage({
|
||||
isOpen,
|
||||
onClose,
|
||||
subPage,
|
||||
initialTab = "overview",
|
||||
profiles,
|
||||
cloudUser,
|
||||
onOpenProfileSync,
|
||||
onAssignProxy,
|
||||
}: CookieBotPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const entitlements = getEntitlements(cloudUser);
|
||||
const unlocked = canUseCookieBot(cloudUser);
|
||||
const isTeam = entitlements.teamCollaboration && Boolean(cloudUser?.teamId);
|
||||
const isOwnerOrAdmin =
|
||||
cloudUser?.teamRole === "owner" || cloudUser?.teamRole === "admin";
|
||||
const showTeamTab = isTeam && cloudUser?.teamRole === "owner";
|
||||
const scope = cookieBotScopeFor(cloudUser);
|
||||
|
||||
// Enrolments, the pooled budget and the live sessions all come from the one
|
||||
// shared store the profile table reads, so an edit here moves both at once.
|
||||
//
|
||||
// Enabled is `unlocked`, NOT `isOpen && unlocked`: the store is a module
|
||||
// singleton whose enabled flag is set by whichever consumer's effect ran
|
||||
// last, so a closed page passing `false` would switch the event stream off
|
||||
// underneath the rail and the profile table.
|
||||
const {
|
||||
schedules: schedulesByProfile,
|
||||
liveSessions,
|
||||
quota,
|
||||
isLoading: isStoreLoading,
|
||||
error: storeError,
|
||||
streamConnected,
|
||||
refresh: refreshStore,
|
||||
} = useCookieBot(unlocked, scope);
|
||||
|
||||
const schedules = useMemo(
|
||||
() =>
|
||||
Object.values(schedulesByProfile).sort(
|
||||
(a, b) =>
|
||||
a.run_at_minute - b.run_at_minute ||
|
||||
a.profile_name.localeCompare(b.profile_name),
|
||||
),
|
||||
[schedulesByProfile],
|
||||
);
|
||||
const live = useMemo(() => Object.values(liveSessions), [liveSessions]);
|
||||
const liveCount = live.length;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<CookieBotTab>(initialTab);
|
||||
const [runs, setRuns] = useState<CookieBotRun[]>([]);
|
||||
const [isLoadingRuns, setIsLoadingRuns] = useState(true);
|
||||
const [runsError, setRunsError] = useState<unknown>(null);
|
||||
const [runFilter, setRunFilter] = useState<RunFilter>("all");
|
||||
const hasLoadedRuns = useRef(false);
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [enrolTargets, setEnrolTargets] = useState<BrowserProfile[]>([]);
|
||||
const [editing, setEditing] = useState<CookieBotSchedule | null>(null);
|
||||
const [enrolOpen, setEnrolOpen] = useState(false);
|
||||
const [pendingRemoval, setPendingRemoval] =
|
||||
useState<CookieBotSchedule | null>(null);
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
|
||||
const isLoading = isStoreLoading || isLoadingRuns;
|
||||
const loadError: unknown = runsError ?? storeError;
|
||||
|
||||
/**
|
||||
* Runs are the one thing the shared store does not hold: only this page and
|
||||
* the per-profile history read them, and they page. `withSpinner` is false
|
||||
* for every background pass, because swapping a correct table for a skeleton
|
||||
* every fifteen seconds is worse than a row being a few seconds stale.
|
||||
*/
|
||||
const loadRuns = useCallback(
|
||||
async (withSpinner: boolean) => {
|
||||
if (withSpinner) setIsLoadingRuns(true);
|
||||
try {
|
||||
const page = await getCookieBotRuns({ scope, limit: RUN_PAGE_SIZE });
|
||||
setRuns(page.runs);
|
||||
setRunsError(null);
|
||||
} catch (error) {
|
||||
// A background refresh that fails leaves the previous rows on screen.
|
||||
if (withSpinner) setRunsError(error);
|
||||
} finally {
|
||||
if (withSpinner) setIsLoadingRuns(false);
|
||||
}
|
||||
},
|
||||
[scope],
|
||||
);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
void refreshStore();
|
||||
void loadRuns(true);
|
||||
}, [refreshStore, loadRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(initialTab);
|
||||
}, [initialTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !unlocked) {
|
||||
hasLoadedRuns.current = false;
|
||||
return;
|
||||
}
|
||||
// The first pass shows a skeleton. Every later pass — a session appearing
|
||||
// or closing, or the poll below — swaps rows in silently. Both matter: the
|
||||
// live set changing means a run row moved, and a run's site counter
|
||||
// advances with no session transition at all.
|
||||
const first = !hasLoadedRuns.current;
|
||||
hasLoadedRuns.current = true;
|
||||
void loadRuns(first);
|
||||
|
||||
if (liveCount === 0) return;
|
||||
const id = window.setInterval(() => {
|
||||
void loadRuns(false);
|
||||
}, LIVE_POLL_MS);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [isOpen, unlocked, liveCount, loadRuns]);
|
||||
|
||||
const enrolledIds = useMemo(
|
||||
() => new Set(Object.keys(schedulesByProfile)),
|
||||
[schedulesByProfile],
|
||||
);
|
||||
|
||||
const openEnrolFor = useCallback(
|
||||
(targets: BrowserProfile[], schedule: CookieBotSchedule | null) => {
|
||||
if (targets.length === 0) return;
|
||||
setEnrolTargets(targets);
|
||||
setEditing(schedule);
|
||||
setEnrolOpen(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleEditSchedule = useCallback(
|
||||
(schedule: CookieBotSchedule) => {
|
||||
const profile = profiles.find((p) => p.id === schedule.profile_id);
|
||||
if (!profile) {
|
||||
showErrorToast(t("cookieBot.enrolled.profileMissing"));
|
||||
return;
|
||||
}
|
||||
openEnrolFor([profile], schedule);
|
||||
},
|
||||
[profiles, openEnrolFor, t],
|
||||
);
|
||||
|
||||
const confirmRemoval = useCallback(async () => {
|
||||
if (!pendingRemoval) return;
|
||||
setIsRemoving(true);
|
||||
try {
|
||||
await deleteCookieBotSchedule(pendingRemoval.profile_id);
|
||||
showSuccessToast(t("cookieBot.schedule.unenrolled"));
|
||||
setPendingRemoval(null);
|
||||
reload();
|
||||
} catch (error) {
|
||||
showErrorToast(translateBackendError(t, error));
|
||||
} finally {
|
||||
setIsRemoving(false);
|
||||
}
|
||||
}, [pendingRemoval, reload, t]);
|
||||
|
||||
const dialogBody = !unlocked ? (
|
||||
<LockedState />
|
||||
) : (
|
||||
<div className="@container flex min-h-0 w-full flex-1 flex-col">
|
||||
<AnimatedTabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => {
|
||||
setActiveTab(value as CookieBotTab);
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="overview">
|
||||
<span>{t("cookieBot.tabs.overview")}</span>
|
||||
<span className="text-xs tabular-nums">{schedules.length}</span>
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="schedule">
|
||||
{t("cookieBot.tabs.schedule")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="activity">
|
||||
<span>{t("cookieBot.tabs.activity")}</span>
|
||||
{live.length > 0 && (
|
||||
<span className="size-1.5 rounded-full bg-success" />
|
||||
)}
|
||||
</AnimatedTabsTrigger>
|
||||
{showTeamTab && (
|
||||
<AnimatedTabsTrigger value="team">
|
||||
{t("cookieBot.tabs.team")}
|
||||
</AnimatedTabsTrigger>
|
||||
)}
|
||||
</AnimatedTabsList>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<RemoteHoursMeter
|
||||
quota={quota}
|
||||
isLoading={isStoreLoading && quota === null}
|
||||
variant="compact"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("cookieBot.enrolled.enrolProfiles")}
|
||||
onClick={() => {
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("cookieBot.enrolled.enrolProfiles")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("cookieBot.enrolled.enrolProfiles")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError !== null && (
|
||||
<div className="mt-4 flex shrink-0 items-center gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||
<p className="min-w-0 flex-1 text-sm text-destructive-text">
|
||||
{translateBackendError(t, loadError)}
|
||||
</p>
|
||||
<RippleButton variant="outline" size="sm" onClick={reload}>
|
||||
{t("common.buttons.retry")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="overview"
|
||||
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
<CookieBotOverview
|
||||
schedules={schedules}
|
||||
runs={runs}
|
||||
live={live}
|
||||
quota={quota}
|
||||
profiles={profiles}
|
||||
isLoading={isLoading}
|
||||
currentUserId={cloudUser?.id ?? null}
|
||||
onEnrol={() => {
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
onEditSchedule={handleEditSchedule}
|
||||
onRemoveSchedule={setPendingRemoval}
|
||||
onJumpToActivity={(filter) => {
|
||||
setRunFilter(filter);
|
||||
setActiveTab("activity");
|
||||
}}
|
||||
/>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="schedule"
|
||||
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
<CookieBotScheduleTab
|
||||
schedules={schedules}
|
||||
isLoading={isStoreLoading}
|
||||
currentUserId={cloudUser?.id ?? null}
|
||||
canEditOthers={isOwnerOrAdmin}
|
||||
onEdit={handleEditSchedule}
|
||||
onRemove={setPendingRemoval}
|
||||
/>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="activity"
|
||||
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
<CookieBotActivity
|
||||
live={live}
|
||||
streamConnected={streamConnected}
|
||||
runs={runs}
|
||||
isLoading={isLoadingRuns}
|
||||
profiles={profiles}
|
||||
showOperator={isTeam}
|
||||
filter={runFilter}
|
||||
onFilterChange={setRunFilter}
|
||||
onRefresh={reload}
|
||||
/>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
{showTeamTab && (
|
||||
<AnimatedTabsContent
|
||||
value="team"
|
||||
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{/* One team-usage implementation, shared with the Account page,
|
||||
so the two never disagree about who spent the pool. */}
|
||||
<TeamUsagePanel quota={quota} className="min-h-0 flex-1" />
|
||||
</AnimatedTabsContent>
|
||||
)}
|
||||
</AnimatedTabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="flex max-h-[85vh] max-w-[min(80rem,calc(100%-4rem))] flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("cookieBot.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("cookieBot.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
)}
|
||||
{dialogBody}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<EnrolPickerDialog
|
||||
isOpen={pickerOpen}
|
||||
profiles={profiles}
|
||||
enrolledIds={enrolledIds}
|
||||
onClose={() => {
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
onConfirm={(selected) => {
|
||||
setPickerOpen(false);
|
||||
openEnrolFor(selected, null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CookieBotEnrolDialog
|
||||
isOpen={enrolOpen}
|
||||
onClose={() => {
|
||||
setEnrolOpen(false);
|
||||
}}
|
||||
profiles={enrolTargets}
|
||||
existing={editing}
|
||||
onSaved={reload}
|
||||
onOpenProfileSync={onOpenProfileSync}
|
||||
onAssignProxy={onAssignProxy}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={pendingRemoval !== null}
|
||||
onClose={() => {
|
||||
setPendingRemoval(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
void confirmRemoval();
|
||||
}}
|
||||
title={t("cookieBot.schedule.unenrolTitle", {
|
||||
name: pendingRemoval?.profile_name ?? "",
|
||||
})}
|
||||
description={t("cookieBot.schedule.unenrolDescription")}
|
||||
confirmButtonText={t("cookieBot.schedule.unenrol")}
|
||||
isLoading={isRemoving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LockedState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<LuCookie className="size-12 text-muted-foreground" />
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("cookieBot.locked.title")}
|
||||
</p>
|
||||
<ProBadge />
|
||||
</div>
|
||||
<p className="max-w-md text-xs text-muted-foreground">
|
||||
{t("cookieBot.locked.hint")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picking which profiles to enrol. Ineligible profiles are shown with their
|
||||
* reason rather than hidden, so the list matches what the operator sees in the
|
||||
* main table and the refusal is discovered here, not at 02:00.
|
||||
*/
|
||||
function EnrolPickerDialog({
|
||||
isOpen,
|
||||
profiles,
|
||||
enrolledIds,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
profiles: BrowserProfile[];
|
||||
enrolledIds: Set<string>;
|
||||
onClose: () => void;
|
||||
onConfirm: (selected: BrowserProfile[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setSearch("");
|
||||
setSelected(new Set());
|
||||
}, [isOpen]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
return profiles
|
||||
.filter((profile) => profile.name.toLowerCase().includes(needle))
|
||||
.map((profile) => ({
|
||||
profile,
|
||||
check: preflight(profile),
|
||||
enrolled: enrolledIds.has(profile.id),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.check.eligible !== b.check.eligible) {
|
||||
return a.check.eligible ? -1 : 1;
|
||||
}
|
||||
return a.profile.name.localeCompare(b.profile.name);
|
||||
});
|
||||
}, [profiles, search, enrolledIds]);
|
||||
|
||||
const chosen = useMemo(
|
||||
() => profiles.filter((profile) => selected.has(profile.id)),
|
||||
[profiles, selected],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="flex max-h-[70vh] max-w-lg flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("cookieBot.picker.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("cookieBot.picker.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative shrink-0">
|
||||
<LuSearch className="absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
}}
|
||||
className="h-8 pl-8 text-sm"
|
||||
placeholder={t("cookieBot.picker.searchPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FadingScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-0.5 pr-1">
|
||||
{rows.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("cookieBot.picker.noProfiles")}
|
||||
</p>
|
||||
) : (
|
||||
rows.map(({ profile, check, enrolled }) => (
|
||||
<label
|
||||
key={profile.id}
|
||||
htmlFor={`cookie-bot-pick-${profile.id}`}
|
||||
className={cn(
|
||||
"flex h-8 cursor-pointer items-center gap-2 rounded-md px-2 text-xs transition-colors duration-100 hover:bg-accent hover:text-accent-foreground",
|
||||
!check.eligible && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`cookie-bot-pick-${profile.id}`}
|
||||
checked={selected.has(profile.id)}
|
||||
disabled={!check.eligible}
|
||||
onCheckedChange={(value) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (value === true) next.add(profile.id);
|
||||
else next.delete(profile.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{profile.name}
|
||||
</span>
|
||||
{enrolled && (
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("cookieBot.picker.alreadyEnrolled")}
|
||||
</span>
|
||||
)}
|
||||
{!check.eligible && (
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{preflightReason(t, check)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</FadingScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
disabled={chosen.length === 0}
|
||||
onClick={() => {
|
||||
onConfirm(chosen);
|
||||
}}
|
||||
>
|
||||
{t("cookieBot.picker.continue", { count: chosen.length })}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
formatDateTime,
|
||||
formatDuration,
|
||||
hasRunCounters,
|
||||
outcomeLabel,
|
||||
runStatusLabel,
|
||||
runStatusTone,
|
||||
StatusDot,
|
||||
} from "@/components/cookie-bot-shared";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import {
|
||||
type CookieBotRun,
|
||||
cancelCookieBotRun,
|
||||
getCookieBotRuns,
|
||||
} from "@/lib/cookie-bot";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
|
||||
const RUN_PAGE_SIZE = 25;
|
||||
|
||||
/** Statuses that are still moving, so the row can offer a stop. */
|
||||
const IN_FLIGHT = new Set(["pending", "running"]);
|
||||
|
||||
function runDurationSeconds(run: CookieBotRun): number | null {
|
||||
if (run.billed_seconds > 0) return run.billed_seconds;
|
||||
const started = run.started_at ? new Date(run.started_at).getTime() : NaN;
|
||||
const ended = run.ended_at ? new Date(run.ended_at).getTime() : NaN;
|
||||
if (!Number.isNaN(started) && !Number.isNaN(ended) && ended > started) {
|
||||
return (ended - started) / 1000;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface CookieBotRunsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profileId: string | null;
|
||||
profileName?: string;
|
||||
/** Called after a run is cancelled, so shared state can be re-read. */
|
||||
onRunCancelled?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the bot actually did for one profile, reached from that profile's row.
|
||||
*
|
||||
* The Cookie Bot page owns the fleet-wide activity view; this is the same data
|
||||
* narrowed to a single profile, which is the question an operator asks while
|
||||
* looking at the table. Every number is the server's — the desktop keeps no run
|
||||
* history of its own — and the status vocabulary is the shared one, so a status
|
||||
* cannot read differently in two places.
|
||||
*/
|
||||
export function CookieBotRunsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profileId,
|
||||
profileName,
|
||||
onRunCancelled,
|
||||
}: CookieBotRunsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [runs, setRuns] = React.useState<CookieBotRun[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [cancellingId, setCancellingId] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!profileId) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const page = await getCookieBotRuns({ profileId, limit: RUN_PAGE_SIZE });
|
||||
setRuns(page.runs);
|
||||
} catch (err) {
|
||||
setError(translateBackendError(t as never, err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [profileId, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setRuns([]);
|
||||
void load();
|
||||
}, [isOpen, load]);
|
||||
|
||||
const handleCancel = React.useCallback(
|
||||
async (run: CookieBotRun) => {
|
||||
setCancellingId(run.id);
|
||||
try {
|
||||
await cancelCookieBotRun(run.id);
|
||||
showSuccessToast(t("cookieBot.running.stopped"));
|
||||
onRunCancelled?.();
|
||||
await load();
|
||||
} catch (err) {
|
||||
showErrorToast(translateBackendError(t as never, err));
|
||||
} finally {
|
||||
setCancellingId(null);
|
||||
}
|
||||
},
|
||||
[load, onRunCancelled, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("cookieBot.history.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{profileName ?? t("cookieBot.history.allProfiles")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<p className="py-8 text-center text-sm text-destructive-text">
|
||||
{error}
|
||||
</p>
|
||||
) : isLoading && runs.length === 0 ? (
|
||||
<div className="space-y-2 py-2">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Skeleton
|
||||
key={`run-skeleton-${index}`}
|
||||
className="h-7 w-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("cookieBot.history.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableRow>
|
||||
<TableHead>{t("cookieBot.history.columnStarted")}</TableHead>
|
||||
<TableHead>{t("cookieBot.history.columnDuration")}</TableHead>
|
||||
<TableHead>{t("cookieBot.history.columnSites")}</TableHead>
|
||||
<TableHead>{t("cookieBot.history.columnStatus")}</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.map((run) => {
|
||||
const duration = runDurationSeconds(run);
|
||||
const started =
|
||||
formatDateTime(run.started_at ?? run.scheduled_for) ?? "—";
|
||||
return (
|
||||
<TableRow key={run.id}>
|
||||
<TableCell className="text-xs tabular-nums whitespace-nowrap">
|
||||
{started}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs tabular-nums">
|
||||
{duration === null ? "—" : formatDuration(t, duration)}
|
||||
</TableCell>
|
||||
{/* Never a confident `0/12`: nothing writes these
|
||||
counters yet, so the column default is not a fact
|
||||
about what the bot did. */}
|
||||
<TableCell className="text-xs tabular-nums">
|
||||
{hasRunCounters(run)
|
||||
? t("cookieBot.history.sitesVisited", {
|
||||
visited: run.sites_visited,
|
||||
total: run.sites_total,
|
||||
})
|
||||
: t("cookieBot.history.sitesUnknown")}
|
||||
{run.sites_failed > 0 && (
|
||||
<span className="ml-1 text-warning-text">
|
||||
{t("cookieBot.history.sitesFailed", {
|
||||
count: run.sites_failed,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StatusDot
|
||||
tone={runStatusTone(run.status)}
|
||||
pulse={run.status === "running"}
|
||||
className="size-1.5"
|
||||
/>
|
||||
{runStatusLabel(t, run.status)}
|
||||
</span>
|
||||
{run.outcome_code && (
|
||||
<span className="mt-0.5 block text-[11px] text-muted-foreground">
|
||||
{outcomeLabel(t, run.outcome_code)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{IN_FLIGHT.has(run.status) && (
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isLoading={cancellingId === run.id}
|
||||
onClick={() => {
|
||||
void handleCancel(run);
|
||||
}}
|
||||
className="h-6 text-[11px]"
|
||||
>
|
||||
{t("cookieBot.running.stop")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
||||
import {
|
||||
describeCadence,
|
||||
minutesToClock,
|
||||
StatusDot,
|
||||
scheduleBlockedReason,
|
||||
scheduleTone,
|
||||
} from "@/components/cookie-bot-shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { CookieBotSchedule } from "@/lib/cookie-bot";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** A slot holding this many enrolments is worth flagging: the fleet leases a
|
||||
* handful of machines per platform, so a pile-up at one minute is a real
|
||||
* capacity fact, not a decoration. */
|
||||
const CROWDED_SLOT = 4;
|
||||
|
||||
interface Slot {
|
||||
hour: number;
|
||||
entries: CookieBotSchedule[];
|
||||
}
|
||||
|
||||
interface CookieBotScheduleTabProps {
|
||||
schedules: CookieBotSchedule[];
|
||||
isLoading: boolean;
|
||||
currentUserId: string | null;
|
||||
canEditOthers: boolean;
|
||||
onEdit: (schedule: CookieBotSchedule) => void;
|
||||
onRemove: (schedule: CookieBotSchedule) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The night, drawn as a night. Every enrolment the caller can see sits under
|
||||
* the hour it starts, so two operators aiming at the same profile — or twelve
|
||||
* profiles aiming at 02:00 — is visible before it becomes a 409 at 02:00.
|
||||
*/
|
||||
export function CookieBotScheduleTab({
|
||||
schedules,
|
||||
isLoading,
|
||||
currentUserId,
|
||||
canEditOthers,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: CookieBotScheduleTabProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const rows = useMemo(() => buildRows(schedules), [schedules]);
|
||||
|
||||
if (isLoading && schedules.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 pt-2">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div key={`slot-skeleton-${i}`} className="flex items-center gap-3">
|
||||
<Skeleton className="h-3 w-10" />
|
||||
<Skeleton
|
||||
className="h-3"
|
||||
style={{ width: `${30 + ((i * 17) % 40)}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schedules.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center py-16">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("cookieBot.schedule.empty")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FadingScrollArea
|
||||
className="min-h-0 flex-1"
|
||||
style={{ "--scroll-fade-top-offset": "16px" } as React.CSSProperties}
|
||||
>
|
||||
<div className="flex flex-col pr-1">
|
||||
{rows.map((row) =>
|
||||
row.kind === "gap" ? (
|
||||
<div
|
||||
key={`gap-${row.from}`}
|
||||
className="flex h-6 items-center gap-2 pl-14 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
<span>
|
||||
{t("cookieBot.schedule.quietHours", { count: row.count })}
|
||||
</span>
|
||||
<span className="h-px flex-1 rounded-full bg-border" />
|
||||
</div>
|
||||
) : (
|
||||
<div key={`slot-${row.slot.hour}`} className="flex gap-3 pb-4">
|
||||
<span className="w-14 shrink-0 pt-1 text-right text-xs tabular-nums text-muted-foreground">
|
||||
{minutesToClock(row.slot.hour * 60)}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 border-l border-border pl-3">
|
||||
{row.slot.entries.map((schedule) => {
|
||||
const mine =
|
||||
!schedule.owner_user_id ||
|
||||
schedule.owner_user_id === currentUserId;
|
||||
const editable = mine || canEditOthers;
|
||||
const blocked = scheduleBlockedReason(t, schedule);
|
||||
return (
|
||||
<div
|
||||
key={`${schedule.owner_user_id ?? "me"}-${schedule.profile_id}`}
|
||||
className="group flex h-7 items-center gap-2 rounded-md px-2 text-xs transition-colors duration-100 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<StatusDot
|
||||
tone={scheduleTone(schedule)}
|
||||
className="size-1.5"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{schedule.profile_name}
|
||||
</span>
|
||||
{/* "This cannot run" beats "it runs nightly": an
|
||||
enrolment the server will refuse should not read as a
|
||||
cadence it is about to keep. */}
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-[10px] uppercase tracking-wide",
|
||||
blocked
|
||||
? "text-warning-text"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{blocked ?? describeCadence(t, schedule.days_mask)}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{minutesToClock(schedule.run_at_minute)}
|
||||
</span>
|
||||
{!mine && schedule.owner_email && (
|
||||
<span className="hidden max-w-40 shrink-0 truncate text-[10px] uppercase tracking-wide text-muted-foreground @2xl:inline">
|
||||
{schedule.owner_email}
|
||||
</span>
|
||||
)}
|
||||
<SlotAction
|
||||
label={t("cookieBot.enrolled.edit")}
|
||||
forbidden={!editable}
|
||||
onClick={() => {
|
||||
onEdit(schedule);
|
||||
}}
|
||||
>
|
||||
<LuPencil className="size-3.5" />
|
||||
</SlotAction>
|
||||
<SlotAction
|
||||
label={t("cookieBot.schedule.unenrol")}
|
||||
forbidden={!editable}
|
||||
destructive
|
||||
onClick={() => {
|
||||
onRemove(schedule);
|
||||
}}
|
||||
>
|
||||
<LuTrash2 className="size-3.5" />
|
||||
</SlotAction>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{row.slot.entries.length >= CROWDED_SLOT && (
|
||||
<span className="px-2 pt-1 text-[11px] text-warning-text">
|
||||
{t("cookieBot.schedule.crowded", {
|
||||
count: row.slot.entries.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</FadingScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotAction({
|
||||
label,
|
||||
forbidden,
|
||||
destructive,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
forbidden: boolean;
|
||||
destructive?: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-7",
|
||||
destructive && "text-destructive-text hover:bg-destructive/10",
|
||||
)}
|
||||
aria-label={label}
|
||||
disabled={forbidden}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{forbidden ? t("cookieBot.conflict.replaceForbidden") : label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
type Row =
|
||||
| { kind: "slot"; slot: Slot }
|
||||
| { kind: "gap"; from: number; count: number };
|
||||
|
||||
/**
|
||||
* Groups enrolments into the hour they start and collapses the empty stretches
|
||||
* between them. A 24-row skeleton of empty hours would be a grid pretending to
|
||||
* be information.
|
||||
*/
|
||||
function buildRows(schedules: CookieBotSchedule[]): Row[] {
|
||||
const byHour = new Map<number, CookieBotSchedule[]>();
|
||||
for (const schedule of schedules) {
|
||||
const hour = Math.floor(schedule.run_at_minute / 60) % 24;
|
||||
const bucket = byHour.get(hour);
|
||||
if (bucket) bucket.push(schedule);
|
||||
else byHour.set(hour, [schedule]);
|
||||
}
|
||||
|
||||
const hours = [...byHour.keys()].sort((a, b) => a - b);
|
||||
const rows: Row[] = [];
|
||||
let previous: number | null = null;
|
||||
for (const hour of hours) {
|
||||
if (previous !== null && hour - previous > 1) {
|
||||
rows.push({
|
||||
kind: "gap",
|
||||
from: previous + 1,
|
||||
count: hour - previous - 1,
|
||||
});
|
||||
}
|
||||
const entries = (byHour.get(hour) ?? []).sort(
|
||||
(a, b) =>
|
||||
a.run_at_minute - b.run_at_minute ||
|
||||
a.profile_name.localeCompare(b.profile_name),
|
||||
);
|
||||
rows.push({ kind: "slot", slot: { hour, entries } });
|
||||
previous = hour;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { TFunction } from "i18next";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type {
|
||||
CookieBotRun,
|
||||
CookieBotSchedule,
|
||||
RemoteHoursQuota,
|
||||
} from "@/lib/cookie-bot";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import type { RemoteSessionState } from "@/lib/remote-sessions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BrowserProfile, WayfernFingerprintConfig } from "@/types";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Cadence */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Weekday bitmask, bit 0 = Monday. The masks below are the only three the
|
||||
* enrolment dialog offers; anything else that comes back from the server is
|
||||
* rendered as its own weekday list rather than forced into one of these.
|
||||
*/
|
||||
export const DAYS_NIGHTLY = 127;
|
||||
export const DAYS_WEEKNIGHTS = 31;
|
||||
export const DAYS_ALTERNATE = 85; // Mon / Wed / Fri / Sun
|
||||
|
||||
export type CadenceId = "nightly" | "weeknights" | "alternate";
|
||||
|
||||
export const CADENCES: { id: CadenceId; mask: number; labelKey: string }[] = [
|
||||
{
|
||||
id: "nightly",
|
||||
mask: DAYS_NIGHTLY,
|
||||
labelKey: "cookieBot.enrol.cadenceNightly",
|
||||
},
|
||||
{
|
||||
id: "weeknights",
|
||||
mask: DAYS_WEEKNIGHTS,
|
||||
labelKey: "cookieBot.enrol.cadenceWeeknights",
|
||||
},
|
||||
{
|
||||
id: "alternate",
|
||||
mask: DAYS_ALTERNATE,
|
||||
labelKey: "cookieBot.enrol.cadenceAlternate",
|
||||
},
|
||||
];
|
||||
|
||||
export function cadenceForMask(mask: number): CadenceId | null {
|
||||
return CADENCES.find((c) => c.mask === mask)?.id ?? null;
|
||||
}
|
||||
|
||||
export function nightsPerWeek(mask: number): number {
|
||||
let count = 0;
|
||||
for (let bit = 0; bit < 7; bit += 1) {
|
||||
if ((mask & (1 << bit)) !== 0) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** A human cadence label. Unknown masks fall back to the night count. */
|
||||
export function describeCadence(t: TFunction, mask: number): string {
|
||||
const id = cadenceForMask(mask);
|
||||
if (id) {
|
||||
return t(CADENCES.find((c) => c.id === id)?.labelKey ?? "");
|
||||
}
|
||||
return t("cookieBot.enrol.cadenceCustom", { count: nightsPerWeek(mask) });
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Time formatting */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** `137` -> `02:17`. Always zero-padded so the column stays on one grid. */
|
||||
export function minutesToClock(minutes: number): string {
|
||||
const safe = ((Math.round(minutes) % 1440) + 1440) % 1440;
|
||||
const h = Math.floor(safe / 60);
|
||||
const m = safe % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** `02:17` -> `137`. Returns null for anything that isn't a real time. */
|
||||
export function clockToMinutes(value: string): number | null {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
const h = Number(match[1]);
|
||||
const m = Number(match[2]);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
|
||||
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
/** `724` -> `12:04`. Used for the live elapsed clock; never rounds up. */
|
||||
export function formatElapsed(seconds: number): string {
|
||||
const total = Math.max(0, Math.floor(seconds));
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
if (h > 0) {
|
||||
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** `724` -> `12m 04s`, for a finished run's duration column. */
|
||||
export function formatDuration(t: TFunction, seconds: number): string {
|
||||
const total = Math.max(0, Math.floor(seconds));
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
if (h > 0) return t("cookieBot.duration.hm", { hours: h, minutes: m });
|
||||
if (m > 0) {
|
||||
return t("cookieBot.duration.ms", {
|
||||
minutes: m,
|
||||
seconds: String(s).padStart(2, "0"),
|
||||
});
|
||||
}
|
||||
return t("cookieBot.duration.s", { seconds: s });
|
||||
}
|
||||
|
||||
/** Parses a server ISO timestamp. Returns null rather than an Invalid Date. */
|
||||
export function parseIso(value: string | null | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
export function formatDateTime(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
const date = parseIso(value);
|
||||
if (!date) return null;
|
||||
return date.toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDate(value: string | null | undefined): string | null {
|
||||
const date = parseIso(value);
|
||||
if (!date) return null;
|
||||
return date.toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Preflight */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Hosts the fleet can lease. Mirrors `BOT_PLATFORMS` in
|
||||
* `src-tauri/src/cookie_bot.rs`; a profile built for anything else has no
|
||||
* machine to run on and is refused before a schedule row is ever written.
|
||||
*/
|
||||
const BOT_PLATFORMS = ["windows", "macos"];
|
||||
|
||||
export type PreflightCode =
|
||||
| "syncOff"
|
||||
| "encrypted"
|
||||
| "unknownPlatform"
|
||||
| "unsupportedPlatform"
|
||||
| "noExitNode";
|
||||
|
||||
/** The one-click repairs a failed preflight can name. */
|
||||
export type PreflightFix = "sync" | "syncSettings" | "proxy";
|
||||
|
||||
export interface PreflightResult {
|
||||
eligible: boolean;
|
||||
code: PreflightCode | null;
|
||||
/** Substituted into the reason line, e.g. the refused OS name. */
|
||||
params: Record<string, string>;
|
||||
/** Which one-click repair applies, when one does. */
|
||||
fix: PreflightFix | null;
|
||||
}
|
||||
|
||||
const ELIGIBLE: PreflightResult = {
|
||||
eligible: true,
|
||||
code: null,
|
||||
params: {},
|
||||
fix: null,
|
||||
};
|
||||
|
||||
/** The OS a profile claims, from its own record then its fingerprint. */
|
||||
export function resolvedOs(profile: BrowserProfile): string | null {
|
||||
return profile.host_os ?? profile.wayfern_config?.os ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact refusals `cookie_bot::bot_precondition` applies, evaluated here so
|
||||
* a user finds out at enrolment rather than at 02:00. Keeping the two in step
|
||||
* matters: a profile this says is fine but the backend refuses would burn a
|
||||
* schedule row and a night.
|
||||
*/
|
||||
export function preflight(profile: BrowserProfile): PreflightResult {
|
||||
const syncMode = profile.sync_mode ?? "Disabled";
|
||||
if (syncMode === "Disabled") {
|
||||
return { eligible: false, code: "syncOff", params: {}, fix: "sync" };
|
||||
}
|
||||
if (syncMode === "Encrypted") {
|
||||
return {
|
||||
eligible: false,
|
||||
code: "encrypted",
|
||||
params: {},
|
||||
fix: "syncSettings",
|
||||
};
|
||||
}
|
||||
const os = resolvedOs(profile);
|
||||
if (!os) {
|
||||
return {
|
||||
eligible: false,
|
||||
code: "unknownPlatform",
|
||||
params: {},
|
||||
fix: null,
|
||||
};
|
||||
}
|
||||
if (!BOT_PLATFORMS.includes(os)) {
|
||||
return {
|
||||
eligible: false,
|
||||
code: "unsupportedPlatform",
|
||||
params: { os },
|
||||
fix: null,
|
||||
};
|
||||
}
|
||||
if (!profile.proxy_id && !profile.vpn_id) {
|
||||
return { eligible: false, code: "noExitNode", params: {}, fix: "proxy" };
|
||||
}
|
||||
return ELIGIBLE;
|
||||
}
|
||||
|
||||
export function preflightReason(t: TFunction, result: PreflightResult): string {
|
||||
switch (result.code) {
|
||||
case "syncOff":
|
||||
return t("cookieBot.preflight.reasonSync");
|
||||
case "encrypted":
|
||||
return t("cookieBot.preflight.reasonEncrypted");
|
||||
case "unknownPlatform":
|
||||
return t("cookieBot.preflight.reasonNoFingerprint");
|
||||
case "unsupportedPlatform":
|
||||
return t("cookieBot.preflight.reasonCrossOs", {
|
||||
os: result.params.os ?? "",
|
||||
});
|
||||
case "noExitNode":
|
||||
return t("cookieBot.preflight.reasonNoExitNode");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function preflightFixLabel(
|
||||
t: TFunction,
|
||||
fix: PreflightResult["fix"],
|
||||
): string | null {
|
||||
switch (fix) {
|
||||
case "sync":
|
||||
return t("cookieBot.preflight.fixSync");
|
||||
case "syncSettings":
|
||||
return t("cookieBot.preflight.fixEncrypted");
|
||||
case "proxy":
|
||||
return t("cookieBot.preflight.fixProxy");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Turning sync on is the one repair the dialog can perform by itself. */
|
||||
export function enableProfileSync(profileId: string): Promise<void> {
|
||||
return invoke<void>("set_profile_sync_mode", {
|
||||
profileId,
|
||||
syncMode: "Regular",
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Timezone */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The timezone the profile pretends to live in. The run is anchored to it so
|
||||
* a "02:00" enrolment means 02:00 where the identity claims to be, not where
|
||||
* the operator happens to be sitting. Falls back to this machine's zone.
|
||||
*/
|
||||
export function profileTimezone(profile: BrowserProfile): string {
|
||||
const raw = profile.wayfern_config?.fingerprint;
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as WayfernFingerprintConfig;
|
||||
if (typeof parsed.timezone === "string" && parsed.timezone.length > 0) {
|
||||
return parsed.timezone;
|
||||
}
|
||||
} catch {
|
||||
// A fingerprint we cannot parse is not an error here — the local zone is
|
||||
// a correct, if less specific, anchor.
|
||||
}
|
||||
}
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Run + session status */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export type StatusTone =
|
||||
| "success"
|
||||
| "warning"
|
||||
| "destructive"
|
||||
| "muted"
|
||||
| "live";
|
||||
|
||||
export function runStatusTone(status: string): StatusTone {
|
||||
switch (status) {
|
||||
case "succeeded":
|
||||
return "success";
|
||||
case "running":
|
||||
case "pending":
|
||||
return "live";
|
||||
case "partial":
|
||||
case "skipped":
|
||||
return "warning";
|
||||
case "failed":
|
||||
return "destructive";
|
||||
default:
|
||||
return "muted";
|
||||
}
|
||||
}
|
||||
|
||||
export function runStatusLabel(t: TFunction, status: string): string {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return t("cookieBot.runStatus.pending");
|
||||
case "running":
|
||||
return t("cookieBot.runStatus.running");
|
||||
case "succeeded":
|
||||
return t("cookieBot.runStatus.succeeded");
|
||||
case "partial":
|
||||
return t("cookieBot.runStatus.partial");
|
||||
case "failed":
|
||||
return t("cookieBot.runStatus.failed");
|
||||
case "skipped":
|
||||
return t("cookieBot.runStatus.skipped");
|
||||
case "cancelled":
|
||||
return t("cookieBot.runStatus.cancelled");
|
||||
default:
|
||||
// The status vocabulary belongs to the server. One it adds after this
|
||||
// build renders as itself rather than as a blank cell.
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every value of the server's `CookieBotOutcomeCode`, mapped to a translated
|
||||
* sentence.
|
||||
*
|
||||
* The code exists so a refusal is something a user can SEE in their history.
|
||||
* Printed raw it was a snake_case English token in ten locales: a Russian
|
||||
* operator asking why last night did nothing read "Причина: no_capacity".
|
||||
* A value newer than this build still falls through to its own name, which
|
||||
* beats a blank cell, but every code the server defines today has a sentence.
|
||||
*/
|
||||
const OUTCOME_KEYS: Record<string, string> = {
|
||||
not_entitled: "cookieBot.outcome.notEntitled",
|
||||
sync_disabled: "cookieBot.outcome.syncDisabled",
|
||||
encrypted_sync: "cookieBot.outcome.encryptedSync",
|
||||
proxy_required: "cookieBot.outcome.proxyRequired",
|
||||
touch_fingerprint: "cookieBot.outcome.touchFingerprint",
|
||||
platform_unsupported: "cookieBot.outcome.platformUnsupported",
|
||||
no_sites: "cookieBot.outcome.noSites",
|
||||
quota_exhausted: "cookieBot.outcome.quotaExhausted",
|
||||
profile_locked: "cookieBot.outcome.profileLocked",
|
||||
no_capacity: "cookieBot.outcome.noCapacity",
|
||||
manager_error: "cookieBot.outcome.managerError",
|
||||
budget_exceeded: "cookieBot.outcome.budgetExceeded",
|
||||
cancelled_by_user: "cookieBot.outcome.cancelledByUser",
|
||||
};
|
||||
|
||||
export function outcomeLabel(
|
||||
t: TFunction,
|
||||
code: string | null | undefined,
|
||||
): string | null {
|
||||
if (!code) return null;
|
||||
const key = OUTCOME_KEYS[code];
|
||||
return key ? t(key) : t("cookieBot.outcome.unknown", { code });
|
||||
}
|
||||
|
||||
/**
|
||||
* The session state machine, named honestly. `provisioning -> ready -> live ->
|
||||
* closed`, with `error` reachable from any of the first three, is what the
|
||||
* backend actually reports; nothing here infers a phase the backend has not
|
||||
* sent.
|
||||
*/
|
||||
export function sessionPhaseLabel(
|
||||
t: TFunction,
|
||||
session: RemoteSessionState,
|
||||
): string {
|
||||
switch (session.state) {
|
||||
case "provisioning":
|
||||
return t("cookieBot.status.provisioning");
|
||||
case "ready":
|
||||
return t("cookieBot.status.ready");
|
||||
case "live":
|
||||
return t("cookieBot.status.warming");
|
||||
case "closed":
|
||||
return t("cookieBot.status.finished");
|
||||
case "error":
|
||||
return t("cookieBot.status.failed");
|
||||
default:
|
||||
return session.state;
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionTone(session: RemoteSessionState): StatusTone {
|
||||
switch (session.state) {
|
||||
case "provisioning":
|
||||
return "warning";
|
||||
case "ready":
|
||||
return "live";
|
||||
case "live":
|
||||
return "success";
|
||||
case "closed":
|
||||
return "muted";
|
||||
// A session the fleet failed is not a session that quietly finished. It
|
||||
// read as an untranslated `error` beside the same grey dot as an idle one,
|
||||
// in the exact place a user checks whether last night worked.
|
||||
case "error":
|
||||
return "destructive";
|
||||
default:
|
||||
return "muted";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a session ended, when the backend named a reason.
|
||||
*
|
||||
* `close_reason` has been on the wire since the stream existed and nothing read
|
||||
* it, so a session that hit the two-hour cap and one the user stopped looked
|
||||
* identical.
|
||||
*/
|
||||
export function sessionCloseReason(
|
||||
t: TFunction,
|
||||
session: RemoteSessionState,
|
||||
): string | null {
|
||||
switch (session.close_reason) {
|
||||
case null:
|
||||
case undefined:
|
||||
case "":
|
||||
return null;
|
||||
case "stopped_by_user":
|
||||
return t("cookieBot.closeReason.stoppedByUser");
|
||||
case "max_duration":
|
||||
return t("cookieBot.closeReason.maxDuration");
|
||||
default:
|
||||
// The vocabulary is the fleet's and it grows. An unknown reason still
|
||||
// beats no reason, but it is labelled so it does not read as a sentence.
|
||||
return t("cookieBot.closeReason.other", { reason: session.close_reason });
|
||||
}
|
||||
}
|
||||
|
||||
const TONE_DOT: Record<StatusTone, string> = {
|
||||
success: "bg-success",
|
||||
warning: "bg-warning",
|
||||
destructive: "bg-destructive",
|
||||
muted: "bg-muted-foreground",
|
||||
live: "bg-warning",
|
||||
};
|
||||
|
||||
/**
|
||||
* The app's one status vocabulary: a bare dot, no chip and no background.
|
||||
* `pulse` is reserved for "a transfer is in progress", exactly as the sync dots
|
||||
* use it, so a pulsing dot always means the same thing.
|
||||
*/
|
||||
export function StatusDot({
|
||||
tone,
|
||||
pulse,
|
||||
className,
|
||||
}: {
|
||||
tone: StatusTone;
|
||||
pulse?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"inline-block size-2 shrink-0 rounded-full",
|
||||
TONE_DOT[tone],
|
||||
pulse && "animate-pulse",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Numbers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A cookie delta. A gain reads as a gain, a loss reads with a real minus sign
|
||||
* (U+2212, not a hyphen), and "nothing happened" reads as an em dash instead of
|
||||
* a confident zero.
|
||||
*/
|
||||
export function CookieDelta({
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
value: number | null | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
if (value === null || value === undefined) {
|
||||
return (
|
||||
<span className={cn("text-muted-foreground", className)} aria-hidden>
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (value === 0) {
|
||||
return (
|
||||
<span className={cn("text-muted-foreground", className)} aria-hidden>
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const positive = value > 0;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
positive ? "text-chart-1" : "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{positive ? `+${value}` : `−${Math.abs(value)}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One decimal, but only when it earns one: `4.7 h`, `128 h`. */
|
||||
export function formatHours(hours: number): string {
|
||||
if (!Number.isFinite(hours)) return "0";
|
||||
if (hours >= 100 || Number.isInteger(hours)) return String(Math.round(hours));
|
||||
return hours.toFixed(1);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Remote hours */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function meterFill(usedRatio: number): string {
|
||||
if (usedRatio >= 1) return "bg-destructive";
|
||||
if (usedRatio >= 0.9) return "bg-warning";
|
||||
return "bg-foreground";
|
||||
}
|
||||
|
||||
/**
|
||||
* The hours meter. The track renders at full opacity on the first paint with
|
||||
* the label already in place; only the numerals wait for the server. The fill
|
||||
* is a scaleX transform with `initial={false}` so the first frame is the true
|
||||
* value and never grows in — and the radius lives on the track, so the fill's
|
||||
* caps cannot flip shape halfway through a change.
|
||||
*/
|
||||
export function RemoteHoursMeter({
|
||||
quota,
|
||||
isLoading,
|
||||
variant = "compact",
|
||||
className,
|
||||
}: {
|
||||
quota: RemoteHoursQuota | null;
|
||||
isLoading: boolean;
|
||||
variant?: "compact" | "full" | "inline";
|
||||
className?: string;
|
||||
}) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const granted = quota?.granted_hours ?? 0;
|
||||
const used = quota?.used_hours ?? 0;
|
||||
const remaining = quota?.remaining_hours ?? 0;
|
||||
const ratio = granted > 0 ? Math.min(1, Math.max(0, used / granted)) : 0;
|
||||
const resets = formatDate(quota?.period_end);
|
||||
|
||||
const bar = (
|
||||
<div
|
||||
className={cn(
|
||||
"h-1 overflow-hidden rounded-full bg-muted",
|
||||
variant === "compact" ? "w-32" : "w-full",
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ scaleX: ratio }}
|
||||
transition={
|
||||
reduceMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.22, ease: MOTION_EASE_OUT }
|
||||
}
|
||||
style={{ transformOrigin: "left", willChange: "transform" }}
|
||||
className={cn("h-full w-full", meterFill(ratio))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === "inline") {
|
||||
return <div className={cn("w-full", className)}>{bar}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-end gap-1", className)}>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-3 w-24" />
|
||||
) : (
|
||||
<RemoteHoursReadout
|
||||
remaining={remaining}
|
||||
granted={granted}
|
||||
resets={resets}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{bar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoteHoursReadout({
|
||||
remaining,
|
||||
granted,
|
||||
resets,
|
||||
}: {
|
||||
remaining: number;
|
||||
granted: number;
|
||||
resets: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const text = t("cookieBot.hours.remaining", {
|
||||
remaining: formatHours(remaining),
|
||||
total: formatHours(granted),
|
||||
});
|
||||
if (!resets) {
|
||||
return (
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{text}</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default text-xs tabular-nums text-muted-foreground">
|
||||
{text}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("cookieBot.hours.resets", { date: resets })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Live sessions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A ticking wall clock, only while something is actually running. Returns
|
||||
* `Date.now()` once a second; consumers render it into `tabular-nums` so a
|
||||
* changing digit never reflows the row.
|
||||
*/
|
||||
export function useSecondTicker(active: boolean): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setNow(Date.now());
|
||||
const id = window.setInterval(() => {
|
||||
setNow(Date.now());
|
||||
}, 1000);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [active]);
|
||||
return now;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Joins */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function indexProfiles(
|
||||
profiles: BrowserProfile[],
|
||||
): Map<string, BrowserProfile> {
|
||||
return new Map(profiles.map((p) => [p.id, p]));
|
||||
}
|
||||
|
||||
export function indexRunsBySession(
|
||||
runs: CookieBotRun[],
|
||||
): Map<string, CookieBotRun> {
|
||||
const map = new Map<string, CookieBotRun>();
|
||||
for (const run of runs) {
|
||||
if (run.session_id) map.set(run.session_id, run);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function indexRunsById(runs: CookieBotRun[]): Map<string, CookieBotRun> {
|
||||
return new Map(runs.map((run) => [run.id, run]));
|
||||
}
|
||||
|
||||
/**
|
||||
* The name to print for a session. The local profile record wins because it is
|
||||
* what the operator renamed; the run row is the fallback for a teammate's
|
||||
* profile this machine has never held.
|
||||
*/
|
||||
export function sessionDisplayName(
|
||||
session: RemoteSessionState,
|
||||
profiles: Map<string, BrowserProfile>,
|
||||
run: CookieBotRun | undefined,
|
||||
): string | null {
|
||||
if (session.profile_id) {
|
||||
const profile = profiles.get(session.profile_id);
|
||||
if (profile) return profile.name;
|
||||
}
|
||||
return run?.profile_name ?? null;
|
||||
}
|
||||
|
||||
export function scheduleSortKey(schedule: CookieBotSchedule): number {
|
||||
return schedule.run_at_minute;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dot a stored enrolment gets.
|
||||
*
|
||||
* `blocked_by` outranks `enabled`: an armed schedule the server will refuse is
|
||||
* not a healthy one, and showing it green with a next-run time is how a
|
||||
* detached proxy stayed invisible until the run was skipped at 02:00.
|
||||
*/
|
||||
export function scheduleTone(schedule: CookieBotSchedule): StatusTone {
|
||||
if (!schedule.enabled) return "muted";
|
||||
return schedule.blocked_by ? "warning" : "success";
|
||||
}
|
||||
|
||||
/** Why this enrolment cannot run tonight, translated, or null. */
|
||||
export function scheduleBlockedReason(
|
||||
t: TFunction,
|
||||
schedule: CookieBotSchedule,
|
||||
): string | null {
|
||||
if (!schedule.enabled) return null;
|
||||
return outcomeLabel(t, schedule.blocked_by);
|
||||
}
|
||||
|
||||
/** Seconds a session has been alive, or null when the backend has not said. */
|
||||
export function sessionElapsedSeconds(
|
||||
session: RemoteSessionState,
|
||||
now: number,
|
||||
): number | null {
|
||||
const started = parseIso(session.started_at);
|
||||
if (!started) return null;
|
||||
const end = parseIso(session.ended_at)?.getTime() ?? now;
|
||||
return Math.max(0, Math.floor((end - started.getTime()) / 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a run's per-site counters mean anything yet.
|
||||
*
|
||||
* `sites_visited`, `sites_failed` and `consent_dismissed` are columns the
|
||||
* server declares with `DEFAULT 0` and, today, nothing ever writes: the fleet
|
||||
* computes them but donutbrowser-infra does not ingest them. Rendering the
|
||||
* default as a fact told a paying user their run visited "0 of 12 sites" and
|
||||
* drew a success-green progress bar pinned at zero for the whole night.
|
||||
*
|
||||
* So a run is only credited with counters once one of them is non-zero. Until
|
||||
* then the UI says it does not know, which is the truth. The moment the
|
||||
* ingestion lands this starts reporting real numbers with no further change.
|
||||
*/
|
||||
export function hasRunCounters(run: {
|
||||
sites_visited: number;
|
||||
sites_failed: number;
|
||||
consent_dismissed: number;
|
||||
}): boolean {
|
||||
return (
|
||||
run.sites_visited > 0 || run.sites_failed > 0 || run.consent_dismissed > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs whose schedule fires on the next occurrence of their local start time.
|
||||
* Purely a read of the server's own `next_run_at` — nothing here computes a
|
||||
* schedule, it only sorts what the server already decided.
|
||||
*/
|
||||
export function sortByNextRun(
|
||||
schedules: CookieBotSchedule[],
|
||||
): CookieBotSchedule[] {
|
||||
return [...schedules].sort((a, b) => {
|
||||
const at = parseIso(a.next_run_at)?.getTime();
|
||||
const bt = parseIso(b.next_run_at)?.getTime();
|
||||
if (at !== undefined && bt !== undefined) return at - bt;
|
||||
if (at !== undefined) return -1;
|
||||
if (bt !== undefined) return 1;
|
||||
return scheduleSortKey(a) - scheduleSortKey(b);
|
||||
});
|
||||
}
|
||||
|
||||
export function useNextDue(schedules: CookieBotSchedule[]) {
|
||||
return useMemo(() => {
|
||||
const enabled = schedules.filter((s) => s.enabled);
|
||||
const sorted = sortByNextRun(enabled);
|
||||
const first = sorted[0] ?? null;
|
||||
const firstAt = parseIso(first?.next_run_at ?? null);
|
||||
const dueCount = firstAt
|
||||
? sorted.filter((s) => {
|
||||
const at = parseIso(s.next_run_at);
|
||||
if (!at) return false;
|
||||
return at.getTime() - firstAt.getTime() < 12 * 60 * 60 * 1000;
|
||||
}).length
|
||||
: enabled.length;
|
||||
return { next: first, nextAt: firstAt, dueCount };
|
||||
}, [schedules]);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
LuCookie,
|
||||
LuInfo,
|
||||
LuLock,
|
||||
LuMoon,
|
||||
LuPlay,
|
||||
LuPuzzle,
|
||||
LuSquare,
|
||||
@@ -35,6 +36,22 @@ import {
|
||||
LuUserSearch,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { CookieBotEnrolDialog } from "@/components/cookie-bot-enrol-dialog";
|
||||
import { CookieBotRunsDialog } from "@/components/cookie-bot-runs-dialog";
|
||||
import {
|
||||
describeCadence,
|
||||
enableProfileSync,
|
||||
minutesToClock,
|
||||
outcomeLabel,
|
||||
type PreflightFix,
|
||||
preflight,
|
||||
preflightFixLabel,
|
||||
preflightReason,
|
||||
runStatusLabel,
|
||||
StatusDot,
|
||||
sessionPhaseLabel,
|
||||
sessionTone,
|
||||
} from "@/components/cookie-bot-shared";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import {
|
||||
ProfileBypassRulesDialog,
|
||||
@@ -57,6 +74,8 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -80,24 +99,35 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useBrowserState } from "@/hooks/use-browser-state";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useScrollFade } from "@/hooks/use-scroll-fade";
|
||||
import { useTableSorting } from "@/hooks/use-table-sorting";
|
||||
import { useTeamLocks } from "@/hooks/use-team-locks";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import {
|
||||
getBrowserDisplayName,
|
||||
getOSDisplayName,
|
||||
getProfileIcon,
|
||||
isCrossOsProfile,
|
||||
} from "@/lib/browser-utils";
|
||||
import {
|
||||
type CookieBotSchedule,
|
||||
cancelCookieBotRun,
|
||||
deleteCookieBotSchedule,
|
||||
runCookieBotNow,
|
||||
} from "@/lib/cookie-bot";
|
||||
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
|
||||
import { canUseCookieBot } from "@/lib/entitlements";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
BrowserProfile,
|
||||
ExtensionGroup,
|
||||
LocationItem,
|
||||
ProfileBotState,
|
||||
ProxyCheckResult,
|
||||
StoredProxy,
|
||||
SyncSessionInfo,
|
||||
@@ -252,8 +282,52 @@ interface TableMeta {
|
||||
}
|
||||
| undefined;
|
||||
onLaunchWithSync: (profile: BrowserProfile) => void;
|
||||
|
||||
// Cookie Bot
|
||||
cookieBotUnlocked: boolean;
|
||||
/** Narrow container: the bot column shows its state mark without the label. */
|
||||
cookieBotCompact: boolean;
|
||||
getProfileBotState: (profileId: string) => ProfileBotState;
|
||||
/** A run this desktop has just asked for, before the stream confirms it. */
|
||||
botPendingProfiles: Set<string>;
|
||||
onBotEnrol: (profile: BrowserProfile) => void;
|
||||
onBotEdit: (profile: BrowserProfile, schedule: CookieBotSchedule) => void;
|
||||
onBotRunNow: (profile: BrowserProfile) => void;
|
||||
onBotStopRun: (runId: string) => void;
|
||||
onBotViewActivity: (profile: BrowserProfile) => void;
|
||||
onBotUnenrol: (profile: BrowserProfile) => void;
|
||||
/**
|
||||
* Perform the repair a failed preflight names, or null when this surface has
|
||||
* no way to reach it. A reason with no affordance is what the menu showed
|
||||
* before: "No proxy or VPN · Attach a proxy" as inert label text that reads
|
||||
* like a button and answers no click.
|
||||
*/
|
||||
onBotFix: ((profile: BrowserProfile, fix: PreflightFix) => void) | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Below this container width the bot column keeps its state mark but drops the
|
||||
* "next run" label: an operator still sees at a glance which rows are enrolled
|
||||
* and which are running, and the row menu stays reachable, without taking the
|
||||
* width the name needs.
|
||||
*/
|
||||
const BOT_LABEL_WIDTH = 880;
|
||||
|
||||
/** Below this the bot column leaves entirely, like the other low-priority ones. */
|
||||
const BOT_COLUMN_MIN_WIDTH = 400;
|
||||
|
||||
/** Bulk enrolments of this size or larger are confirmed, as run and stop are. */
|
||||
const BULK_ENROL_CONFIRM_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* Run statuses that mean the browser never came up.
|
||||
*
|
||||
* `POST /cookie-bot/runs` answers 202 with the run row it recorded, so a
|
||||
* refusal — no capacity, no sites, a profile someone else has open — arrives as
|
||||
* a successful response carrying a terminal status.
|
||||
*/
|
||||
const RUN_DID_NOT_START = new Set(["skipped", "failed", "cancelled"]);
|
||||
|
||||
interface SyncStatusDot {
|
||||
color: string;
|
||||
tooltip: string;
|
||||
@@ -1117,6 +1191,196 @@ const NoteCell = React.memo<{
|
||||
|
||||
NoteCell.displayName = "NoteCell";
|
||||
|
||||
/** `HH:MM` of the server's own next-run instant, in this machine's locale. */
|
||||
function formatNextRun(schedule: CookieBotSchedule): string | null {
|
||||
if (!schedule.next_run_at) return null;
|
||||
const date = new Date(schedule.next_run_at);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One row's Cookie Bot state, and the row's bot actions.
|
||||
*
|
||||
* The whole cell is the menu trigger. A dedicated kebab would cost another
|
||||
* column of width in a table that is already dense, and the state mark is
|
||||
* exactly the thing an operator reaches for when they want to change it. The
|
||||
* dot, the tone and the phase wording all come from the shared status
|
||||
* vocabulary, so a run cannot read one way here and another on the Cookie Bot
|
||||
* page.
|
||||
*/
|
||||
const BotCell = React.memo<{
|
||||
profile: BrowserProfile;
|
||||
meta: TableMeta;
|
||||
}>(({ profile, meta }) => {
|
||||
// Own `t` rather than `meta.t`: the shared status helpers take a real
|
||||
// `TFunction`, and every other cell in this file resolves it the same way.
|
||||
const { t } = useTranslation();
|
||||
const { schedule, liveSession } = meta.getProfileBotState(profile.id);
|
||||
const check = preflight(profile);
|
||||
const isPending = meta.botPendingProfiles.has(profile.id);
|
||||
const isLive = liveSession !== null;
|
||||
const nextRun = schedule ? formatNextRun(schedule) : null;
|
||||
|
||||
// The server computes "why tonight would be refused" on every read, precisely
|
||||
// so a detached proxy is visible in the afternoon rather than announcing
|
||||
// itself as a skipped run at 02:00. Dropping it left a broken enrolment
|
||||
// showing a healthy dot and a next-run time it could never keep.
|
||||
const blockedReason =
|
||||
schedule?.enabled && schedule.blocked_by
|
||||
? outcomeLabel(t, schedule.blocked_by)
|
||||
: null;
|
||||
|
||||
// `provisioning` is a transfer in progress — the one meaning the app already
|
||||
// reserves a pulsing dot for. Nothing else pulses.
|
||||
const isPreparing = liveSession?.state === "provisioning" || isPending;
|
||||
const tone = liveSession
|
||||
? sessionTone(liveSession)
|
||||
: isPending
|
||||
? "warning"
|
||||
: schedule
|
||||
? schedule.enabled && !blockedReason
|
||||
? "muted"
|
||||
: "warning"
|
||||
: null;
|
||||
|
||||
const label = isPending
|
||||
? t("cookieBot.status.provisioning")
|
||||
: liveSession
|
||||
? sessionPhaseLabel(t, liveSession)
|
||||
: schedule
|
||||
? !schedule.enabled
|
||||
? t("cookieBot.state.paused")
|
||||
: (blockedReason ?? nextRun ?? t("cookieBot.state.enrolled"))
|
||||
: "—";
|
||||
|
||||
const summary = schedule
|
||||
? blockedReason
|
||||
? t("cookieBot.state.blocked", { reason: blockedReason })
|
||||
: t("cookieBot.state.summary", {
|
||||
cadence: describeCadence(t, schedule.days_mask),
|
||||
time: minutesToClock(schedule.run_at_minute),
|
||||
})
|
||||
: check.eligible
|
||||
? t("cookieBot.state.notEnrolled")
|
||||
: // The repair is its own menu item when this surface can reach it, so
|
||||
// the label stays a statement instead of looking like a second button.
|
||||
[
|
||||
preflightReason(t, check),
|
||||
meta.onBotFix ? null : preflightFixLabel(t, check.fix),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("cookieBot.state.rowMenu", { name: profile.name })}
|
||||
className="flex h-9 w-full min-w-0 cursor-pointer items-center gap-1.5 rounded border-none bg-transparent px-1.5 text-left transition-colors duration-100 hover:bg-muted"
|
||||
>
|
||||
{tone ? (
|
||||
<StatusDot tone={tone} pulse={isPreparing} className="size-1.5" />
|
||||
) : (
|
||||
<span aria-hidden="true" className="size-1.5 shrink-0" />
|
||||
)}
|
||||
{!meta.cookieBotCompact && (
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-xs tabular-nums",
|
||||
isLive || isPending
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-56">
|
||||
<DropdownMenuLabel className="font-normal text-muted-foreground">
|
||||
{summary}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{schedule ? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onBotEdit(profile, schedule);
|
||||
}}
|
||||
>
|
||||
{t("cookieBot.actions.editSchedule")}
|
||||
</DropdownMenuItem>
|
||||
{liveSession?.run_id ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (liveSession.run_id) {
|
||||
meta.onBotStopRun(liveSession.run_id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("cookieBot.running.stop")}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
disabled={isLive || isPending}
|
||||
onClick={() => {
|
||||
meta.onBotRunNow(profile);
|
||||
}}
|
||||
>
|
||||
{t("cookieBot.actions.runNow")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onBotViewActivity(profile);
|
||||
}}
|
||||
>
|
||||
{t("cookieBot.actions.viewActivity")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
meta.onBotUnenrol(profile);
|
||||
}}
|
||||
>
|
||||
{t("cookieBot.schedule.unenrol")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!check.eligible && check.fix && meta.onBotFix && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onBotFix?.(profile, check.fix as PreflightFix);
|
||||
}}
|
||||
>
|
||||
{preflightFixLabel(t, check.fix)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
disabled={!check.eligible}
|
||||
onClick={() => {
|
||||
meta.onBotEnrol(profile);
|
||||
}}
|
||||
>
|
||||
{t("cookieBot.actions.enrol")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
});
|
||||
|
||||
BotCell.displayName = "BotCell";
|
||||
|
||||
interface ProfilesDataTableProps {
|
||||
profiles: BrowserProfile[];
|
||||
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
@@ -1131,6 +1395,8 @@ interface ProfilesDataTableProps {
|
||||
isUpdating: (browser: string) => boolean;
|
||||
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
|
||||
onAssignProfilesToGroup: (profileIds: string[]) => void;
|
||||
/** Opens proxy assignment for a specific set of profiles. */
|
||||
onAssignProfilesToProxy?: (profileIds: string[]) => void;
|
||||
selectedGroupId: string | null;
|
||||
selectedProfiles: string[];
|
||||
onSelectedProfilesChange: Dispatch<SetStateAction<string[]>>;
|
||||
@@ -1186,6 +1452,7 @@ export function ProfilesDataTable({
|
||||
runningProfiles,
|
||||
isUpdating,
|
||||
onAssignProfilesToGroup,
|
||||
onAssignProfilesToProxy,
|
||||
selectedProfiles,
|
||||
onSelectedProfilesChange,
|
||||
onBulkDelete,
|
||||
@@ -1319,6 +1586,36 @@ export function ProfilesDataTable({
|
||||
const { user } = useCloudAuth();
|
||||
const { isProfileLocked, getLockInfo } = useTeamLocks(user?.id);
|
||||
|
||||
// Cookie Bot. Enrolments and live runs both live server-side, so the table
|
||||
// reads them from the shared store rather than from BrowserProfile.
|
||||
const cookieBotUnlocked = canUseCookieBot(user);
|
||||
const {
|
||||
scheduleFor,
|
||||
liveSessionFor,
|
||||
refresh: refreshCookieBotState,
|
||||
} = useCookieBot(cookieBotUnlocked, cookieBotScopeFor(user));
|
||||
const [botPendingProfiles, setBotPendingProfiles] = React.useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [botScheduleDialog, setBotScheduleDialog] = React.useState<{
|
||||
profiles: BrowserProfile[];
|
||||
existing: CookieBotSchedule | null;
|
||||
} | null>(null);
|
||||
const [botRunsProfile, setBotRunsProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [botUnenrolProfile, setBotUnenrolProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [isUnenrolling, setIsUnenrolling] = React.useState(false);
|
||||
const [pendingBulkEnrol, setPendingBulkEnrol] = React.useState<
|
||||
BrowserProfile[] | null
|
||||
>(null);
|
||||
|
||||
// Content columns grow proportionally with the container but never drop
|
||||
// below the compact-layout floor; the name column takes the remainder.
|
||||
// Computed in px from the observed container width because fixed table
|
||||
// layout ignores max()/calc() column widths.
|
||||
const [containerWidth, setContainerWidth] = React.useState(0);
|
||||
|
||||
const [proxyOverrides, setProxyOverrides] = React.useState<
|
||||
Record<string, string | null>
|
||||
>({});
|
||||
@@ -1512,6 +1809,142 @@ export function ProfilesDataTable({
|
||||
[handleProxySelection],
|
||||
);
|
||||
|
||||
const getProfileBotState = React.useCallback(
|
||||
(profileId: string): ProfileBotState => ({
|
||||
schedule: scheduleFor(profileId),
|
||||
liveSession: liveSessionFor(profileId),
|
||||
}),
|
||||
[scheduleFor, liveSessionFor],
|
||||
);
|
||||
|
||||
const handleBotEnrol = React.useCallback((profile: BrowserProfile) => {
|
||||
setBotScheduleDialog({ profiles: [profile], existing: null });
|
||||
}, []);
|
||||
|
||||
const handleBotEdit = React.useCallback(
|
||||
(profile: BrowserProfile, schedule: CookieBotSchedule) => {
|
||||
setBotScheduleDialog({ profiles: [profile], existing: schedule });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleBotViewActivity = React.useCallback((profile: BrowserProfile) => {
|
||||
setBotRunsProfile(profile);
|
||||
}, []);
|
||||
|
||||
const handleBotFix = React.useCallback(
|
||||
(profile: BrowserProfile, fix: PreflightFix) => {
|
||||
if (fix === "proxy") {
|
||||
onAssignProfilesToProxy?.([profile.id]);
|
||||
return;
|
||||
}
|
||||
if (fix === "syncSettings") {
|
||||
onOpenProfileSyncDialog?.(profile);
|
||||
return;
|
||||
}
|
||||
void enableProfileSync(profile.id).catch((error: unknown) => {
|
||||
showErrorToast(
|
||||
parseBackendError(error)
|
||||
? translateBackendError(t as never, error)
|
||||
: t("cookieBot.preflight.fixFailed"),
|
||||
);
|
||||
});
|
||||
},
|
||||
[onAssignProfilesToProxy, onOpenProfileSyncDialog, t],
|
||||
);
|
||||
|
||||
// Null rather than a no-op when nothing is wired: the menu then states the
|
||||
// reason without offering a repair it cannot perform.
|
||||
const botFixHandler =
|
||||
onAssignProfilesToProxy || onOpenProfileSyncDialog ? handleBotFix : null;
|
||||
|
||||
const handleBotRunNow = React.useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
// Held locally until the stream reports the session: the run is real the
|
||||
// moment the command returns, and a row that still looks idle invites a
|
||||
// second click that would spend a second hour.
|
||||
setBotPendingProfiles((prev) => new Set(prev).add(profile.id));
|
||||
try {
|
||||
const started = await runCookieBotNow(profile.id);
|
||||
// 202, not 200: the route answers with a RECORDED run, and a run that
|
||||
// could not get a host comes back already terminal, carrying an
|
||||
// `outcome_code`, rather than as an HTTP error. Treating every 2xx as
|
||||
// "started" told a user their run had begun on a night when every
|
||||
// Windows host in a four-slot fleet was busy, and the only trace was a
|
||||
// row in a history panel they had to go and open.
|
||||
if (RUN_DID_NOT_START.has(started.run.status)) {
|
||||
showErrorToast(
|
||||
t("cookieBot.actions.runNotStarted", {
|
||||
reason:
|
||||
outcomeLabel(t as never, started.run.outcome_code) ??
|
||||
runStatusLabel(t as never, started.run.status),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(t("cookieBot.actions.runStarted"));
|
||||
}
|
||||
await refreshCookieBotState();
|
||||
} catch (error) {
|
||||
showErrorToast(translateBackendError(t as never, error));
|
||||
} finally {
|
||||
setBotPendingProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(profile.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[refreshCookieBotState, t],
|
||||
);
|
||||
|
||||
const handleBotStopRun = React.useCallback(
|
||||
async (runId: string) => {
|
||||
try {
|
||||
await cancelCookieBotRun(runId);
|
||||
showSuccessToast(t("cookieBot.running.stopped"));
|
||||
await refreshCookieBotState();
|
||||
} catch (error) {
|
||||
showErrorToast(translateBackendError(t as never, error));
|
||||
}
|
||||
},
|
||||
[refreshCookieBotState, t],
|
||||
);
|
||||
|
||||
const handleBotUnenrol = React.useCallback(async () => {
|
||||
if (!botUnenrolProfile) return;
|
||||
setIsUnenrolling(true);
|
||||
try {
|
||||
await deleteCookieBotSchedule(botUnenrolProfile.id);
|
||||
showSuccessToast(t("cookieBot.schedule.unenrolled"));
|
||||
setBotUnenrolProfile(null);
|
||||
await refreshCookieBotState();
|
||||
} catch (error) {
|
||||
showErrorToast(translateBackendError(t as never, error));
|
||||
} finally {
|
||||
setIsUnenrolling(false);
|
||||
}
|
||||
}, [botUnenrolProfile, refreshCookieBotState, t]);
|
||||
|
||||
const handleBulkCookieBotEnrol = React.useCallback(() => {
|
||||
const targets = profiles.filter((p) => selectedProfiles.includes(p.id));
|
||||
if (targets.length === 0) return;
|
||||
const eligible = targets.filter((p) => preflight(p).eligible);
|
||||
// Same guard as bulk run: an action that can touch nothing says so instead
|
||||
// of opening a dialog whose only outcome is a refusal.
|
||||
if (eligible.length === 0) {
|
||||
showErrorToast(t("cookieBot.actionBar.noneEligible"));
|
||||
return;
|
||||
}
|
||||
// Ten or more is the threshold bulk run and stop already use, and enrolling
|
||||
// is the heavier commitment of the three: each row books a nightly job
|
||||
// against a shared budget.
|
||||
if (eligible.length >= BULK_ENROL_CONFIRM_THRESHOLD) {
|
||||
setPendingBulkEnrol(targets);
|
||||
return;
|
||||
}
|
||||
setBotScheduleDialog({ profiles: targets, existing: null });
|
||||
}, [profiles, selectedProfiles, t]);
|
||||
|
||||
// Use shared browser state hook
|
||||
const browserState = useBrowserState(
|
||||
profiles,
|
||||
@@ -2019,6 +2452,23 @@ export function ProfilesDataTable({
|
||||
(() => {
|
||||
/* empty */
|
||||
}),
|
||||
|
||||
// Cookie Bot
|
||||
cookieBotUnlocked,
|
||||
cookieBotCompact: containerWidth > 0 && containerWidth < BOT_LABEL_WIDTH,
|
||||
getProfileBotState,
|
||||
botPendingProfiles,
|
||||
onBotEnrol: handleBotEnrol,
|
||||
onBotEdit: handleBotEdit,
|
||||
onBotRunNow: (profile: BrowserProfile) => {
|
||||
void handleBotRunNow(profile);
|
||||
},
|
||||
onBotStopRun: (runId: string) => {
|
||||
void handleBotStopRun(runId);
|
||||
},
|
||||
onBotViewActivity: handleBotViewActivity,
|
||||
onBotUnenrol: setBotUnenrolProfile,
|
||||
onBotFix: botFixHandler,
|
||||
}),
|
||||
[
|
||||
t,
|
||||
@@ -2076,6 +2526,16 @@ export function ProfilesDataTable({
|
||||
getLockInfo,
|
||||
getProfileSyncInfo,
|
||||
onLaunchWithSync,
|
||||
cookieBotUnlocked,
|
||||
containerWidth,
|
||||
getProfileBotState,
|
||||
botPendingProfiles,
|
||||
handleBotEnrol,
|
||||
handleBotEdit,
|
||||
handleBotRunNow,
|
||||
handleBotStopRun,
|
||||
handleBotViewActivity,
|
||||
botFixHandler,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2976,6 +3436,19 @@ export function ProfilesDataTable({
|
||||
return <DnsCell profile={profile} meta={meta} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bot",
|
||||
size: 84,
|
||||
header: ({ table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
if (meta.cookieBotCompact) return null;
|
||||
return meta.t("profiles.table.bot");
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
return <BotCell profile={row.original} meta={meta} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
header: "",
|
||||
@@ -3053,14 +3526,11 @@ export function ProfilesDataTable({
|
||||
// Low-priority columns leave the table as the container narrows (most
|
||||
// expendable first); their data stays reachable via the profile info
|
||||
// dialog. Visibility (not CSS hiding) so table-fixed reclaims the width.
|
||||
// `bot` starts hidden and is switched on by the resize effect below. An
|
||||
// unentitled account must never see a paid column, not even for the frame
|
||||
// before the observer's first measurement lands.
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({ created_at: false });
|
||||
|
||||
// Content columns grow proportionally with the container but never drop
|
||||
// below the compact-layout floor; the name column takes the remainder.
|
||||
// Computed in px from the observed container width because fixed table
|
||||
// layout ignores max()/calc() column widths.
|
||||
const [containerWidth, setContainerWidth] = React.useState(0);
|
||||
React.useState<VisibilityState>({ created_at: false, bot: false });
|
||||
|
||||
const table = useReactTable({
|
||||
data: profiles,
|
||||
@@ -3090,6 +3560,14 @@ export function ProfilesDataTable({
|
||||
const scrollParentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const columnWidth = React.useCallback(
|
||||
(id: string, sizePx: number) => {
|
||||
// The bot column is the one column with two shapes: a labelled state at
|
||||
// full width, a bare mark when the table is narrow. Taking a proportion
|
||||
// in the compact shape would waste the space the name column needs.
|
||||
if (id === "bot") {
|
||||
return containerWidth > 0 && containerWidth < BOT_LABEL_WIDTH
|
||||
? "28px"
|
||||
: `${Math.max(84, Math.round(containerWidth * 0.09))}px`;
|
||||
}
|
||||
const proportions: Record<string, { pct: number; floor: number }> = {
|
||||
tags: { pct: 0.12, floor: 100 },
|
||||
note: { pct: 0.1, floor: 80 },
|
||||
@@ -3120,6 +3598,10 @@ export function ProfilesDataTable({
|
||||
ext: w >= 672,
|
||||
note: w >= 576,
|
||||
tags: w >= 512,
|
||||
// Bot state survives further down than the other content columns:
|
||||
// by then it is a 28px mark, and it is the only place a row's
|
||||
// enrolment and its actions can be reached.
|
||||
bot: cookieBotUnlocked && w >= BOT_COLUMN_MIN_WIDTH,
|
||||
};
|
||||
return Object.keys(next).every((k) => prev[k] === next[k])
|
||||
? prev
|
||||
@@ -3132,7 +3614,7 @@ export function ProfilesDataTable({
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
}, [cookieBotUnlocked]);
|
||||
|
||||
// Compact 36px row from the redesign spec; estimateSize must match the
|
||||
// actual rendered row height or virtualizer placement drifts under scroll.
|
||||
@@ -3508,6 +3990,23 @@ export function ProfilesDataTable({
|
||||
<LuCookie />
|
||||
</DataTableActionBarAction>
|
||||
)}
|
||||
<span className="relative inline-flex">
|
||||
<DataTableActionBarAction
|
||||
tooltip={
|
||||
cookieBotUnlocked
|
||||
? t("cookieBot.actionBar.enrol")
|
||||
: t("cookieBot.actionBar.proRequired")
|
||||
}
|
||||
onClick={cookieBotUnlocked ? handleBulkCookieBotEnrol : undefined}
|
||||
disabled={!cookieBotUnlocked}
|
||||
size="icon"
|
||||
>
|
||||
<LuMoon />
|
||||
</DataTableActionBarAction>
|
||||
{!cookieBotUnlocked && (
|
||||
<ProBadge className="pointer-events-none absolute -top-2 -right-2" />
|
||||
)}
|
||||
</span>
|
||||
{onBulkDelete && (
|
||||
<DataTableActionBarAction
|
||||
tooltip={t("common.buttons.delete")}
|
||||
@@ -3554,6 +4053,85 @@ export function ProfilesDataTable({
|
||||
profileId={launchHookProfile?.id ?? null}
|
||||
currentLaunchHook={launchHookProfile?.launch_hook ?? null}
|
||||
/>
|
||||
{botScheduleDialog && (
|
||||
<CookieBotEnrolDialog
|
||||
isOpen
|
||||
onClose={() => {
|
||||
setBotScheduleDialog(null);
|
||||
}}
|
||||
profiles={botScheduleDialog.profiles}
|
||||
existing={botScheduleDialog.existing}
|
||||
onOpenProfileSync={onOpenProfileSyncDialog}
|
||||
// "A proxy or VPN is required" is the precondition most profiles
|
||||
// fail, and without this the dialog showed the reason with no way to
|
||||
// act on it — one-click fixable from the Cookie Bot page and a dead
|
||||
// end from the row menu that is the primary entry point.
|
||||
onAssignProxy={onAssignProfilesToProxy}
|
||||
onSaved={() => {
|
||||
// Clearing after a bulk write mirrors the other bulk actions: the
|
||||
// selection has been acted on, and leaving it live invites a second
|
||||
// pass over profiles that are already enrolled.
|
||||
if (botScheduleDialog.profiles.length > 1) {
|
||||
onSelectedProfilesChange([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={pendingBulkEnrol !== null}
|
||||
onClose={() => {
|
||||
setPendingBulkEnrol(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (!pendingBulkEnrol) return;
|
||||
setBotScheduleDialog({
|
||||
profiles: pendingBulkEnrol,
|
||||
existing: null,
|
||||
});
|
||||
setPendingBulkEnrol(null);
|
||||
}}
|
||||
title={t("cookieBot.enrol.confirmBulkTitle", {
|
||||
count:
|
||||
pendingBulkEnrol?.filter((p) => preflight(p).eligible).length ?? 0,
|
||||
})}
|
||||
description={t("cookieBot.enrol.confirmBulkDescription", {
|
||||
count:
|
||||
pendingBulkEnrol?.filter((p) => preflight(p).eligible).length ?? 0,
|
||||
})}
|
||||
confirmButtonText={t("cookieBot.enrol.confirmBulkButton", {
|
||||
count:
|
||||
pendingBulkEnrol?.filter((p) => preflight(p).eligible).length ?? 0,
|
||||
})}
|
||||
confirmButtonVariant="default"
|
||||
profileIds={pendingBulkEnrol
|
||||
?.filter((p) => preflight(p).eligible)
|
||||
.map((p) => p.id)}
|
||||
profiles={pendingBulkEnrol?.map((p) => ({ id: p.id, name: p.name }))}
|
||||
/>
|
||||
<CookieBotRunsDialog
|
||||
isOpen={botRunsProfile !== null}
|
||||
onClose={() => {
|
||||
setBotRunsProfile(null);
|
||||
}}
|
||||
profileId={botRunsProfile?.id ?? null}
|
||||
profileName={botRunsProfile?.name}
|
||||
onRunCancelled={() => {
|
||||
void refreshCookieBotState();
|
||||
}}
|
||||
/>
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={botUnenrolProfile !== null}
|
||||
onClose={() => {
|
||||
setBotUnenrolProfile(null);
|
||||
}}
|
||||
onConfirm={handleBotUnenrol}
|
||||
title={t("cookieBot.schedule.unenrolTitle", {
|
||||
name: botUnenrolProfile?.name ?? "",
|
||||
})}
|
||||
description={t("cookieBot.schedule.unenrolDescription")}
|
||||
confirmButtonText={t("cookieBot.schedule.unenrol")}
|
||||
isLoading={isUnenrolling}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2034,8 +2034,7 @@ function SecuritySectionInline({
|
||||
}
|
||||
if (mode === "set" || mode === "change") {
|
||||
if (password.length < 8) return t("profilePassword.errors.tooShort");
|
||||
if (password !== confirm)
|
||||
return t("profilePassword.errors.passwordMismatch");
|
||||
if (password !== confirm) return t("profilePassword.errors.mismatch");
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal } from "react-icons/go";
|
||||
import {
|
||||
LuCloud,
|
||||
LuCookie,
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuPlug,
|
||||
@@ -26,6 +27,7 @@ export type AppPage =
|
||||
| "proxies"
|
||||
| "extensions"
|
||||
| "groups"
|
||||
| "cookieBot"
|
||||
| "vpns"
|
||||
| "settings"
|
||||
| "integrations"
|
||||
@@ -174,6 +176,12 @@ interface RailNavProps {
|
||||
currentPage: AppPage;
|
||||
onNavigate: (page: AppPage) => void;
|
||||
onOpenAbout: () => void;
|
||||
/**
|
||||
* A remote session is running right now. The Cookie Bot item carries a dot so
|
||||
* the state is legible from every other page — an overnight job you cannot
|
||||
* see from where you are standing may as well not be observable at all.
|
||||
*/
|
||||
cookieBotRunning?: boolean;
|
||||
}
|
||||
|
||||
/** Shared-element indicator that slides between the active rail items. */
|
||||
@@ -199,6 +207,7 @@ const TOP_ITEMS: RailItem[] = [
|
||||
{ page: "proxies", Icon: FiWifi, labelKey: "rail.network" },
|
||||
{ page: "extensions", Icon: LuPuzzle, labelKey: "rail.extensions" },
|
||||
{ page: "groups", Icon: LuUsers, labelKey: "rail.groups" },
|
||||
{ page: "cookieBot", Icon: LuCookie, labelKey: "rail.cookieBot" },
|
||||
{ page: "integrations", Icon: LuPlug, labelKey: "rail.integrations" },
|
||||
{ page: "account", Icon: LuCloud, labelKey: "rail.account" },
|
||||
];
|
||||
@@ -229,6 +238,7 @@ export function RailNav({
|
||||
currentPage,
|
||||
onNavigate,
|
||||
onOpenAbout,
|
||||
cookieBotRunning = false,
|
||||
}: RailNavProps) {
|
||||
const { t } = useTranslation();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
@@ -325,9 +335,19 @@ export function RailNav({
|
||||
>
|
||||
{active && <ActiveIndicator />}
|
||||
<Icon className="size-3.5" />
|
||||
{page === "cookieBot" && cookieBotRunning && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute top-1 right-1 size-1.5 rounded-full bg-success"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
|
||||
<TooltipContent side="right">
|
||||
{page === "cookieBot" && cookieBotRunning
|
||||
? t("rail.cookieBotRunning")
|
||||
: t(labelKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type {
|
||||
NameType,
|
||||
ValueType,
|
||||
} from "recharts/types/component/DefaultTooltipContent";
|
||||
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
||||
import { formatHours, RemoteHoursMeter } from "@/components/cookie-bot-shared";
|
||||
import { AnimatedDisclosureItem } from "@/components/ui/animated-disclosure";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import {
|
||||
type CookieBotUsage,
|
||||
type CookieBotUsageMember,
|
||||
getCookieBotUsage,
|
||||
type RemoteHoursQuota,
|
||||
} from "@/lib/cookie-bot";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** How many past billing periods the selector offers. */
|
||||
const PERIOD_COUNT = 6;
|
||||
|
||||
function periodKey(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function recentPeriods(): { value: string; label: string }[] {
|
||||
const now = new Date();
|
||||
return Array.from({ length: PERIOD_COUNT }, (_, index) => {
|
||||
const date = new Date(now.getFullYear(), now.getMonth() - index, 1);
|
||||
return {
|
||||
value: periodKey(date),
|
||||
label: date.toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The part of an address that identifies the person, for a dense axis. */
|
||||
function shortName(email: string): string {
|
||||
const local = email.split("@")[0] ?? email;
|
||||
return local.length > 14 ? `${local.slice(0, 13)}…` : local;
|
||||
}
|
||||
|
||||
interface MemberDatum {
|
||||
name: string;
|
||||
email: string;
|
||||
bot: number;
|
||||
interactive: number;
|
||||
total: number;
|
||||
runs: number;
|
||||
/** How many of those runs did not do what they were asked. */
|
||||
runsFailed: number;
|
||||
sessions: number;
|
||||
}
|
||||
|
||||
interface TeamUsagePanelProps {
|
||||
/**
|
||||
* The live pooled budget. Only used before the selected period's own figures
|
||||
* arrive, so the block is never empty on first paint; once `usage` lands the
|
||||
* period's numbers win, because looking at June must show what June allowed
|
||||
* rather than what is left today.
|
||||
*/
|
||||
quota?: RemoteHoursQuota | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who spent the pooled remote hours this period.
|
||||
*
|
||||
* The account page owns plan truth — what was bought — and this is the other
|
||||
* half of that: what it was spent on and by whom. Every figure is served; the
|
||||
* desktop computes no allowance and no share of one.
|
||||
*/
|
||||
export function TeamUsagePanel({ quota, className }: TeamUsagePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const periods = React.useMemo(() => recentPeriods(), []);
|
||||
const [period, setPeriod] = React.useState(periods[0].value);
|
||||
const [usage, setUsage] = React.useState<CookieBotUsage | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
void getCookieBotUsage(period)
|
||||
.then((result) => {
|
||||
if (active) setUsage(result);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (active) {
|
||||
setUsage(null);
|
||||
setError(translateBackendError(t as never, err));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [period, t]);
|
||||
|
||||
// Heaviest first: the whole point of the view is to make the biggest
|
||||
// consumer the first thing read, both in the chart and in the table.
|
||||
const members: MemberDatum[] = React.useMemo(() => {
|
||||
if (!usage) return [];
|
||||
return [...usage.members]
|
||||
.sort(
|
||||
(a: CookieBotUsageMember, b: CookieBotUsageMember) =>
|
||||
b.used_hours - a.used_hours,
|
||||
)
|
||||
.map((member) => ({
|
||||
name: shortName(member.email),
|
||||
email: member.email,
|
||||
bot: member.bot_hours,
|
||||
interactive: member.interactive_hours,
|
||||
total: member.used_hours,
|
||||
runs: member.bot_runs,
|
||||
runsFailed: member.bot_runs_failed,
|
||||
sessions: member.sessions,
|
||||
}));
|
||||
}, [usage]);
|
||||
|
||||
const heaviest = members[0]?.total ?? 0;
|
||||
const isSolo = members.length <= 1;
|
||||
|
||||
// The meter takes a quota shape; the usage response carries the same numbers
|
||||
// for the period being looked at, so it is adapted rather than
|
||||
// re-implemented. The live quota is only the stand-in until it arrives.
|
||||
const pooled: RemoteHoursQuota | null = React.useMemo(
|
||||
() =>
|
||||
usage
|
||||
? {
|
||||
granted_hours: usage.granted_hours,
|
||||
used_hours: usage.used_hours,
|
||||
remaining_hours: usage.remaining_hours,
|
||||
period_start: usage.period_start,
|
||||
period_end: usage.period_end,
|
||||
team_id: usage.team_id,
|
||||
seats: usage.seats,
|
||||
per_seat_hours: 0,
|
||||
members: [],
|
||||
}
|
||||
: (quota ?? null),
|
||||
[usage, quota],
|
||||
);
|
||||
|
||||
const renderTooltip = React.useCallback(
|
||||
({ active, payload }: TooltipContentProps<ValueType, NameType>) => {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
const datum = payload[0].payload as MemberDatum;
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-popover px-2.5 py-2 text-xs text-popover-foreground shadow-sm">
|
||||
<p className="font-medium">{datum.email}</p>
|
||||
<p className="mt-1 flex items-center justify-between gap-4 tabular-nums">
|
||||
<span className="text-chart-1">
|
||||
{t("cookieBot.team.legendBot")}
|
||||
</span>
|
||||
<span>
|
||||
{t("cookieBot.team.hours", { hours: formatHours(datum.bot) })}
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex items-center justify-between gap-4 tabular-nums">
|
||||
<span className="text-chart-2">
|
||||
{t("cookieBot.team.legendInteractive")}
|
||||
</span>
|
||||
<span>
|
||||
{t("cookieBot.team.hours", {
|
||||
hours: formatHours(datum.interactive),
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3", className)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-medium">{t("cookieBot.team.title")}</h3>
|
||||
<Select
|
||||
value={period}
|
||||
onValueChange={(value) => {
|
||||
setPeriod(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{periods.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Driven by the selected period's own figures, not by the live quota:
|
||||
choosing June must show what June was allowed, not what is left
|
||||
today. */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{pooled ? (
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("cookieBot.team.pooled", {
|
||||
used: formatHours(pooled.used_hours),
|
||||
total: formatHours(pooled.granted_hours),
|
||||
// `count` (not `seats`) so i18next can pluralise: "1 seat" and
|
||||
// "across 4 seats" are different sentences in most locales.
|
||||
count: pooled.seats,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<Skeleton className="h-3 w-56" />
|
||||
)}
|
||||
<RemoteHoursMeter
|
||||
quota={pooled}
|
||||
isLoading={isLoading && usage === null}
|
||||
variant="inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="py-6 text-center text-xs text-destructive-text">
|
||||
{error}
|
||||
</p>
|
||||
) : isLoading && !usage ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-[180px] w-full" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</div>
|
||||
) : members.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
{t("cookieBot.team.noActivity")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{!isSolo && (
|
||||
<>
|
||||
<div className="h-[clamp(160px,22vh,240px)] w-full">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={1}
|
||||
minHeight={1}
|
||||
>
|
||||
<AreaChart
|
||||
data={members}
|
||||
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="cookieBotHoursGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.5}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="interactiveHoursGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--chart-2)"
|
||||
stopOpacity={0.5}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--chart-2)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
className="text-xs"
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-xs"
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
width={40}
|
||||
/>
|
||||
<Tooltip content={renderTooltip} />
|
||||
{/* `linear` because the axis is a ranking, not time: a
|
||||
monotone curve would invent values between people. */}
|
||||
<Area
|
||||
type="linear"
|
||||
dataKey="bot"
|
||||
stackId="1"
|
||||
stroke="var(--chart-1)"
|
||||
fill="url(#cookieBotHoursGradient)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
type="linear"
|
||||
dataKey="interactive"
|
||||
stackId="1"
|
||||
stroke="var(--chart-2)"
|
||||
fill="url(#interactiveHoursGradient)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-2.5 rounded"
|
||||
style={{ backgroundColor: "var(--chart-1)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("cookieBot.team.legendBot")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-2.5 rounded"
|
||||
style={{ backgroundColor: "var(--chart-2)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("cookieBot.team.legendInteractive")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isSolo && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("cookieBot.team.soloNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-md border border-border">
|
||||
<div className="grid grid-cols-[1fr_auto_auto_5rem] items-center gap-3 border-b border-border bg-muted/40 px-3 py-1.5 text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
<span>{t("cookieBot.team.columnMember")}</span>
|
||||
<span className="text-right">
|
||||
{t("cookieBot.team.columnRuns")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("cookieBot.team.columnHours")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("cookieBot.team.columnShare")}
|
||||
</span>
|
||||
</div>
|
||||
{members.map((member, index) => (
|
||||
// The ranking genuinely re-orders when the period changes, so the
|
||||
// rows travel to their new places instead of teleporting. Layout
|
||||
// only — the row is fully rendered and readable on first paint.
|
||||
<AnimatedDisclosureItem
|
||||
key={member.email}
|
||||
className="grid grid-cols-[1fr_auto_auto_5rem] items-center gap-3 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate",
|
||||
index === 0 ? "font-medium text-foreground" : "",
|
||||
)}
|
||||
title={member.email}
|
||||
>
|
||||
{member.email}
|
||||
</span>
|
||||
{/* The failure count is already on the wire and answers the
|
||||
question the run count cannot: whether the hours bought
|
||||
anything. */}
|
||||
<span className="text-right tabular-nums text-muted-foreground">
|
||||
{member.runs}
|
||||
{member.runsFailed > 0 && (
|
||||
<span className="ml-1 text-warning-text">
|
||||
{t("cookieBot.team.runsFailed", {
|
||||
n: member.runsFailed,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-right tabular-nums",
|
||||
index === 0
|
||||
? "font-semibold text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t("cookieBot.team.hours", {
|
||||
hours: formatHours(member.total),
|
||||
})}
|
||||
</span>
|
||||
<span className="h-1 overflow-hidden rounded-full bg-muted">
|
||||
{/* Radius on the track, scale on the fill: a rounded cap that
|
||||
is being scaled flips shape mid-transition. `initial=
|
||||
{false}` keeps the first paint at the true share. */}
|
||||
<motion.span
|
||||
initial={false}
|
||||
animate={{
|
||||
scaleX: heaviest > 0 ? member.total / heaviest : 0,
|
||||
}}
|
||||
transition={
|
||||
reduceMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.22, ease: MOTION_EASE_OUT }
|
||||
}
|
||||
style={{ transformOrigin: "left", willChange: "transform" }}
|
||||
className={cn(
|
||||
"block h-full w-full rounded-full",
|
||||
index === 0 ? "bg-foreground" : "bg-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</AnimatedDisclosureItem>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user