Parameter search & inline display for Past Runs and Run History pages (#3985)

This commit is contained in:
Celal Zamanoglu
2025-11-14 01:33:39 +03:00
committed by GitHub
parent f1e118ab2b
commit a95837783a
10 changed files with 797 additions and 257 deletions

View 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 };

View File

@@ -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);
},
});

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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>
)}

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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)

View File

@@ -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])