feat: Running tasks and steps UI (#165)
This commit is contained in:
40
skyvern-frontend/src/routes/tasks/detail/JSONArtifact.tsx
Normal file
40
skyvern-frontend/src/routes/tasks/detail/JSONArtifact.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { artifactApiClient } from "@/api/AxiosClient";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
type Props = {
|
||||
uri: string;
|
||||
};
|
||||
|
||||
function JSONArtifact({ uri }: Props) {
|
||||
const { data, isFetching, isError, error } = useQuery<
|
||||
Record<string, unknown>
|
||||
>({
|
||||
queryKey: ["artifact", uri],
|
||||
queryFn: async () => {
|
||||
return artifactApiClient
|
||||
.get(`/artifact/json`, {
|
||||
params: {
|
||||
path: uri.slice(7),
|
||||
},
|
||||
})
|
||||
.then((response) => response.data);
|
||||
},
|
||||
});
|
||||
|
||||
if (isFetching) {
|
||||
return <Skeleton className="w-full h-48" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
className="w-full"
|
||||
rows={15}
|
||||
value={isError ? JSON.stringify(error) : JSON.stringify(data, null, 2)}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { JSONArtifact };
|
||||
205
skyvern-frontend/src/routes/tasks/detail/StepArtifacts.tsx
Normal file
205
skyvern-frontend/src/routes/tasks/detail/StepArtifacts.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { client } from "@/api/AxiosClient";
|
||||
import {
|
||||
ArtifactApiResponse,
|
||||
ArtifactType,
|
||||
StepApiResponse,
|
||||
} from "@/api/types";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { artifactApiBaseUrl } from "@/util/env";
|
||||
import { ZoomableImage } from "@/components/ZoomableImage";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { JSONArtifact } from "./JSONArtifact";
|
||||
import { TextArtifact } from "./TextArtifact";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
stepProps: StepApiResponse;
|
||||
};
|
||||
|
||||
function StepArtifacts({ id, stepProps }: Props) {
|
||||
const { taskId } = useParams();
|
||||
const {
|
||||
data: artifacts,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<Array<ArtifactApiResponse>>({
|
||||
queryKey: ["task", taskId, "steps", id, "artifacts"],
|
||||
queryFn: async () => {
|
||||
return client
|
||||
.get(`/tasks/${taskId}/steps/${id}/artifacts`)
|
||||
.then((response) => response.data);
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return <div>Error: {error?.message}</div>;
|
||||
}
|
||||
|
||||
const llmScreenshotUris = artifacts
|
||||
?.filter(
|
||||
(artifact) => artifact.artifact_type === ArtifactType.LLMScreenshot,
|
||||
)
|
||||
.map((artifact) => artifact.uri);
|
||||
|
||||
const actionScreenshotUris = artifacts
|
||||
?.filter(
|
||||
(artifact) => artifact.artifact_type === ArtifactType.ActionScreenshot,
|
||||
)
|
||||
.map((artifact) => artifact.uri);
|
||||
|
||||
const visibleElementsTreeUri = artifacts?.find(
|
||||
(artifact) => artifact.artifact_type === ArtifactType.VisibleElementsTree,
|
||||
)?.uri;
|
||||
|
||||
const visibleElementsTreeTrimmedUri = artifacts?.find(
|
||||
(artifact) =>
|
||||
artifact.artifact_type === ArtifactType.VisibleElementsTreeTrimmed,
|
||||
)?.uri;
|
||||
|
||||
const llmPromptUri = artifacts?.find(
|
||||
(artifact) => artifact.artifact_type === ArtifactType.LLMPrompt,
|
||||
)?.uri;
|
||||
|
||||
const llmRequestUri = artifacts?.find(
|
||||
(artifact) => artifact.artifact_type === ArtifactType.LLMRequest,
|
||||
)?.uri;
|
||||
|
||||
const llmResponseRawUri = artifacts?.find(
|
||||
(artifact) => artifact.artifact_type === ArtifactType.LLMResponseRaw,
|
||||
)?.uri;
|
||||
|
||||
const llmResponseParsedUri = artifacts?.find(
|
||||
(artifact) => artifact.artifact_type === ArtifactType.LLMResponseParsed,
|
||||
)?.uri;
|
||||
|
||||
const htmlRawUri = artifacts?.find(
|
||||
(artifact) => artifact.artifact_type === ArtifactType.HTMLScrape,
|
||||
)?.uri;
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="info" className="w-full">
|
||||
<TabsList className="grid w-full h-16 grid-cols-5">
|
||||
<TabsTrigger value="info">Info</TabsTrigger>
|
||||
<TabsTrigger value="screenshot_llm">LLM Screenshots</TabsTrigger>
|
||||
<TabsTrigger value="screenshot_action">Action Screenshots</TabsTrigger>
|
||||
<TabsTrigger value="element_tree">Element Tree</TabsTrigger>
|
||||
<TabsTrigger value="element_tree_trimmed">
|
||||
Element Tree (Trimmed)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="llm_prompt">LLM Prompt</TabsTrigger>
|
||||
<TabsTrigger value="llm_request">LLM Request</TabsTrigger>
|
||||
<TabsTrigger value="llm_response_raw">LLM Response (Raw)</TabsTrigger>
|
||||
<TabsTrigger value="llm_response_parsed">
|
||||
LLM Response (Parsed)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="html_raw">HTML (Raw)</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="info">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex items-center">
|
||||
<Label className="w-24">Step ID:</Label>
|
||||
{isFetching ? (
|
||||
<Skeleton className="h-4 w-40" />
|
||||
) : (
|
||||
<span>{stepProps?.step_id}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Label className="w-24">Status:</Label>
|
||||
{isFetching ? (
|
||||
<Skeleton className="h-4 w-40" />
|
||||
) : stepProps ? (
|
||||
<StatusBadge status={stepProps.status} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Label className="w-24">Created At:</Label>
|
||||
{isFetching ? (
|
||||
<Skeleton className="h-4 w-40" />
|
||||
) : stepProps ? (
|
||||
<span>{stepProps.created_at}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="screenshot_llm">
|
||||
{llmScreenshotUris && llmScreenshotUris.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-4 p-4">
|
||||
{llmScreenshotUris.map((uri, index) => (
|
||||
<ZoomableImage
|
||||
key={index}
|
||||
src={`${artifactApiBaseUrl}/artifact/image?path=${uri.slice(7)}`}
|
||||
className="object-cover w-full h-full"
|
||||
alt="action-screenshot"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : isFetching ? (
|
||||
<div className="grid grid-cols-3 gap-4 p-4">
|
||||
<Skeleton className="w-full h-full" />
|
||||
<Skeleton className="w-full h-full" />
|
||||
<Skeleton className="w-full h-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div>No screenshots found</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="screenshot_action">
|
||||
{actionScreenshotUris && actionScreenshotUris.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-4 p-4">
|
||||
{actionScreenshotUris.map((uri, index) => (
|
||||
<ZoomableImage
|
||||
key={index}
|
||||
src={`${artifactApiBaseUrl}/artifact/image?path=${uri.slice(7)}`}
|
||||
className="object-cover w-full h-full"
|
||||
alt="action-screenshot"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : isFetching ? (
|
||||
<div className="grid grid-cols-3 gap-4 p-4">
|
||||
<Skeleton className="w-full h-full" />
|
||||
<Skeleton className="w-full h-full" />
|
||||
<Skeleton className="w-full h-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div>No screenshots found</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="element_tree">
|
||||
{visibleElementsTreeUri ? (
|
||||
<JSONArtifact uri={visibleElementsTreeUri} />
|
||||
) : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="element_tree_trimmed">
|
||||
{visibleElementsTreeTrimmedUri ? (
|
||||
<JSONArtifact uri={visibleElementsTreeTrimmedUri} />
|
||||
) : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="llm_prompt">
|
||||
{llmPromptUri ? <TextArtifact uri={llmPromptUri} /> : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="llm_request">
|
||||
{llmRequestUri ? <JSONArtifact uri={llmRequestUri} /> : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="llm_response_raw">
|
||||
{llmResponseRawUri ? <JSONArtifact uri={llmResponseRawUri} /> : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="llm_response_parsed">
|
||||
{llmResponseParsedUri ? (
|
||||
<JSONArtifact uri={llmResponseParsedUri} />
|
||||
) : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="html_raw">
|
||||
{htmlRawUri ? <TextArtifact uri={htmlRawUri} /> : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export { StepArtifacts };
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from "react";
|
||||
import { StepNavigation } from "./StepNavigation";
|
||||
import { StepArtifacts } from "./StepArtifacts";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { StepApiResponse } from "@/api/types";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { client } from "@/api/AxiosClient";
|
||||
|
||||
function StepArtifactsLayout() {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const { taskId } = useParams();
|
||||
|
||||
const {
|
||||
data: steps,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<Array<StepApiResponse>>({
|
||||
queryKey: ["task", taskId, "steps"],
|
||||
queryFn: async () => {
|
||||
return client
|
||||
.get(`/tasks/${taskId}/steps`)
|
||||
.then((response) => response.data);
|
||||
},
|
||||
});
|
||||
|
||||
if (isFetching) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div>Error: {error?.message}</div>;
|
||||
}
|
||||
|
||||
if (!steps) {
|
||||
return <div>No steps found</div>;
|
||||
}
|
||||
|
||||
const activeStep = steps[activeIndex];
|
||||
|
||||
return (
|
||||
<div className="px-4 flex">
|
||||
<aside className="w-64 shrink-0">
|
||||
<StepNavigation
|
||||
activeIndex={activeIndex}
|
||||
onActiveIndexChange={setActiveIndex}
|
||||
/>
|
||||
</aside>
|
||||
<main className="px-4 w-full">
|
||||
{activeStep ? (
|
||||
<StepArtifacts id={activeStep.step_id} stepProps={activeStep} />
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { StepArtifactsLayout };
|
||||
42
skyvern-frontend/src/routes/tasks/detail/StepInfo.tsx
Normal file
42
skyvern-frontend/src/routes/tasks/detail/StepInfo.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { StepApiResponse } from "@/api/types";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
type Props = {
|
||||
isFetching: boolean;
|
||||
stepProps?: StepApiResponse;
|
||||
};
|
||||
|
||||
function StepInfo({ isFetching, stepProps }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex items-center">
|
||||
<Label className="w-24">Step ID:</Label>
|
||||
{isFetching ? (
|
||||
<Skeleton className="h-4 w-40" />
|
||||
) : (
|
||||
<span>{stepProps?.step_id}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Label className="w-24">Status:</Label>
|
||||
{isFetching ? (
|
||||
<Skeleton className="h-4 w-40" />
|
||||
) : stepProps ? (
|
||||
<StatusBadge status={stepProps.status} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Label className="w-24">Created At:</Label>
|
||||
{isFetching ? (
|
||||
<Skeleton className="h-4 w-40" />
|
||||
) : stepProps ? (
|
||||
<span>{stepProps.created_at}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { StepInfo };
|
||||
@@ -1,123 +0,0 @@
|
||||
import { client } from "@/api/AxiosClient";
|
||||
import { StepApiResponse } from "@/api/types";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { StepListSkeleton } from "./StepListSkeleton";
|
||||
import { TaskStatusBadge } from "@/components/TaskStatusBadge";
|
||||
import { basicTimeFormat } from "@/util/timeFormat";
|
||||
|
||||
function StepList() {
|
||||
const { taskId } = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
|
||||
|
||||
const {
|
||||
data: steps,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<Array<StepApiResponse>>({
|
||||
queryKey: ["task", taskId, "steps", page],
|
||||
queryFn: async () => {
|
||||
return client
|
||||
.get(`/tasks/${taskId}/steps`, {
|
||||
params: {
|
||||
page,
|
||||
},
|
||||
})
|
||||
.then((response) => response.data);
|
||||
},
|
||||
});
|
||||
|
||||
if (isFetching) {
|
||||
return <StepListSkeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div>Error: {error?.message}</div>;
|
||||
}
|
||||
|
||||
if (!steps) {
|
||||
return <div>No steps found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-1/3">Order</TableHead>
|
||||
<TableHead className="w-1/3">Status</TableHead>
|
||||
<TableHead className="w-1/3">Created At</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{steps.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>No tasks found</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
steps.map((step) => {
|
||||
return (
|
||||
<TableRow key={step.step_id} className="cursor-pointer w-4">
|
||||
<TableCell className="w-1/3">{step.order}</TableCell>
|
||||
<TableCell className="w-1/3">
|
||||
<TaskStatusBadge status={step.status} />
|
||||
</TableCell>
|
||||
<TableCell className="w-1/3">
|
||||
{basicTimeFormat(step.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(Math.max(1, page - 1)));
|
||||
setSearchParams(params);
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationLink href="#">{page}</PaginationLink>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page + 1));
|
||||
setSearchParams(params);
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { StepList };
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const pageSizeArray = new Array(15).fill(null); // doesn't matter the value
|
||||
|
||||
function StepListSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Order</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created At</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageSizeArray.map((_, index) => {
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { StepListSkeleton };
|
||||
78
skyvern-frontend/src/routes/tasks/detail/StepNavigation.tsx
Normal file
78
skyvern-frontend/src/routes/tasks/detail/StepNavigation.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { client } from "@/api/AxiosClient";
|
||||
import { StepApiResponse } from "@/api/types";
|
||||
import { cn } from "@/util/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { PAGE_SIZE } from "../constants";
|
||||
|
||||
type Props = {
|
||||
activeIndex: number;
|
||||
onActiveIndexChange: (index: number) => void;
|
||||
};
|
||||
|
||||
function StepNavigation({ activeIndex, onActiveIndexChange }: Props) {
|
||||
const { taskId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
|
||||
|
||||
const {
|
||||
data: steps,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<Array<StepApiResponse>>({
|
||||
queryKey: ["task", taskId, "steps", page],
|
||||
queryFn: async () => {
|
||||
return client
|
||||
.get(`/tasks/${taskId}/steps`, {
|
||||
params: {
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
},
|
||||
})
|
||||
.then((response) => response.data);
|
||||
},
|
||||
});
|
||||
|
||||
if (isFetching) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div>Error: {error?.message}</div>;
|
||||
}
|
||||
|
||||
if (!steps) {
|
||||
return <div>No steps found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col gap-4">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = activeIndex === index;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-center items-center px-6 py-2 hover:bg-primary-foreground rounded-2xl cursor-pointer",
|
||||
{
|
||||
"bg-primary-foreground": isActive,
|
||||
},
|
||||
)}
|
||||
key={step.step_id}
|
||||
onClick={() => {
|
||||
onActiveIndexChange(index);
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{step.retry_index > 0
|
||||
? `Step ${step.order + 1} ( Retry ${step.retry_index} )`
|
||||
: `Step ${step.order + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export { StepNavigation };
|
||||
@@ -2,20 +2,22 @@ import { client } from "@/api/AxiosClient";
|
||||
import { Status, TaskApiResponse } from "@/api/types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { StepList } from "./StepList";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { TaskStatusBadge } from "@/components/TaskStatusBadge";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { artifactApiBaseUrl } from "@/util/env";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { basicTimeFormat } from "@/util/timeFormat";
|
||||
import { StepArtifactsLayout } from "./StepArtifactsLayout";
|
||||
import Zoom from "react-medium-image-zoom";
|
||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||
|
||||
function TaskDetails() {
|
||||
const { taskId } = useParams();
|
||||
@@ -27,11 +29,10 @@ function TaskDetails() {
|
||||
error: taskError,
|
||||
refetch,
|
||||
} = useQuery<TaskApiResponse>({
|
||||
queryKey: ["task", taskId],
|
||||
queryKey: ["task", taskId, "details"],
|
||||
queryFn: async () => {
|
||||
return client.get(`/tasks/${taskId}`).then((response) => response.data);
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
if (isTaskError) {
|
||||
@@ -63,14 +64,14 @@ function TaskDetails() {
|
||||
<div className="flex">
|
||||
<Label className="w-32">Recording</Label>
|
||||
<video
|
||||
src={`${artifactApiBaseUrl}/artifact?path=${task.recording_url.slice(7)}`}
|
||||
src={`${artifactApiBaseUrl}/artifact/recording?path=${task.recording_url.slice(7)}`}
|
||||
controls
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center">
|
||||
<Label className="w-32">Status</Label>
|
||||
<TaskStatusBadge status={task.status} />
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
{task.status === Status.Completed ? (
|
||||
<div className="flex items-center">
|
||||
@@ -93,8 +94,8 @@ function TaskDetails() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="task-details">
|
||||
<Accordion type="multiple">
|
||||
<AccordionItem value="task-parameters">
|
||||
<AccordionTrigger>
|
||||
<h1>Task Parameters</h1>
|
||||
</AccordionTrigger>
|
||||
@@ -102,7 +103,9 @@ function TaskDetails() {
|
||||
<div>
|
||||
<p className="py-2">Task ID: {taskId}</p>
|
||||
<p className="py-2">URL: {task.request.url}</p>
|
||||
<p className="py-2">{basicTimeFormat(task.created_at)}</p>
|
||||
<p className="py-2">
|
||||
Created: {basicTimeFormat(task.created_at)}
|
||||
</p>
|
||||
<div className="py-2">
|
||||
<Label>Navigation Goal</Label>
|
||||
<Textarea
|
||||
@@ -130,11 +133,36 @@ function TaskDetails() {
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="task-artifacts">
|
||||
<AccordionTrigger>
|
||||
<h1>Screenshot</h1>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="max-w-sm mx-auto">
|
||||
{task.screenshot_url ? (
|
||||
<Zoom zoomMargin={16}>
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<img
|
||||
src={`${artifactApiBaseUrl}/artifact/image?path=${task.screenshot_url.slice(7)}`}
|
||||
alt="screenshot"
|
||||
/>
|
||||
</AspectRatio>
|
||||
</Zoom>
|
||||
) : (
|
||||
<p>No screenshot</p>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="task-steps">
|
||||
<AccordionTrigger>
|
||||
<h1>Task Steps</h1>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<StepArtifactsLayout />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<div className="py-2">
|
||||
<h1>Task Steps</h1>
|
||||
<StepList />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
38
skyvern-frontend/src/routes/tasks/detail/TextArtifact.tsx
Normal file
38
skyvern-frontend/src/routes/tasks/detail/TextArtifact.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { artifactApiClient } from "@/api/AxiosClient";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
type Props = {
|
||||
uri: string;
|
||||
};
|
||||
|
||||
function TextArtifact({ uri }: Props) {
|
||||
const { data, isFetching, isError, error } = useQuery<string>({
|
||||
queryKey: ["artifact", uri],
|
||||
queryFn: async () => {
|
||||
return artifactApiClient
|
||||
.get(`/artifact/text`, {
|
||||
params: {
|
||||
path: uri.slice(7),
|
||||
},
|
||||
})
|
||||
.then((response) => response.data);
|
||||
},
|
||||
});
|
||||
|
||||
if (isFetching) {
|
||||
return <Skeleton className="w-full h-48" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
className="w-full"
|
||||
rows={15}
|
||||
value={isError ? JSON.stringify(error) : data}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { TextArtifact };
|
||||
@@ -22,7 +22,7 @@ import { TaskListSkeleton } from "./TaskListSkeleton";
|
||||
import { RunningTasks } from "../running/RunningTasks";
|
||||
import { cn } from "@/util/utils";
|
||||
import { PAGE_SIZE } from "../constants";
|
||||
import { TaskStatusBadge } from "@/components/TaskStatusBadge";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { basicTimeFormat } from "@/util/timeFormat";
|
||||
|
||||
function TaskList() {
|
||||
@@ -102,7 +102,7 @@ function TaskList() {
|
||||
>
|
||||
<TableCell className="w-1/3">{task.request.url}</TableCell>
|
||||
<TableCell className="w-1/3">
|
||||
<TaskStatusBadge status={task.status} />
|
||||
<StatusBadge status={task.status} />
|
||||
</TableCell>
|
||||
<TableCell className="w-1/3">
|
||||
{basicTimeFormat(task.created_at)}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { client } from "@/api/AxiosClient";
|
||||
import {
|
||||
ArtifactApiResponse,
|
||||
ArtifactType,
|
||||
StepApiResponse,
|
||||
} from "@/api/types";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { artifactApiBaseUrl } from "@/util/env";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
function LatestScreenshot({ id }: Props) {
|
||||
const {
|
||||
data: screenshotUri,
|
||||
isFetching,
|
||||
isError,
|
||||
} = useQuery<string | undefined>({
|
||||
queryKey: ["task", id, "latestScreenshot"],
|
||||
queryFn: async () => {
|
||||
const steps: StepApiResponse[] = await client
|
||||
.get(`/tasks/${id}/steps`)
|
||||
.then((response) => response.data);
|
||||
|
||||
if (steps.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latestStep = steps[steps.length - 1];
|
||||
|
||||
if (!latestStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
const artifacts: ArtifactApiResponse[] = await client
|
||||
.get(`/tasks/${id}/steps/${latestStep.step_id}/artifacts`)
|
||||
.then((response) => response.data);
|
||||
|
||||
const actionScreenshotUris = artifacts
|
||||
?.filter(
|
||||
(artifact) =>
|
||||
artifact.artifact_type === ArtifactType.ActionScreenshot,
|
||||
)
|
||||
.map((artifact) => artifact.uri);
|
||||
|
||||
if (actionScreenshotUris.length > 0) {
|
||||
return actionScreenshotUris[0];
|
||||
}
|
||||
|
||||
const llmScreenshotUris = artifacts
|
||||
?.filter(
|
||||
(artifact) => artifact.artifact_type === ArtifactType.LLMScreenshot,
|
||||
)
|
||||
.map((artifact) => artifact.uri);
|
||||
|
||||
if (llmScreenshotUris.length > 0) {
|
||||
return llmScreenshotUris[0];
|
||||
}
|
||||
|
||||
return Promise.reject("No screenshots found");
|
||||
},
|
||||
refetchInterval: 2000,
|
||||
});
|
||||
|
||||
if (isFetching) {
|
||||
return <Skeleton className="w-full h-full" />;
|
||||
}
|
||||
|
||||
if (isError || !screenshotUri || typeof screenshotUri !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`${artifactApiBaseUrl}/artifact/image?path=${screenshotUri.slice(7)}`}
|
||||
className="w-full h-full object-contain"
|
||||
alt="Latest screenshot"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { LatestScreenshot };
|
||||
@@ -11,20 +11,15 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { PAGE_SIZE } from "../constants";
|
||||
import { RunningTaskSkeleton } from "./RunningTaskSkeleton";
|
||||
import { basicTimeFormat } from "@/util/timeFormat";
|
||||
import { LatestScreenshot } from "./LatestScreenshot";
|
||||
|
||||
function RunningTasks() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
|
||||
|
||||
const {
|
||||
data: tasks,
|
||||
isPending,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<Array<TaskApiResponse>>({
|
||||
const { data: tasks } = useQuery<Array<TaskApiResponse>>({
|
||||
queryKey: ["tasks", page],
|
||||
queryFn: async () => {
|
||||
return client
|
||||
@@ -40,25 +35,13 @@ function RunningTasks() {
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <RunningTaskSkeleton />;
|
||||
}
|
||||
const runningTasks = tasks?.filter((task) => task.status === Status.Running);
|
||||
|
||||
if (isError) {
|
||||
return <div>Error: {error?.message}</div>;
|
||||
}
|
||||
|
||||
if (!tasks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runningTasks = tasks.filter((task) => task.status === Status.Running);
|
||||
|
||||
if (runningTasks.length === 0) {
|
||||
if (runningTasks?.length === 0) {
|
||||
return <div>No running tasks</div>;
|
||||
}
|
||||
|
||||
return runningTasks.map((task) => {
|
||||
return runningTasks?.map((task) => {
|
||||
return (
|
||||
<Card
|
||||
key={task.task_id}
|
||||
@@ -68,10 +51,17 @@ function RunningTasks() {
|
||||
}}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{task.request.url}</CardTitle>
|
||||
<CardDescription></CardDescription>
|
||||
<CardTitle>{task.task_id}</CardTitle>
|
||||
<CardDescription className="whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{task.request.url}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>Goal: {task.request.navigation_goal}</CardContent>
|
||||
<CardContent>
|
||||
Latest screenshot:
|
||||
<div className="w-40 h-40 border-2">
|
||||
<LatestScreenshot id={task.task_id} />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>Created: {basicTimeFormat(task.created_at)}</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user