Add Browsers (browser management) to cloud app (#3549)

This commit is contained in:
Jonathan Dobson
2025-09-29 14:05:30 -04:00
committed by GitHub
parent f2d32da914
commit e04f81fcda
11 changed files with 687 additions and 119 deletions

View File

@@ -7,14 +7,15 @@ import {
} from "./ui/tooltip";
type Props = {
className?: string;
content: string;
};
function HelpTooltip({ content }: Props) {
function HelpTooltip({ content, className }: Props) {
return (
<TooltipProvider>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<TooltipTrigger className={className} asChild>
<QuestionMarkCircledIcon className="size-4" />
</TooltipTrigger>
<TooltipContent className="max-w-[250px]">{content}</TooltipContent>

View File

@@ -1,5 +1,5 @@
import { Navigate, Outlet, createBrowserRouter } from "react-router-dom";
import { BrowserSession } from "@/routes/browserSession/BrowserSession";
import { BrowserSession } from "@/routes/browserSessions/BrowserSession";
import { PageLayout } from "./components/PageLayout";
import { DiscoverPage } from "./routes/discover/DiscoverPage";
import { HistoryPage } from "./routes/history/HistoryPage";

View File

@@ -1,114 +0,0 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { BrowserStream } from "@/components/BrowserStream";
import { LogoMinimized } from "@/components/LogoMinimized";
import { SwitchBar } from "@/components/SwitchBar";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { BrowserSessionVideo } from "./BrowserSessionVideo";
import { cn } from "@/util/utils";
type TabName = "stream" | "videos";
function BrowserSession() {
const { browserSessionId } = useParams();
const [hasBrowserSession, setHasBrowserSession] = useState(false);
const [activeTab, setActiveTab] = useState<TabName>("stream");
const credentialGetter = useCredentialGetter();
const query = useQuery({
queryKey: ["browserSession", browserSessionId],
queryFn: async () => {
const client = await getClient(credentialGetter, "sans-api-v1");
try {
const response = await client.get(
`/browser_sessions/${browserSessionId}`,
);
setHasBrowserSession(true);
return response.data;
} catch (error) {
setHasBrowserSession(false);
return null;
}
},
});
if (query.isLoading) {
return (
<div className="h-screen w-full gap-4 p-6">
<div className="flex h-full w-full items-center justify-center">
{/* we need nice artwork here */}
Loading...
</div>
</div>
);
}
if (!hasBrowserSession) {
return (
<div className="h-screen w-full gap-4 p-6">
<div className="flex h-full w-full items-center justify-center">
{/* we need nice artwork here */}
No browser session found.
</div>
</div>
);
}
return (
<div className="h-screen w-full gap-4 p-6">
<div className="flex h-full w-full flex-col items-start justify-start gap-2">
<div className="flex w-full flex-shrink-0 flex-row items-center justify-between rounded-lg border p-4">
<div className="flex flex-row items-center justify-start gap-2">
<LogoMinimized />
<div className="text-xl">browser session</div>
</div>
</div>
{/* Tab Navigation */}
<SwitchBar
className="mb-2 border-none"
onChange={(value) => setActiveTab(value as TabName)}
value={activeTab}
options={[
{
label: "Stream",
value: "stream",
helpText: "The live stream of the browser session (if active).",
},
{
label: "Recordings",
value: "videos",
helpText: "All recordings of this browser session.",
},
]}
/>
{/* Tab Content */}
<div className="relative min-h-0 w-full flex-1 rounded-lg border p-4">
<div
className={cn(
"absolute left-0 top-0 z-10 flex h-full w-full items-center justify-center",
{
hidden: activeTab !== "stream",
},
)}
>
<BrowserStream
browserSessionId={browserSessionId}
interactive={false}
showControlButtons={true}
/>
</div>
{activeTab === "videos" && <BrowserSessionVideo />}
</div>
</div>
</div>
);
}
export { BrowserSession };

View File

@@ -0,0 +1,207 @@
import { ReloadIcon, StopIcon } from "@radix-ui/react-icons";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { BrowserStream } from "@/components/BrowserStream";
import { LogoMinimized } from "@/components/LogoMinimized";
import { SwitchBar } from "@/components/SwitchBar";
import { Toaster } from "@/components/ui/toaster";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useCloseBrowserSessionMutation } from "@/routes/browserSessions/hooks/useCloseBrowserSessionMutation";
import { CopyText } from "@/routes/workflows/editor/Workspace";
import { type BrowserSession as BrowserSessionType } from "@/routes/workflows/types/browserSessionTypes";
import { cn } from "@/util/utils";
import { BrowserSessionVideo } from "./BrowserSessionVideo";
type TabName = "stream" | "videos";
function BrowserSession() {
const { browserSessionId } = useParams();
const [hasBrowserSession, setHasBrowserSession] = useState(false);
const [browserSession, setBrowserSession] =
useState<BrowserSessionType | null>(null);
const [activeTab, setActiveTab] = useState<TabName>("stream");
const [isDialogOpen, setIsDialogOpen] = useState(false);
const credentialGetter = useCredentialGetter();
const query = useQuery({
queryKey: ["browserSession", browserSessionId],
queryFn: async () => {
const client = await getClient(credentialGetter, "sans-api-v1");
try {
const response = await client.get<BrowserSessionType>(
`/browser_sessions/${browserSessionId}`,
);
setHasBrowserSession(true);
setBrowserSession(response.data);
return response.data;
} catch (error) {
setHasBrowserSession(false);
setBrowserSession(null);
return null;
}
},
});
const closeBrowserSessionMutation = useCloseBrowserSessionMutation({
browserSessionId,
onSuccess: () => {
setIsDialogOpen(false);
},
});
if (query.isLoading) {
return (
<div className="h-screen w-full gap-4 p-6">
<div className="flex h-full w-full items-center justify-center">
{/* we need nice artwork here */}
Loading...
</div>
</div>
);
}
if (!hasBrowserSession) {
return (
<div className="h-screen w-full gap-4 p-6">
<div className="flex h-full w-full items-center justify-center">
{/* we need nice artwork here */}
No browser session found.
</div>
</div>
);
}
return (
<div className="h-screen w-full gap-4 p-6">
<div className="flex h-full w-full flex-col items-start justify-start gap-2">
<div className="flex w-full flex-shrink-0 flex-row items-center justify-between rounded-lg border p-4">
<div className="flex w-full flex-row items-center justify-start gap-2">
<LogoMinimized />
<div className="text-xl">Browser Session</div>
{browserSession && (
<div className="ml-auto flex flex-col items-end justify-end overflow-hidden">
<div className="flex items-center justify-end">
<div className="max-w-[20rem] truncate font-mono text-xs opacity-75">
{browserSession.browser_session_id}
</div>
<CopyText
className="opacity-75 hover:opacity-100"
text={browserSession.browser_session_id}
/>
</div>
{browserSession.browser_address && (
<div className="flex items-center justify-end">
<div className="max-w-[20rem] truncate font-mono text-xs opacity-75">
{browserSession.browser_address}
</div>
<CopyText
className="opacity-75 hover:opacity-100"
text={browserSession.browser_address}
/>
</div>
)}
</div>
)}
</div>
</div>
{/* Tab Navigation */}
<div className="flex w-full items-center justify-start gap-2">
<SwitchBar
className="border-none"
onChange={(value) => setActiveTab(value as TabName)}
value={activeTab}
options={[
{
label: "Stream",
value: "stream",
helpText: "The live stream of the browser session (if active).",
},
{
label: "Recordings",
value: "videos",
helpText: "All recordings of this browser session.",
},
]}
/>
{browserSessionId && (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="ml-auto" variant="secondary">
<StopIcon className="mr-2 h-4 w-4" />
Stop
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
Are you sure you want to stop (shut down) this browser
session?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Back</Button>
</DialogClose>
<Button
variant="destructive"
onClick={() => {
closeBrowserSessionMutation.mutate();
}}
disabled={closeBrowserSessionMutation.isPending}
>
{closeBrowserSessionMutation.isPending && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}
Stop Browser Session
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
{/* Tab Content */}
<div className="relative min-h-0 w-full flex-1 rounded-lg border p-4">
<div
className={cn(
"absolute left-0 top-0 z-10 flex h-full w-full items-center justify-center",
{
hidden: activeTab !== "stream",
},
)}
>
<BrowserStream
browserSessionId={browserSessionId}
interactive={false}
showControlButtons={true}
/>
</div>
{activeTab === "videos" && <BrowserSessionVideo />}
</div>
</div>
<Toaster />
</div>
);
}
export { BrowserSession };

View File

@@ -0,0 +1,324 @@
import {
ExternalLinkIcon,
GlobeIcon,
PlusIcon,
ReloadIcon,
} from "@radix-ui/react-icons";
import { useSearchParams } from "react-router-dom";
import { HelpTooltip } from "@/components/HelpTooltip";
import { Button } from "@/components/ui/button";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useBrowserSessionsQuery } from "@/routes/browserSessions/hooks/useBrowserSessionsQuery";
import { useCreateBrowserSessionMutation } from "@/routes/browserSessions/hooks/useCreateBrowserSessionMutation";
import { type BrowserSession } from "@/routes/workflows/types/browserSessionTypes";
import { CopyText } from "@/routes/workflows/editor/Workspace";
import { basicTimeFormat } from "@/util/timeFormat";
import { cn, formatMs } from "@/util/utils";
function toDate(
time: string,
defaultDate: Date | null = new Date(0),
): Date | null {
time = time.replace(/\.(\d{3})\d*/, ".$1");
if (!time.endsWith("Z")) {
time += "Z";
}
const date = new Date(time);
if (isNaN(date.getTime())) {
return defaultDate;
}
return date;
}
function sessionIsOpen(browserSession: BrowserSession): boolean {
return (
browserSession.completed_at === null && browserSession.started_at !== null
);
}
function BrowserSessions() {
const [searchParams, setSearchParams] = useSearchParams();
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
const itemsPerPage = searchParams.get("page_size")
? Number(searchParams.get("page_size"))
: 10;
function setParamPatch(patch: Record<string, string>) {
const params = new URLSearchParams(searchParams);
Object.entries(patch).forEach(([k, v]) => params.set(k, v));
setSearchParams(params, { replace: true });
}
function handlePreviousPage() {
if (page === 1) return;
setParamPatch({ page: String(page - 1) });
}
function handleNextPage() {
if (isNextDisabled) return;
setParamPatch({ page: String(page + 1) });
}
const createBrowserSessionMutation = useCreateBrowserSessionMutation();
const { data: browserSessions = [], isLoading } = useBrowserSessionsQuery(
page,
itemsPerPage,
);
const { data: nextPageBrowserSessions } = useBrowserSessionsQuery(
page + 1,
itemsPerPage,
);
const isNextDisabled =
isLoading ||
!nextPageBrowserSessions ||
nextPageBrowserSessions.length === 0;
function handleRowClick(browserSessionId: string) {
window.open(
window.location.origin + `/browser-session/${browserSessionId}`,
"_blank",
"noopener,noreferrer",
);
}
return (
<div className="px-8">
{/* header */}
<div className="space-y-5">
<div className="flex items-center gap-2">
<GlobeIcon className="size-6" />
<h1 className="text-2xl">Browsers</h1>
</div>
<p className="text-slate-300">
Create your own live browsers to interact with websites, or run
workflows in.
</p>
</div>
{/* browsers */}
<div className="mt-6 space-y-4">
<div className="flex justify-end">
<div className="flex gap-4">
<Button
disabled={createBrowserSessionMutation.isPending}
onClick={() => {
createBrowserSessionMutation.mutate({
proxyLocation: null,
timeout: null,
});
}}
>
{createBrowserSessionMutation.isPending ? (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
) : (
<PlusIcon className="mr-2 h-4 w-4" />
)}
Create
</Button>
</div>
</div>
<div className="rounded-lg border">
<Table className="w-full table-fixed">
<TableHeader className="rounded-t-lg bg-slate-elevation2">
<TableRow>
<TableHead className="w-1/4 truncate rounded-tl-lg text-slate-400">
ID
</TableHead>
<TableHead className="w-1/12 truncate text-slate-400">
Open
</TableHead>
<TableHead className="w-1/6 truncate text-slate-400">
<span className="mr-2">Occupied</span>
<HelpTooltip
className="inline"
content="Browser is busy running a task or workflow"
/>
</TableHead>
<TableHead className="w-1/6 truncate text-slate-400">
Started
</TableHead>
<TableHead className="w-1/6 truncate text-slate-400">
Timeout
</TableHead>
<TableHead className="w-1/2 truncate text-slate-400">
CDP Url
</TableHead>
<TableHead className="w-[2.5rem] rounded-tr-lg"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={6}>Loading...</TableCell>
</TableRow>
) : browserSessions?.length === 0 ? (
<TableRow>
<TableCell colSpan={6}>No browser sessions found</TableCell>
</TableRow>
) : (
browserSessions?.map((browserSession) => {
const isOpen = sessionIsOpen(browserSession);
const startedAtDate = toDate(
browserSession.started_at ?? "",
null,
);
const ago = startedAtDate ? (
formatMs(Date.now() - startedAtDate.getTime()).ago
) : (
<span className="opacity-50">never</span>
);
const cdpUrl =
browserSession.browser_address ??
"wss://session-staging.skyvern.com/pbs_442960015326262218/devtools/browser/f01f27e1-182b-4a33-9017-4c1146d3eb3e";
return (
<TableRow key={browserSession.browser_session_id}>
<TableCell>
<div className="flex items-center">
<div className="flex-1 truncate opacity-75">
{browserSession.browser_session_id}
</div>
<CopyText
className="opacity-75 hover:opacity-100"
text={browserSession.browser_session_id}
/>
</div>
</TableCell>
<TableCell>
{isOpen ? (
"Yes"
) : (
<span className="opacity-50">No</span>
)}
</TableCell>
<TableCell>
{browserSession.runnable_id ? (
"Yes"
) : (
<span className="opacity-50">No</span>
)}
</TableCell>
<TableCell
title={
browserSession.started_at
? basicTimeFormat(browserSession.started_at)
: "not started"
}
>
{ago}
</TableCell>
<TableCell>
{browserSession.timeout
? `${browserSession.timeout}m`
: "-"}
</TableCell>
<TableCell>
<div className="flex items-center">
<div className="flex-1 truncate opacity-75">
{cdpUrl}
</div>
{cdpUrl !== "-" ? (
<CopyText
className="opacity-75 hover:opacity-100"
text={cdpUrl}
/>
) : null}
</div>
</TableCell>
<TableCell
className="cursor-pointer"
onClick={() => {
handleRowClick(browserSession.browser_session_id);
}}
>
<ExternalLinkIcon className="inline size-4" />
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
<div className="relative px-3 py-3">
<div className="absolute left-3 top-1/2 flex -translate-y-1/2 items-center gap-2 text-sm">
<span className="text-slate-400">Items per page</span>
<select
className="h-9 rounded-md border border-slate-300 bg-background"
value={itemsPerPage}
onChange={(e) => {
const next = Number(e.target.value);
const params = new URLSearchParams(searchParams);
params.set("page_size", String(next));
params.set("page", "1");
setSearchParams(params, { replace: true });
}}
>
<option className="px-3" value={5}>
5
</option>
<option className="px-3" value={10}>
10
</option>
<option className="px-3" value={20}>
20
</option>
<option className="px-3" value={50}>
50
</option>
</select>
</div>
<Pagination className="pt-0">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
className={cn({
"cursor-not-allowed opacity-50": page === 1,
})}
onClick={handlePreviousPage}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink>{page}</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
className={cn({
"cursor-not-allowed opacity-50": isNextDisabled,
})}
onClick={handleNextPage}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
</div>
</div>
);
}
export { BrowserSessions };

View File

@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { type BrowserSession } from "@/routes/workflows/types/browserSessionTypes";
const useBrowserSessionsQuery = (page: number, itemsPerPage: number) => {
const credentialGetter = useCredentialGetter();
return useQuery<BrowserSession[]>({
queryKey: ["browser_sessions", page, itemsPerPage],
queryFn: async () => {
const client = await getClient(credentialGetter, "sans-api-v1");
const params = new URLSearchParams();
params.append("page", String(page));
params.append("page_size", String(itemsPerPage));
return client
.get(`/browser_sessions/history`, {
params,
})
.then((response) => response.data);
},
});
};
export { useBrowserSessionsQuery };

View File

@@ -0,0 +1,56 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { toast } from "@/components/ui/use-toast";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
const useCloseBrowserSessionMutation = ({
browserSessionId,
onSuccess,
}: {
browserSessionId?: string;
onSuccess?: () => void;
}) => {
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
const closeBrowserSessionMutation = useMutation({
mutationFn: async () => {
if (!browserSessionId) {
console.warn("No browserSessionId provided for close mutation");
return;
}
const client = await getClient(credentialGetter, "sans-api-v1");
return client
.post(`/browser_sessions/${browserSessionId}/close`)
.then((response) => response.data);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["browserSessions"],
});
queryClient.invalidateQueries({
queryKey: ["browserSession", browserSessionId],
});
toast({
variant: "success",
title: "Browser Session Closed",
description: "The browser session has been successfully closed.",
});
onSuccess?.();
},
onError: (error) => {
toast({
variant: "destructive",
title: "Error",
description: error.message,
});
},
});
return closeBrowserSessionMutation;
};
export { useCloseBrowserSessionMutation };

View File

@@ -0,0 +1,40 @@
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { BrowserSession } from "@/routes/workflows/types/browserSessionTypes";
function useCreateBrowserSessionMutation() {
const queryClient = useQueryClient();
const credentialGetter = useCredentialGetter();
const navigate = useNavigate();
return useMutation({
mutationFn: async ({
proxyLocation = null,
timeout = null,
}: {
proxyLocation: string | null;
timeout: number | null;
}) => {
const client = await getClient(credentialGetter, "sans-api-v1");
return client.post<string, { data: BrowserSession }>(
"/browser_sessions",
{
proxy_location: proxyLocation,
timeout,
},
);
},
onSuccess: (response) => {
queryClient.invalidateQueries({
queryKey: ["browser_sessions"],
});
navigate(`/browser-session/${response.data.browser_session_id}`);
},
});
}
export { useCreateBrowserSessionMutation };

View File

@@ -108,7 +108,7 @@ interface Dom {
splitLeft: MutableRefObject<HTMLInputElement | null>;
}
function CopyText({ text }: { text: string }) {
function CopyText({ className, text }: { className?: string; text: string }) {
const [wasCopied, setWasCopied] = useState(false);
function handleCopy(code: string) {
@@ -118,7 +118,15 @@ function CopyText({ text }: { text: string }) {
}
return (
<Button size="icon" variant="link" onClick={() => handleCopy(text)}>
<Button
className={className}
size="icon"
variant="link"
onClick={(e) => {
e.stopPropagation();
handleCopy(text);
}}
>
{wasCopied ? <CheckIcon /> : <CopyIcon />}
</Button>
);

View File

@@ -0,0 +1,20 @@
interface BrowserSession {
browser_address: string | null;
browser_session_id: string;
completed_at: string | null;
recordings: Recording[];
runnable_id: string | null;
runnable_type: string | null;
started_at: string | null;
timeout: number | null;
vnc_streaming_supported: boolean;
}
interface Recording {
url: string;
checksum: string;
filename: string;
modified_at: string;
}
export { type BrowserSession, type Recording };