Parameter search & inline display for Past Runs and Run History pages (#3985)
This commit is contained in:
40
skyvern-frontend/src/components/TableSearchInput.tsx
Normal file
40
skyvern-frontend/src/components/TableSearchInput.tsx
Normal file
@@ -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<HTMLInputElement>) {
|
||||
onChange(event.target.value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<div className="pointer-events-none absolute left-0 top-0 flex h-9 w-9 items-center justify-center">
|
||||
<MagnifyingGlassIcon className="size-5 text-slate-400" />
|
||||
</div>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
className={cn("pl-9", inputClassName)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { TableSearchInput };
|
||||
@@ -13,12 +13,18 @@ type Props = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
statusFilters?: Array<Status>;
|
||||
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<Array<Task | WorkflowRunApiResponse>>({
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<Array<Status>>([]);
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between">
|
||||
<header>
|
||||
<h1 className="text-2xl">Run History</h1>
|
||||
</header>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<TableSearchInput
|
||||
value={search}
|
||||
onChange={(value) => {
|
||||
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"
|
||||
/>
|
||||
<StatusFilterDropdown
|
||||
values={statusFilters}
|
||||
onChange={setStatusFilters}
|
||||
@@ -112,14 +170,13 @@ function RunHistory() {
|
||||
<Table>
|
||||
<TableHeader className="rounded-t-lg bg-slate-elevation2">
|
||||
<TableRow>
|
||||
<TableHead className="w-1/4 rounded-tl-lg text-slate-400">
|
||||
<TableHead className="w-1/5 rounded-tl-lg text-slate-400">
|
||||
Run ID
|
||||
</TableHead>
|
||||
<TableHead className="w-1/4 text-slate-400">Detail</TableHead>
|
||||
<TableHead className="w-1/4 text-slate-400">Status</TableHead>
|
||||
<TableHead className="w-1/4 rounded-tr-lg text-slate-400">
|
||||
Created At
|
||||
</TableHead>
|
||||
<TableHead className="w-1/5 text-slate-400">Detail</TableHead>
|
||||
<TableHead className="w-1/5 text-slate-400">Status</TableHead>
|
||||
<TableHead className="w-1/5 text-slate-400">Created At</TableHead>
|
||||
<TableHead className="w-1/5 rounded-tr-lg text-slate-400"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -179,41 +236,88 @@ function RunHistory() {
|
||||
run.workflow_title ?? ""
|
||||
);
|
||||
|
||||
const isExpanded = expandedRows.has(run.workflow_run_id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={run.workflow_run_id}
|
||||
className="cursor-pointer"
|
||||
onClick={(event) => {
|
||||
handleNavigate(
|
||||
event,
|
||||
env.useNewRunsUrl
|
||||
? `/runs/${run.workflow_run_id}`
|
||||
: `/workflows/${run.workflow_permanent_id}/${run.workflow_run_id}/overview`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<TableCell
|
||||
className="max-w-0 truncate"
|
||||
title={run.workflow_run_id}
|
||||
<React.Fragment key={run.workflow_run_id}>
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
onClick={(event) => {
|
||||
handleNavigate(
|
||||
event,
|
||||
env.useNewRunsUrl
|
||||
? `/runs/${run.workflow_run_id}`
|
||||
: `/workflows/${run.workflow_permanent_id}/${run.workflow_run_id}/overview`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{run.workflow_run_id}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-0 truncate"
|
||||
title={run.workflow_title ?? undefined}
|
||||
>
|
||||
{workflowTitle}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={run.status} />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-0 truncate"
|
||||
title={basicTimeFormat(run.created_at)}
|
||||
>
|
||||
{basicLocalTimeFormat(run.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableCell
|
||||
className="max-w-0 truncate"
|
||||
title={run.workflow_run_id}
|
||||
>
|
||||
{run.workflow_run_id}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-0 truncate"
|
||||
title={run.workflow_title ?? undefined}
|
||||
>
|
||||
{workflowTitle}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={run.status} />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-0 truncate"
|
||||
title={basicTimeFormat(run.created_at)}
|
||||
>
|
||||
{basicLocalTimeFormat(run.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleParametersExpanded(
|
||||
run.workflow_run_id,
|
||||
);
|
||||
}}
|
||||
className={cn(isExpanded && "text-blue-400")}
|
||||
>
|
||||
<MixerHorizontalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isExpanded
|
||||
? "Hide Parameters"
|
||||
: "Show Parameters"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{isExpanded && (
|
||||
<TableRow key={`${run.workflow_run_id}-params`}>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="bg-slate-50 dark:bg-slate-900/50"
|
||||
>
|
||||
<WorkflowRunParametersInline
|
||||
workflowPermanentId={run.workflow_permanent_id}
|
||||
workflowRunId={run.workflow_run_id}
|
||||
searchQuery={debouncedSearch}
|
||||
keywordMatchesParameter={matchesParameter}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
@@ -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<WorkflowRunStatusApiResponse>({
|
||||
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 (
|
||||
<div className="ml-8 py-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!run || !run.parameters || Object.keys(run.parameters).length === 0) {
|
||||
return (
|
||||
<div className="ml-8 py-4 text-sm text-slate-400">
|
||||
No parameters for this run
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const parameterItems = Object.entries(run.parameters).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
description: null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ParameterDisplayInline
|
||||
parameters={parameterItems}
|
||||
searchQuery={searchQuery}
|
||||
keywordMatchesParameter={keywordMatchesParameter}
|
||||
showDescription={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { RunHistory };
|
||||
|
||||
@@ -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<string | null>(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() {
|
||||
<h1 className="text-2xl">Past Runs</h1>
|
||||
</header>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="relative">
|
||||
<div className="absolute left-0 top-0 flex h-9 w-9 items-center justify-center">
|
||||
<MagnifyingGlassIcon className="size-5" />
|
||||
</div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<TableSearchInput
|
||||
value={search}
|
||||
onChange={(value) => {
|
||||
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"
|
||||
/>
|
||||
<StatusFilterDropdown
|
||||
values={statusFilters}
|
||||
onChange={setStatusFilters}
|
||||
@@ -154,20 +177,20 @@ function WorkflowPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-1/3">ID</TableHead>
|
||||
<TableHead className="w-1/3">Status</TableHead>
|
||||
<TableHead className="w-1/3">Created At</TableHead>
|
||||
<TableHead className="w-0"></TableHead>
|
||||
<TableHead className="w-1/4">ID</TableHead>
|
||||
<TableHead className="w-1/4">Status</TableHead>
|
||||
<TableHead className="w-1/4">Created At</TableHead>
|
||||
<TableHead className="w-1/4"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>Loading...</TableCell>
|
||||
<TableCell colSpan={4}>Loading...</TableCell>
|
||||
</TableRow>
|
||||
) : workflowRuns?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>No workflow runs found</TableCell>
|
||||
<TableCell colSpan={4}>No workflow runs found</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
workflowRuns?.map((workflowRun) => {
|
||||
@@ -183,59 +206,90 @@ function WorkflowPage() {
|
||||
workflowRun.workflow_run_id ?? ""
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={workflowRun.workflow_run_id}
|
||||
onClick={(event) => {
|
||||
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"
|
||||
>
|
||||
<TableCell>{workflowRunId}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={workflowRun.status} />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
title={basicTimeFormat(workflowRun.created_at)}
|
||||
return (
|
||||
<React.Fragment key={workflowRun.workflow_run_id}>
|
||||
{/* Main run row */}
|
||||
<TableRow
|
||||
onClick={(event) => {
|
||||
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)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpenRunParams(
|
||||
workflowRun.workflow_run_id ?? null,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<MixerHorizontalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View Parameters</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableCell>{workflowRunId}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={workflowRun.status} />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
title={basicTimeFormat(workflowRun.created_at)}
|
||||
>
|
||||
{basicLocalTimeFormat(workflowRun.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleParametersExpanded(
|
||||
workflowRun.workflow_run_id,
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
isExpanded && "text-blue-400",
|
||||
)}
|
||||
>
|
||||
<MixerHorizontalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isExpanded
|
||||
? "Hide Parameters"
|
||||
: "Show Parameters"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Expanded parameters section */}
|
||||
{isExpanded && (
|
||||
<TableRow key={`${workflowRun.workflow_run_id}-params`}>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="bg-slate-50 dark:bg-slate-900/50"
|
||||
>
|
||||
<WorkflowRunParameters
|
||||
workflowPermanentId={workflowPermanentId}
|
||||
workflowRunId={workflowRun.workflow_run_id}
|
||||
workflow={workflow}
|
||||
searchQuery={debouncedSearch}
|
||||
keywordMatchesParameter={matchesParameter}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
@@ -292,4 +346,94 @@ function WorkflowPage() {
|
||||
);
|
||||
}
|
||||
|
||||
type WorkflowRunParametersProps = {
|
||||
workflowPermanentId: string;
|
||||
workflowRunId: string;
|
||||
workflow: Awaited<ReturnType<typeof useWorkflowQuery>>["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<WorkflowRunStatusApiResponse>({
|
||||
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 (
|
||||
<div className="ml-8 py-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!run || !run.parameters || Object.keys(run.parameters).length === 0) {
|
||||
return (
|
||||
<div className="ml-8 py-4 text-sm text-slate-400">
|
||||
No parameters for this run
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<ParameterDisplayInline
|
||||
parameters={parameterItems}
|
||||
searchQuery={searchQuery}
|
||||
keywordMatchesParameter={keywordMatchesParameter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { WorkflowPage };
|
||||
|
||||
@@ -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<Set<string>>(
|
||||
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<string>();
|
||||
|
||||
const expanded = new Set<string>();
|
||||
|
||||
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<HTMLTableCellElement>,
|
||||
@@ -401,20 +375,15 @@ function Workflows() {
|
||||
)}
|
||||
</header>
|
||||
<div className="flex justify-between">
|
||||
<div className="relative">
|
||||
<div className="absolute left-0 top-0 flex size-9 items-center justify-center">
|
||||
<MagnifyingGlassIcon className="size-6" />
|
||||
</div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setParamPatch({ page: "1" });
|
||||
}}
|
||||
placeholder="Search by title or parameter..."
|
||||
className="w-48 pl-9 lg:w-72"
|
||||
/>
|
||||
</div>
|
||||
<TableSearchInput
|
||||
value={search}
|
||||
onChange={(value) => {
|
||||
setSearch(value);
|
||||
setParamPatch({ page: "1" });
|
||||
}}
|
||||
placeholder="Search by title or parameter..."
|
||||
className="w-48 lg:w-72"
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<ImportWorkflowButton
|
||||
onImportStart={startPolling}
|
||||
@@ -488,10 +457,17 @@ function Workflows() {
|
||||
</TableRow>
|
||||
) : (
|
||||
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"
|
||||
>
|
||||
<div className="ml-8 space-y-2 py-4">
|
||||
<div className="mb-3 text-sm font-medium">
|
||||
Parameters
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{workflow.workflow_definition.parameters
|
||||
.filter((p) => p.parameter_type !== "output")
|
||||
.map((param, idx) => {
|
||||
const matchesParam =
|
||||
parameterMatchesSearch(param);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"grid grid-cols-[minmax(200px,1fr)_minmax(200px,1fr)_minmax(300px,2fr)] gap-6 rounded border bg-white p-3 text-sm dark:border-slate-800 dark:bg-slate-900",
|
||||
matchesParam &&
|
||||
"shadow-[0_0_15px_rgba(59,130,246,0.3)] ring-2 ring-blue-500/50",
|
||||
)}
|
||||
>
|
||||
<div className="font-medium text-blue-600 dark:text-blue-400">
|
||||
<HighlightText
|
||||
text={param.key}
|
||||
query={debouncedSearch}
|
||||
/>
|
||||
</div>
|
||||
<div className="truncate">
|
||||
{param.parameter_type ===
|
||||
"workflow" &&
|
||||
param.default_value ? (
|
||||
<HighlightText
|
||||
text={String(param.default_value)}
|
||||
query={debouncedSearch}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-slate-400">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-slate-500">
|
||||
{param.description ? (
|
||||
<HighlightText
|
||||
text={param.description}
|
||||
query={debouncedSearch}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-slate-400">
|
||||
No description
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<ParameterDisplayInline
|
||||
parameters={parameterItems}
|
||||
searchQuery={debouncedSearch}
|
||||
keywordMatchesParameter={matchesParameter}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@@ -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<ParameterDisplayItem>;
|
||||
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 (
|
||||
<div className={cn("ml-8 py-4 text-sm text-slate-400", className)}>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("ml-8 space-y-2 py-4", className)}>
|
||||
<div className="mb-3 text-sm font-medium">{title}</div>
|
||||
<div className="space-y-2">
|
||||
{parameters.map((parameter) => {
|
||||
const displayValue = getDisplayValue(parameter.value);
|
||||
const matches = keywordMatchesParameter(parameter);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={parameter.key}
|
||||
className={cn(
|
||||
"grid gap-6 rounded border bg-white p-3 text-sm dark:border-slate-800 dark:bg-slate-900",
|
||||
showDescription
|
||||
? "grid-cols-[minmax(200px,1fr)_minmax(200px,1fr)_minmax(300px,2fr)]"
|
||||
: "grid-cols-[minmax(200px,1fr)_minmax(300px,2fr)]",
|
||||
matches &&
|
||||
"shadow-[0_0_15px_rgba(59,130,246,0.3)] ring-2 ring-blue-500/50",
|
||||
)}
|
||||
>
|
||||
<div className="font-medium text-blue-600 dark:text-blue-400">
|
||||
<HighlightText text={parameter.key} query={searchQuery} />
|
||||
</div>
|
||||
<div className="truncate">
|
||||
{displayValue === "-" ? (
|
||||
<span className="text-slate-400">-</span>
|
||||
) : (
|
||||
<HighlightText text={displayValue} query={searchQuery} />
|
||||
)}
|
||||
</div>
|
||||
{showDescription ? (
|
||||
<div className="text-slate-500">
|
||||
{parameter.description ? (
|
||||
<HighlightText
|
||||
text={parameter.description}
|
||||
query={searchQuery}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-slate-400">No description</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { ParameterDisplayItem };
|
||||
export { ParameterDisplayInline };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
function toSet(ids: Iterable<string>): Set<string> {
|
||||
return new Set(ids);
|
||||
}
|
||||
|
||||
function useParameterExpansion() {
|
||||
const [manuallyExpandedRows, setManuallyExpandedRows] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [autoExpandedRows, setAutoExpandedRows] = useState<Set<string>>(
|
||||
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<string>) => {
|
||||
setAutoExpandedRows(toSet(ids));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
expandedRows,
|
||||
toggleExpanded,
|
||||
setManuallyExpandedRows,
|
||||
manuallyExpandedRows,
|
||||
setAutoExpandedRows: updateAutoExpandedRows,
|
||||
};
|
||||
}
|
||||
|
||||
export { useParameterExpansion };
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user