diff --git a/skyvern-frontend/src/components/TableSearchInput.tsx b/skyvern-frontend/src/components/TableSearchInput.tsx new file mode 100644 index 00000000..907e2f20 --- /dev/null +++ b/skyvern-frontend/src/components/TableSearchInput.tsx @@ -0,0 +1,40 @@ +import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/util/utils"; +import { ChangeEvent } from "react"; + +type TableSearchInputProps = { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + inputClassName?: string; +}; + +function TableSearchInput({ + value, + onChange, + placeholder = "Search…", + className, + inputClassName, +}: TableSearchInputProps) { + function handleChange(event: ChangeEvent) { + onChange(event.target.value); + } + + return ( +
+
+ +
+ +
+ ); +} + +export { TableSearchInput }; diff --git a/skyvern-frontend/src/hooks/useRunsQuery.ts b/skyvern-frontend/src/hooks/useRunsQuery.ts index 2e4dc6fc..9aa06fbc 100644 --- a/skyvern-frontend/src/hooks/useRunsQuery.ts +++ b/skyvern-frontend/src/hooks/useRunsQuery.ts @@ -13,12 +13,18 @@ type Props = { page?: number; pageSize?: number; statusFilters?: Array; + search?: string; } & UseQueryOptions; -function useRunsQuery({ page = 1, pageSize = 10, statusFilters }: Props) { +function useRunsQuery({ + page = 1, + pageSize = 10, + statusFilters, + search, +}: Props) { const credentialGetter = useCredentialGetter(); return useQuery>({ - queryKey: ["runs", { statusFilters }, page, pageSize], + queryKey: ["runs", { statusFilters }, page, pageSize, search], queryFn: async () => { const client = await getClient(credentialGetter); const params = new URLSearchParams(); @@ -29,6 +35,9 @@ function useRunsQuery({ page = 1, pageSize = 10, statusFilters }: Props) { params.append("status", status); }); } + if (search) { + params.append("search_key", search); + } return client.get("/runs", { params }).then((res) => res.data); }, }); diff --git a/skyvern-frontend/src/routes/history/RunHistory.tsx b/skyvern-frontend/src/routes/history/RunHistory.tsx index 0b434621..db2b7650 100644 --- a/skyvern-frontend/src/routes/history/RunHistory.tsx +++ b/skyvern-frontend/src/routes/history/RunHistory.tsx @@ -1,7 +1,12 @@ -import { LightningBoltIcon } from "@radix-ui/react-icons"; +import { LightningBoltIcon, MixerHorizontalIcon } from "@radix-ui/react-icons"; import { Tip } from "@/components/Tip"; -import { Status, Task, WorkflowRunApiResponse } from "@/api/types"; +import { + Status, + Task, + WorkflowRunApiResponse, + WorkflowRunStatusApiResponse, +} from "@/api/types"; import { StatusBadge } from "@/components/StatusBadge"; import { StatusFilterDropdown } from "@/components/StatusFilterDropdown"; import { @@ -25,11 +30,24 @@ import { useRunsQuery } from "@/hooks/useRunsQuery"; import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat"; import { cn } from "@/util/utils"; import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { getClient } from "@/api/AxiosClient"; import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import * as env from "@/util/env"; +import { useDebounce } from "use-debounce"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useGlobalWorkflowsQuery } from "@/routes/workflows/hooks/useGlobalWorkflowsQuery"; +import { TableSearchInput } from "@/components/TableSearchInput"; +import { useKeywordSearch } from "@/routes/workflows/hooks/useKeywordSearch"; +import { useParameterExpansion } from "@/routes/workflows/hooks/useParameterExpansion"; +import { ParameterDisplayInline } from "@/routes/workflows/components/ParameterDisplayInline"; function isTask(run: Task | WorkflowRunApiResponse): run is Task { return "task_id" in run; @@ -43,10 +61,14 @@ function RunHistory() { ? Number(searchParams.get("page_size")) : 10; const [statusFilters, setStatusFilters] = useState>([]); + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebounce(search, 500); + const { data: runs, isFetching } = useRunsQuery({ page, pageSize: itemsPerPage, statusFilters, + search: debouncedSearch, }); const navigate = useNavigate(); @@ -72,6 +94,29 @@ function RunHistory() { const isNextDisabled = isFetching || !nextPageRuns || nextPageRuns.length === 0; + const { matchesParameter, isSearchActive } = + useKeywordSearch(debouncedSearch); + const { + expandedRows, + toggleExpanded: toggleParametersExpanded, + setAutoExpandedRows, + } = useParameterExpansion(); + + useEffect(() => { + if (!isSearchActive) { + setAutoExpandedRows([]); + return; + } + + const workflowRunIds = + runs + ?.filter((run): run is WorkflowRunApiResponse => !isTask(run)) + .map((run) => run.workflow_run_id) + .filter((id): id is string => Boolean(id)) ?? []; + + setAutoExpandedRows(workflowRunIds); + }, [isSearchActive, runs, setAutoExpandedRows]); + function handleNavigate(event: React.MouseEvent, path: string) { if (event.ctrlKey || event.metaKey) { window.open( @@ -101,8 +146,21 @@ function RunHistory() { } return (
-
+

Run History

+
+
+ { + setSearch(value); + const params = new URLSearchParams(searchParams); + params.set("page", "1"); + setSearchParams(params, { replace: true }); + }} + placeholder="Search runs by parameter..." + className="w-48 lg:w-72" + /> - + Run ID - Detail - Status - - Created At - + Detail + Status + Created At + @@ -179,41 +236,88 @@ function RunHistory() { run.workflow_title ?? "" ); + const isExpanded = expandedRows.has(run.workflow_run_id); + return ( - { - handleNavigate( - event, - env.useNewRunsUrl - ? `/runs/${run.workflow_run_id}` - : `/workflows/${run.workflow_permanent_id}/${run.workflow_run_id}/overview`, - ); - }} - > - + { + handleNavigate( + event, + env.useNewRunsUrl + ? `/runs/${run.workflow_run_id}` + : `/workflows/${run.workflow_permanent_id}/${run.workflow_run_id}/overview`, + ); + }} > - {run.workflow_run_id} - - - {workflowTitle} - - - - - - {basicLocalTimeFormat(run.created_at)} - - + + {run.workflow_run_id} + + + {workflowTitle} + + + + + + {basicLocalTimeFormat(run.created_at)} + + +
+ + + + + + + {isExpanded + ? "Hide Parameters" + : "Show Parameters"} + + + +
+
+ + + {isExpanded && ( + + + + + + )} + ); }) )} @@ -268,4 +372,81 @@ function RunHistory() { ); } +type WorkflowRunParametersInlineProps = { + workflowPermanentId: string; + workflowRunId: string; + searchQuery: string; + keywordMatchesParameter: (parameter: { + key: string; + value: unknown; + description?: string | null; + }) => boolean; +}; + +function WorkflowRunParametersInline({ + workflowPermanentId, + workflowRunId, + searchQuery, + keywordMatchesParameter, +}: WorkflowRunParametersInlineProps) { + const { data: globalWorkflows } = useGlobalWorkflowsQuery(); + const credentialGetter = useCredentialGetter(); + + const { data: run, isLoading } = useQuery({ + queryKey: [ + "workflowRun", + workflowPermanentId, + workflowRunId, + "params-inline", + ], + queryFn: async () => { + const client = await getClient(credentialGetter); + const params = new URLSearchParams(); + const isGlobalWorkflow = globalWorkflows?.some( + (workflow) => workflow.workflow_permanent_id === workflowPermanentId, + ); + if (isGlobalWorkflow) { + params.set("template", "true"); + } + return client + .get(`/workflows/${workflowPermanentId}/runs/${workflowRunId}`, { + params, + }) + .then((r) => r.data); + }, + enabled: !!workflowPermanentId && !!workflowRunId && !!globalWorkflows, + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!run || !run.parameters || Object.keys(run.parameters).length === 0) { + return ( +
+ No parameters for this run +
+ ); + } + + const parameterItems = Object.entries(run.parameters).map(([key, value]) => ({ + key, + value, + description: null, + })); + + return ( + + ); +} + export { RunHistory }; diff --git a/skyvern-frontend/src/routes/workflows/WorkflowPage.tsx b/skyvern-frontend/src/routes/workflows/WorkflowPage.tsx index 2da9c8e1..7592fb05 100644 --- a/skyvern-frontend/src/routes/workflows/WorkflowPage.tsx +++ b/skyvern-frontend/src/routes/workflows/WorkflowPage.tsx @@ -25,12 +25,11 @@ import { import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat"; import { cn } from "@/util/utils"; import { - MagnifyingGlassIcon, MixerHorizontalIcon, Pencil2Icon, PlayIcon, } from "@radix-ui/react-icons"; -import { useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Link, useNavigate, @@ -40,7 +39,6 @@ import { import { useWorkflowQuery } from "./hooks/useWorkflowQuery"; import { useWorkflowRunsQuery } from "./hooks/useWorkflowRunsQuery"; import { WorkflowActions } from "./WorkflowActions"; -import { Input } from "@/components/ui/input"; import { useDebounce } from "use-debounce"; import { Tooltip, @@ -50,6 +48,15 @@ import { } from "@/components/ui/tooltip"; import { RunParametersDialog } from "./workflowRun/RunParametersDialog"; import * as env from "@/util/env"; +import { getClient } from "@/api/AxiosClient"; +import { WorkflowRunStatusApiResponse } from "@/api/types"; +import { useQuery } from "@tanstack/react-query"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { useGlobalWorkflowsQuery } from "./hooks/useGlobalWorkflowsQuery"; +import { TableSearchInput } from "@/components/TableSearchInput"; +import { useKeywordSearch } from "./hooks/useKeywordSearch"; +import { useParameterExpansion } from "./hooks/useParameterExpansion"; +import { ParameterDisplayInline } from "./components/ParameterDisplayInline"; function WorkflowPage() { const { workflowPermanentId } = useParams(); @@ -62,6 +69,13 @@ function WorkflowPage() { const [search, setSearch] = useState(""); const [debouncedSearch] = useDebounce(search, 500); const [openRunParams, setOpenRunParams] = useState(null); + const { matchesParameter, isSearchActive } = + useKeywordSearch(debouncedSearch); + const { + expandedRows, + toggleExpanded: toggleParametersExpanded, + setAutoExpandedRows, + } = useParameterExpansion(); const { data: workflowRuns, isLoading } = useWorkflowRunsQuery({ workflowPermanentId, @@ -83,6 +97,20 @@ function WorkflowPage() { workflowPermanentId, }); + useEffect(() => { + if (!isSearchActive) { + setAutoExpandedRows([]); + return; + } + + const runIds = + workflowRuns + ?.map((run) => run.workflow_run_id) + .filter((id): id is string => Boolean(id)) ?? []; + + setAutoExpandedRows(runIds); + }, [isSearchActive, workflowRuns, setAutoExpandedRows]); + if (!workflowPermanentId) { return null; // this should never happen } @@ -129,22 +157,17 @@ function WorkflowPage() {

Past Runs

-
-
- -
- { - setSearch(e.target.value); - const params = new URLSearchParams(searchParams); - params.set("page", "1"); - setSearchParams(params, { replace: true }); - }} - placeholder="Search runs by parameter..." - className="w-48 pl-9 lg:w-72" - /> -
+ { + setSearch(value); + const params = new URLSearchParams(searchParams); + params.set("page", "1"); + setSearchParams(params, { replace: true }); + }} + placeholder="Search runs by parameter..." + className="w-48 lg:w-72" + /> - ID - Status - Created At - + ID + Status + Created At + {isLoading ? ( - Loading... + Loading... ) : workflowRuns?.length === 0 ? ( - No workflow runs found + No workflow runs found ) : ( workflowRuns?.map((workflowRun) => { @@ -183,59 +206,90 @@ function WorkflowPage() { workflowRun.workflow_run_id ?? "" ); - return ( - { - const url = env.useNewRunsUrl - ? `/runs/${workflowRun.workflow_run_id}` - : `/workflows/${workflowPermanentId}/${workflowRun.workflow_run_id}/overview`; + const isExpanded = expandedRows.has( + workflowRun.workflow_run_id, + ); - if (event.ctrlKey || event.metaKey) { - window.open( - window.location.origin + url, - "_blank", - "noopener,noreferrer", - ); - return; - } - navigate(url); - }} - className="cursor-pointer" - > - {workflowRunId} - - - - + {/* Main run row */} + { + const url = env.useNewRunsUrl + ? `/runs/${workflowRun.workflow_run_id}` + : `/workflows/${workflowPermanentId}/${workflowRun.workflow_run_id}/overview`; + + if (event.ctrlKey || event.metaKey) { + window.open( + window.location.origin + url, + "_blank", + "noopener,noreferrer", + ); + return; + } + navigate(url); + }} + className="cursor-pointer" > - {basicLocalTimeFormat(workflowRun.created_at)} - - -
- - - - - - View Parameters - - -
-
-
+ {workflowRunId} + + + + + {basicLocalTimeFormat(workflowRun.created_at)} + + +
+ + + + + + + {isExpanded + ? "Hide Parameters" + : "Show Parameters"} + + + +
+
+ + + {/* Expanded parameters section */} + {isExpanded && ( + + + + + + )} + ); }) )} @@ -292,4 +346,94 @@ function WorkflowPage() { ); } +type WorkflowRunParametersProps = { + workflowPermanentId: string; + workflowRunId: string; + workflow: Awaited>["data"]; + searchQuery: string; + keywordMatchesParameter: (parameter: { + key: string; + value: unknown; + description?: string | null; + }) => boolean; +}; + +function WorkflowRunParameters({ + workflowPermanentId, + workflowRunId, + workflow, + searchQuery, + keywordMatchesParameter, +}: WorkflowRunParametersProps) { + const { data: globalWorkflows } = useGlobalWorkflowsQuery(); + const credentialGetter = useCredentialGetter(); + + const isGlobalWorkflow = + globalWorkflows?.some( + (wf) => wf.workflow_permanent_id === workflowPermanentId, + ) ?? false; + + const { data: run, isLoading } = useQuery({ + queryKey: [ + "workflowRun", + workflowPermanentId, + workflowRunId, + "params", + isGlobalWorkflow, + ], + queryFn: async () => { + const client = await getClient(credentialGetter); + const params = new URLSearchParams(); + if (isGlobalWorkflow) { + params.set("template", "true"); + } + return client + .get(`/workflows/${workflowPermanentId}/runs/${workflowRunId}`, { + params, + }) + .then((r) => r.data); + }, + enabled: Boolean(workflowPermanentId && workflowRunId), + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!run || !run.parameters || Object.keys(run.parameters).length === 0) { + return ( +
+ No parameters for this run +
+ ); + } + + // Create a map of parameter definitions by key + const defByKey = new Map( + (workflow?.workflow_definition.parameters ?? []).map((p) => [p.key, p]), + ); + + const parameterItems = Object.entries(run.parameters).map(([key, value]) => { + const def = defByKey.get(key); + const description = def && "description" in def ? def.description : null; + return { + key, + value, + description, + }; + }); + + return ( + + ); +} + export { WorkflowPage }; diff --git a/skyvern-frontend/src/routes/workflows/Workflows.tsx b/skyvern-frontend/src/routes/workflows/Workflows.tsx index 143288e1..eff44f17 100644 --- a/skyvern-frontend/src/routes/workflows/Workflows.tsx +++ b/skyvern-frontend/src/routes/workflows/Workflows.tsx @@ -1,6 +1,5 @@ import { getClient } from "@/api/AxiosClient"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Pagination, PaginationContent, @@ -30,7 +29,6 @@ import { DotsHorizontalIcon, FileIcon, LightningBoltIcon, - MagnifyingGlassIcon, MixerHorizontalIcon, Pencil2Icon, PlayIcon, @@ -38,7 +36,7 @@ import { ReloadIcon, } from "@radix-ui/react-icons"; import { useQuery } from "@tanstack/react-query"; -import React, { useCallback, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useDebounce } from "use-debounce"; import { NarrativeCard } from "./components/header/NarrativeCard"; @@ -51,11 +49,15 @@ import { useCreateWorkflowMutation } from "./hooks/useCreateWorkflowMutation"; import { useFoldersQuery } from "./hooks/useFoldersQuery"; import { useActiveImportsPolling } from "./hooks/useActiveImportsPolling"; import { ImportWorkflowButton } from "./ImportWorkflowButton"; -import { Parameter, WorkflowApiResponse } from "./types/workflowTypes"; +import { WorkflowApiResponse } from "./types/workflowTypes"; import { WorkflowCreateYAMLRequest } from "./types/workflowYamlTypes"; import { WorkflowActions } from "./WorkflowActions"; import { WorkflowTemplates } from "../discover/WorkflowTemplates"; import { Skeleton } from "@/components/ui/skeleton"; +import { TableSearchInput } from "@/components/TableSearchInput"; +import { ParameterDisplayInline } from "./components/ParameterDisplayInline"; +import { useKeywordSearch } from "./hooks/useKeywordSearch"; +import { useParameterExpansion } from "./hooks/useParameterExpansion"; const emptyWorkflowRequest: WorkflowCreateYAMLRequest = { title: "New Workflow", @@ -85,11 +87,6 @@ function Workflows() { const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); const [isViewAllFoldersOpen, setIsViewAllFoldersOpen] = useState(false); - // Parameter expansion state - const [manuallyExpandedRows, setManuallyExpandedRows] = useState>( - new Set(), - ); - // Poll for active imports const { activeImports, startPolling } = useActiveImportsPolling(); @@ -176,59 +173,36 @@ function Workflows() { const isNextDisabled = isFetching || !nextPageWorkflows || nextPageWorkflows.length === 0; - // Check if a specific parameter matches the search - const parameterMatchesSearch = useCallback( - (param: Parameter): boolean => { - if (!debouncedSearch.trim()) return false; - const lowerQuery = debouncedSearch.toLowerCase(); + const { matchesParameter, isSearchActive } = + useKeywordSearch(debouncedSearch); + const { + expandedRows, + toggleExpanded: toggleParametersExpanded, + setAutoExpandedRows, + } = useParameterExpansion(); - const keyMatch = param.key?.toLowerCase().includes(lowerQuery) ?? false; - const descMatch = - param.description?.toLowerCase().includes(lowerQuery) ?? false; - const valueMatch = Boolean( - param.parameter_type === "workflow" && - param.default_value && - String(param.default_value).toLowerCase().includes(lowerQuery), - ); - - return keyMatch || descMatch || valueMatch; - }, - [debouncedSearch], - ); - - // Auto-expand rows when parameters match search - const autoExpandedRows = useMemo(() => { - if (!debouncedSearch.trim()) return new Set(); - - const expanded = new Set(); - - workflows.forEach((workflow) => { - const hasParameterMatch = workflow.workflow_definition.parameters?.some( - (param) => parameterMatchesSearch(param), - ); - - if (hasParameterMatch) { - expanded.add(workflow.workflow_permanent_id); - } - }); - - return expanded; - }, [workflows, debouncedSearch, parameterMatchesSearch]); - - // Combine manual and auto-expanded rows - const expandedRows = useMemo(() => { - return new Set([...manuallyExpandedRows, ...autoExpandedRows]); - }, [manuallyExpandedRows, autoExpandedRows]); - - const toggleParametersExpanded = (workflowId: string) => { - const newExpanded = new Set(manuallyExpandedRows); - if (newExpanded.has(workflowId)) { - newExpanded.delete(workflowId); - } else { - newExpanded.add(workflowId); + useEffect(() => { + if (!isSearchActive) { + setAutoExpandedRows([]); + return; } - setManuallyExpandedRows(newExpanded); - }; + + const matchingWorkflows = workflows.filter((workflow) => + workflow.workflow_definition.parameters?.some((param) => { + const value = + param.parameter_type === "workflow" ? param.default_value : undefined; + return matchesParameter({ + key: param.key, + value, + description: param.description ?? null, + }); + }), + ); + + setAutoExpandedRows( + matchingWorkflows.map((workflow) => workflow.workflow_permanent_id), + ); + }, [isSearchActive, workflows, matchesParameter, setAutoExpandedRows]); function handleRowClick( event: React.MouseEvent, @@ -401,20 +375,15 @@ function Workflows() { )}
-
-
- -
- { - setSearch(event.target.value); - setParamPatch({ page: "1" }); - }} - placeholder="Search by title or parameter..." - className="w-48 pl-9 lg:w-72" - /> -
+ { + setSearch(value); + setParamPatch({ page: "1" }); + }} + placeholder="Search by title or parameter..." + className="w-48 lg:w-72" + />
) : ( displayWorkflows?.map((workflow) => { - const hasParameters = - workflow.workflow_definition.parameters.filter( - (p) => p.parameter_type !== "output", - ).length > 0; + const parameterItems = workflow.workflow_definition.parameters + .filter((p) => p.parameter_type !== "output") + .map((param) => ({ + key: param.key, + value: + param.parameter_type === "workflow" + ? param.default_value ?? "" + : "", + description: param.description ?? null, + })); + const hasParameters = parameterItems.length > 0; const isExpanded = expandedRows.has( workflow.workflow_permanent_id, ); @@ -700,63 +676,11 @@ function Workflows() { colSpan={5} className="bg-slate-50 dark:bg-slate-900/50" > -
-
- Parameters -
-
- {workflow.workflow_definition.parameters - .filter((p) => p.parameter_type !== "output") - .map((param, idx) => { - const matchesParam = - parameterMatchesSearch(param); - - return ( -
-
- -
-
- {param.parameter_type === - "workflow" && - param.default_value ? ( - - ) : ( - - - - - )} -
-
- {param.description ? ( - - ) : ( - - No description - - )} -
-
- ); - })} -
-
+ )} diff --git a/skyvern-frontend/src/routes/workflows/components/ParameterDisplayInline.tsx b/skyvern-frontend/src/routes/workflows/components/ParameterDisplayInline.tsx new file mode 100644 index 00000000..4c09ee07 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/components/ParameterDisplayInline.tsx @@ -0,0 +1,105 @@ +import { ReactNode } from "react"; +import { cn } from "@/util/utils"; +import { HighlightText } from "./HighlightText"; + +type ParameterDisplayItem = { + key: string; + value: unknown; + description?: string | null; +}; + +type ParameterDisplayInlineProps = { + title?: string; + parameters: Array; + searchQuery: string; + keywordMatchesParameter: (parameter: ParameterDisplayItem) => boolean; + showDescription?: boolean; + emptyMessage?: ReactNode; + className?: string; +}; + +function getDisplayValue(value: unknown): string { + if (value === null || value === undefined || value === "") { + return "-"; + } + + if (typeof value === "string") { + return value; + } + + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function ParameterDisplayInline({ + title = "Parameters", + parameters, + searchQuery, + keywordMatchesParameter, + showDescription = true, + emptyMessage = "No parameters for this run", + className, +}: ParameterDisplayInlineProps) { + if (!parameters || parameters.length === 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + return ( +
+
{title}
+
+ {parameters.map((parameter) => { + const displayValue = getDisplayValue(parameter.value); + const matches = keywordMatchesParameter(parameter); + + return ( +
+
+ +
+
+ {displayValue === "-" ? ( + - + ) : ( + + )} +
+ {showDescription ? ( +
+ {parameter.description ? ( + + ) : ( + No description + )} +
+ ) : null} +
+ ); + })} +
+
+ ); +} + +export type { ParameterDisplayItem }; +export { ParameterDisplayInline }; diff --git a/skyvern-frontend/src/routes/workflows/hooks/useKeywordSearch.ts b/skyvern-frontend/src/routes/workflows/hooks/useKeywordSearch.ts new file mode 100644 index 00000000..84898554 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useKeywordSearch.ts @@ -0,0 +1,60 @@ +import { useCallback, useMemo } from "react"; +import type { ParameterDisplayItem } from "../components/ParameterDisplayInline"; + +function normalize(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + + if (typeof value === "string") { + return value; + } + + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function useKeywordSearch(search: string) { + const normalizedSearch = useMemo(() => search.trim().toLowerCase(), [search]); + + const isSearchActive = normalizedSearch.length > 0; + + const matchesText = useCallback( + (text: string | null | undefined) => { + if (!isSearchActive || !text) { + return false; + } + return text.toLowerCase().includes(normalizedSearch); + }, + [isSearchActive, normalizedSearch], + ); + + const matchesParameter = useCallback( + (parameter: ParameterDisplayItem) => { + if (!isSearchActive) { + return false; + } + + const valueString = normalize(parameter.value); + + return ( + matchesText(parameter.key) || + matchesText(parameter.description ?? "") || + matchesText(valueString) + ); + }, + [isSearchActive, matchesText], + ); + + return { + searchQuery: normalizedSearch, + isSearchActive, + matchesText, + matchesParameter, + }; +} + +export { useKeywordSearch }; diff --git a/skyvern-frontend/src/routes/workflows/hooks/useParameterExpansion.ts b/skyvern-frontend/src/routes/workflows/hooks/useParameterExpansion.ts new file mode 100644 index 00000000..9dbd6df9 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useParameterExpansion.ts @@ -0,0 +1,48 @@ +import { useCallback, useMemo, useState } from "react"; + +function toSet(ids: Iterable): Set { + return new Set(ids); +} + +function useParameterExpansion() { + const [manuallyExpandedRows, setManuallyExpandedRows] = useState>( + new Set(), + ); + const [autoExpandedRows, setAutoExpandedRows] = useState>( + new Set(), + ); + + const toggleExpanded = useCallback((id: string) => { + setManuallyExpandedRows((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const expandedRows = useMemo(() => { + const combined = new Set(autoExpandedRows); + for (const id of manuallyExpandedRows) { + combined.add(id); + } + return combined; + }, [autoExpandedRows, manuallyExpandedRows]); + + const updateAutoExpandedRows = useCallback((ids: Iterable) => { + setAutoExpandedRows(toSet(ids)); + }, []); + + return { + expandedRows, + toggleExpanded, + setManuallyExpandedRows, + manuallyExpandedRows, + setAutoExpandedRows: updateAutoExpandedRows, + }; +} + +export { useParameterExpansion }; diff --git a/skyvern/forge/sdk/db/client.py b/skyvern/forge/sdk/db/client.py index a36b4232..addf1548 100644 --- a/skyvern/forge/sdk/db/client.py +++ b/skyvern/forge/sdk/db/client.py @@ -2379,6 +2379,7 @@ class AgentDB: page_size: int = 10, status: list[WorkflowRunStatus] | None = None, include_debugger_runs: bool = False, + search_key: str | None = None, ) -> list[WorkflowRun | Task]: try: async with self.Session() as session: @@ -2398,6 +2399,28 @@ class AgentDB: if not include_debugger_runs: workflow_run_query = workflow_run_query.filter(WorkflowRunModel.debug_session_id.is_(None)) + if search_key: + key_like = f"%{search_key}%" + param_exists = exists( + select(1) + .select_from(WorkflowRunParameterModel) + .join( + WorkflowParameterModel, + WorkflowParameterModel.workflow_parameter_id + == WorkflowRunParameterModel.workflow_parameter_id, + ) + .where(WorkflowRunParameterModel.workflow_run_id == WorkflowRunModel.workflow_run_id) + .where(WorkflowParameterModel.deleted_at.is_(None)) + .where( + or_( + WorkflowParameterModel.key.ilike(key_like), + WorkflowParameterModel.description.ilike(key_like), + WorkflowRunParameterModel.value.ilike(key_like), + ) + ) + ) + workflow_run_query = workflow_run_query.where(param_exists) + if status: workflow_run_query = workflow_run_query.filter(WorkflowRunModel.status.in_(status)) workflow_run_query = workflow_run_query.order_by(WorkflowRunModel.created_at.desc()).limit(limit) diff --git a/skyvern/forge/sdk/routes/agent_protocol.py b/skyvern/forge/sdk/routes/agent_protocol.py index 04bb395a..16b18f15 100644 --- a/skyvern/forge/sdk/routes/agent_protocol.py +++ b/skyvern/forge/sdk/routes/agent_protocol.py @@ -1857,6 +1857,10 @@ async def get_runs( page: int = Query(1, ge=1), page_size: int = Query(10, ge=1), status: Annotated[list[WorkflowRunStatus] | None, Query()] = None, + search_key: str | None = Query( + None, + description="Search runs by parameter key, parameter description, or run parameter value.", + ), ) -> Response: analytics.capture("skyvern-oss-agent-runs-get") @@ -1864,7 +1868,9 @@ async def get_runs( if page > 10: return [] - runs = await app.DATABASE.get_all_runs(current_org.organization_id, page=page, page_size=page_size, status=status) + runs = await app.DATABASE.get_all_runs( + current_org.organization_id, page=page, page_size=page_size, status=status, search_key=search_key + ) return ORJSONResponse([run.model_dump() for run in runs])