diff --git a/skyvern-frontend/src/components/HelpTooltip.tsx b/skyvern-frontend/src/components/HelpTooltip.tsx index 9b2c6cb2..43596dc7 100644 --- a/skyvern-frontend/src/components/HelpTooltip.tsx +++ b/skyvern-frontend/src/components/HelpTooltip.tsx @@ -7,14 +7,15 @@ import { } from "./ui/tooltip"; type Props = { + className?: string; content: string; }; -function HelpTooltip({ content }: Props) { +function HelpTooltip({ content, className }: Props) { return ( - + {content} diff --git a/skyvern-frontend/src/router.tsx b/skyvern-frontend/src/router.tsx index 0d8cf07d..cbf15c21 100644 --- a/skyvern-frontend/src/router.tsx +++ b/skyvern-frontend/src/router.tsx @@ -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"; diff --git a/skyvern-frontend/src/routes/browserSession/BrowserSession.tsx b/skyvern-frontend/src/routes/browserSession/BrowserSession.tsx deleted file mode 100644 index 7d6bd669..00000000 --- a/skyvern-frontend/src/routes/browserSession/BrowserSession.tsx +++ /dev/null @@ -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("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 ( -
-
- {/* we need nice artwork here */} - Loading... -
-
- ); - } - - if (!hasBrowserSession) { - return ( -
-
- {/* we need nice artwork here */} - No browser session found. -
-
- ); - } - - return ( -
-
-
-
- -
browser session
-
-
- - {/* Tab Navigation */} - 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 */} -
-
- -
- {activeTab === "videos" && } -
-
-
- ); -} - -export { BrowserSession }; diff --git a/skyvern-frontend/src/routes/browserSessions/BrowserSession.tsx b/skyvern-frontend/src/routes/browserSessions/BrowserSession.tsx new file mode 100644 index 00000000..da0b0207 --- /dev/null +++ b/skyvern-frontend/src/routes/browserSessions/BrowserSession.tsx @@ -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(null); + const [activeTab, setActiveTab] = useState("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( + `/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 ( +
+
+ {/* we need nice artwork here */} + Loading... +
+
+ ); + } + + if (!hasBrowserSession) { + return ( +
+
+ {/* we need nice artwork here */} + No browser session found. +
+
+ ); + } + + return ( +
+
+
+
+ +
Browser Session
+ {browserSession && ( +
+
+
+ {browserSession.browser_session_id} +
+ +
+ {browserSession.browser_address && ( +
+
+ {browserSession.browser_address} +
+ +
+ )} +
+ )} +
+
+ + {/* Tab Navigation */} +
+ 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 && ( + + + + + + + Are you sure? + + Are you sure you want to stop (shut down) this browser + session? + + + + + + + + + + + )} +
+ + {/* Tab Content */} +
+
+ +
+ {activeTab === "videos" && } +
+
+ +
+ ); +} + +export { BrowserSession }; diff --git a/skyvern-frontend/src/routes/browserSession/BrowserSessionVideo.tsx b/skyvern-frontend/src/routes/browserSessions/BrowserSessionVideo.tsx similarity index 100% rename from skyvern-frontend/src/routes/browserSession/BrowserSessionVideo.tsx rename to skyvern-frontend/src/routes/browserSessions/BrowserSessionVideo.tsx diff --git a/skyvern-frontend/src/routes/browserSessions/BrowserSessions.tsx b/skyvern-frontend/src/routes/browserSessions/BrowserSessions.tsx new file mode 100644 index 00000000..57c4613e --- /dev/null +++ b/skyvern-frontend/src/routes/browserSessions/BrowserSessions.tsx @@ -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) { + 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 ( +
+ {/* header */} +
+
+ +

Browsers

+
+

+ Create your own live browsers to interact with websites, or run + workflows in. +

+
+ + {/* browsers */} +
+
+
+ +
+
+
+ + + + + ID + + + Open + + + Occupied + + + + Started + + + Timeout + + + CDP Url + + + + + + {isLoading ? ( + + Loading... + + ) : browserSessions?.length === 0 ? ( + + No browser sessions found + + ) : ( + browserSessions?.map((browserSession) => { + const isOpen = sessionIsOpen(browserSession); + const startedAtDate = toDate( + browserSession.started_at ?? "", + null, + ); + const ago = startedAtDate ? ( + formatMs(Date.now() - startedAtDate.getTime()).ago + ) : ( + never + ); + const cdpUrl = + browserSession.browser_address ?? + "wss://session-staging.skyvern.com/pbs_442960015326262218/devtools/browser/f01f27e1-182b-4a33-9017-4c1146d3eb3e"; + + return ( + + +
+
+ {browserSession.browser_session_id} +
+ +
+
+ + {isOpen ? ( + "Yes" + ) : ( + No + )} + + + {browserSession.runnable_id ? ( + "Yes" + ) : ( + No + )} + + + {ago} + + + {browserSession.timeout + ? `${browserSession.timeout}m` + : "-"} + + +
+
+ {cdpUrl} +
+ {cdpUrl !== "-" ? ( + + ) : null} +
+
+ { + handleRowClick(browserSession.browser_session_id); + }} + > + + +
+ ); + }) + )} +
+
+
+
+ Items per page + +
+ + + + + + + {page} + + + + + + +
+
+
+
+ ); +} + +export { BrowserSessions }; diff --git a/skyvern-frontend/src/routes/browserSessions/hooks/useBrowserSessionsQuery.ts b/skyvern-frontend/src/routes/browserSessions/hooks/useBrowserSessionsQuery.ts new file mode 100644 index 00000000..ef6cbdc4 --- /dev/null +++ b/skyvern-frontend/src/routes/browserSessions/hooks/useBrowserSessionsQuery.ts @@ -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({ + 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 }; diff --git a/skyvern-frontend/src/routes/browserSessions/hooks/useCloseBrowserSessionMutation.ts b/skyvern-frontend/src/routes/browserSessions/hooks/useCloseBrowserSessionMutation.ts new file mode 100644 index 00000000..9f42cb19 --- /dev/null +++ b/skyvern-frontend/src/routes/browserSessions/hooks/useCloseBrowserSessionMutation.ts @@ -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 }; diff --git a/skyvern-frontend/src/routes/browserSessions/hooks/useCreateBrowserSessionMutation.ts b/skyvern-frontend/src/routes/browserSessions/hooks/useCreateBrowserSessionMutation.ts new file mode 100644 index 00000000..610d6bb1 --- /dev/null +++ b/skyvern-frontend/src/routes/browserSessions/hooks/useCreateBrowserSessionMutation.ts @@ -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( + "/browser_sessions", + { + proxy_location: proxyLocation, + timeout, + }, + ); + }, + onSuccess: (response) => { + queryClient.invalidateQueries({ + queryKey: ["browser_sessions"], + }); + navigate(`/browser-session/${response.data.browser_session_id}`); + }, + }); +} + +export { useCreateBrowserSessionMutation }; diff --git a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx index 4c733b6d..abaa7364 100644 --- a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx @@ -108,7 +108,7 @@ interface Dom { splitLeft: MutableRefObject; } -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 ( - ); diff --git a/skyvern-frontend/src/routes/workflows/types/browserSessionTypes.ts b/skyvern-frontend/src/routes/workflows/types/browserSessionTypes.ts new file mode 100644 index 00000000..67f78ef4 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/types/browserSessionTypes.ts @@ -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 };