Add Browsers (browser management) to cloud app (#3549)
This commit is contained in:
@@ -7,14 +7,15 @@ import {
|
|||||||
} from "./ui/tooltip";
|
} from "./ui/tooltip";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
className?: string;
|
||||||
content: string;
|
content: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function HelpTooltip({ content }: Props) {
|
function HelpTooltip({ content, className }: Props) {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={300}>
|
<Tooltip delayDuration={300}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger className={className} asChild>
|
||||||
<QuestionMarkCircledIcon className="size-4" />
|
<QuestionMarkCircledIcon className="size-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-[250px]">{content}</TooltipContent>
|
<TooltipContent className="max-w-[250px]">{content}</TooltipContent>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Navigate, Outlet, createBrowserRouter } from "react-router-dom";
|
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 { PageLayout } from "./components/PageLayout";
|
||||||
import { DiscoverPage } from "./routes/discover/DiscoverPage";
|
import { DiscoverPage } from "./routes/discover/DiscoverPage";
|
||||||
import { HistoryPage } from "./routes/history/HistoryPage";
|
import { HistoryPage } from "./routes/history/HistoryPage";
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
207
skyvern-frontend/src/routes/browserSessions/BrowserSession.tsx
Normal file
207
skyvern-frontend/src/routes/browserSessions/BrowserSession.tsx
Normal 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 };
|
||||||
324
skyvern-frontend/src/routes/browserSessions/BrowserSessions.tsx
Normal file
324
skyvern-frontend/src/routes/browserSessions/BrowserSessions.tsx
Normal 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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -108,7 +108,7 @@ interface Dom {
|
|||||||
splitLeft: MutableRefObject<HTMLInputElement | null>;
|
splitLeft: MutableRefObject<HTMLInputElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CopyText({ text }: { text: string }) {
|
function CopyText({ className, text }: { className?: string; text: string }) {
|
||||||
const [wasCopied, setWasCopied] = useState(false);
|
const [wasCopied, setWasCopied] = useState(false);
|
||||||
|
|
||||||
function handleCopy(code: string) {
|
function handleCopy(code: string) {
|
||||||
@@ -118,7 +118,15 @@ function CopyText({ text }: { text: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 />}
|
{wasCopied ? <CheckIcon /> : <CopyIcon />}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user