feat: cookie bot

This commit is contained in:
zhom
2026-08-03 07:57:45 +04:00
parent 04b9617631
commit 7d82a25107
46 changed files with 15059 additions and 148 deletions
+100 -2
View File
@@ -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
+2
View File
@@ -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,
+583
View File
@@ -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>
);
}
+923
View File
@@ -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,
};
}
+575
View File
@@ -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()}`;
}
+604
View File
@@ -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>
);
}
+237
View File
@@ -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>
);
}
+264
View File
@@ -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;
}
+821
View File
@@ -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]);
}
+586 -8
View File
@@ -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}
/>
</>
);
}
+1 -2
View File
@@ -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;
};
+21 -1
View File
@@ -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>
);
})}
+470
View File
@@ -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>
);
}