feat(workflows, runs, api): parameter metadata search/filter/display across workflows and runs (#3718)

Co-authored-by: Jonathan Dobson <jon.m.dobson@gmail.com>
This commit is contained in:
Celal Zamanoglu
2025-10-16 16:04:53 +03:00
committed by GitHub
parent 427e674299
commit 5531367566
9 changed files with 700 additions and 18 deletions

View File

@@ -24,7 +24,12 @@ import {
} from "@/components/ui/table";
import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat";
import { cn } from "@/util/utils";
import { Pencil2Icon, PlayIcon } from "@radix-ui/react-icons";
import {
MagnifyingGlassIcon,
MixerHorizontalIcon,
Pencil2Icon,
PlayIcon,
} from "@radix-ui/react-icons";
import { useEffect, useState } from "react";
import {
Link,
@@ -35,6 +40,15 @@ 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,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { RunParametersDialog } from "./workflowRun/RunParametersDialog";
function WorkflowPage() {
const { workflowPermanentId } = useParams();
@@ -44,11 +58,15 @@ function WorkflowPage() {
const navigate = useNavigate();
const PAGE_SIZE = 10;
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search, 500);
const [openRunParams, setOpenRunParams] = useState<string | null>(null);
const { data: workflowRuns, isLoading } = useWorkflowRunsQuery({
workflowPermanentId,
statusFilters,
page,
search: debouncedSearch,
refetchOnMount: "always",
});
@@ -106,13 +124,31 @@ function WorkflowPage() {
</div>
</header>
<div className="space-y-4">
<header className="flex justify-between">
<header>
<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>
<StatusFilterDropdown
values={statusFilters}
onChange={setStatusFilters}
/>
</header>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
@@ -120,6 +156,7 @@ function WorkflowPage() {
<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>
</TableRow>
</TableHeader>
<TableBody>
@@ -173,12 +210,43 @@ function WorkflowPage() {
>
{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>
);
})
)}
</TableBody>
</Table>
<RunParametersDialog
open={openRunParams !== null}
onOpenChange={(open) => {
if (!open) setOpenRunParams(null);
}}
workflowPermanentId={workflowPermanentId}
workflowRunId={openRunParams}
/>
<Pagination className="pt-2">
<PaginationContent>
<PaginationItem>

View File

@@ -29,6 +29,7 @@ import { cn } from "@/util/utils";
import {
LightningBoltIcon,
MagnifyingGlassIcon,
MixerHorizontalIcon,
Pencil2Icon,
PlayIcon,
PlusIcon,
@@ -39,6 +40,7 @@ import { useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useDebounce } from "use-debounce";
import { NarrativeCard } from "./components/header/NarrativeCard";
import { WorkflowParametersDialog } from "./components/WorkflowParametersDialog";
import { useCreateWorkflowMutation } from "./hooks/useCreateWorkflowMutation";
import { ImportWorkflowButton } from "./ImportWorkflowButton";
import { WorkflowApiResponse } from "./types/workflowTypes";
@@ -63,6 +65,7 @@ function Workflows() {
const createWorkflowMutation = useCreateWorkflowMutation();
const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = useState("");
const [openWorkflowId, setOpenWorkflowId] = useState<string | null>(null);
const [debouncedSearch] = useDebounce(search, 500);
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
const itemsPerPage = searchParams.get("page_size")
@@ -79,7 +82,9 @@ function Workflows() {
params.append("page", String(page));
params.append("page_size", String(itemsPerPage));
params.append("only_workflows", "true");
params.append("title", debouncedSearch);
if (debouncedSearch) {
params.append("search_key", debouncedSearch);
}
return client
.get(`/workflows`, {
params,
@@ -96,7 +101,9 @@ function Workflows() {
params.append("page", String(page + 1));
params.append("page_size", String(itemsPerPage));
params.append("only_workflows", "true");
params.append("title", debouncedSearch);
if (debouncedSearch) {
params.append("search_key", debouncedSearch);
}
return client
.get(`/workflows`, {
params,
@@ -198,7 +205,7 @@ function Workflows() {
setSearch(event.target.value);
setParamPatch({ page: "1" });
}}
placeholder="Search by title..."
placeholder="Search by title or parameter..."
className="w-48 pl-9 lg:w-72"
/>
</div>
@@ -312,6 +319,29 @@ function Workflows() {
</Tooltip>
</TooltipProvider>
<WorkflowActions workflow={workflow} />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={() =>
setOpenWorkflowId(
workflow.workflow_permanent_id,
)
}
disabled={
!workflow.workflow_definition.parameters.some(
(p) => p.parameter_type !== "output",
)
}
>
<MixerHorizontalIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View Parameters</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
</TableRow>
@@ -320,6 +350,14 @@ function Workflows() {
)}
</TableBody>
</Table>
<WorkflowParametersDialog
open={openWorkflowId !== null}
onOpenChange={(open) => {
if (!open) setOpenWorkflowId(null);
}}
workflowId={openWorkflowId}
workflows={workflows}
/>
<div className="relative px-3 py-3">
<div className="absolute left-3 top-1/2 flex -translate-y-1/2 items-center gap-2 text-sm">
<span className="text-slate-400">Items per page</span>

View File

@@ -0,0 +1,121 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { EyeClosedIcon, EyeOpenIcon } from "@radix-ui/react-icons";
import { useState } from "react";
type ParameterItem = {
id: string;
key: string;
description?: string | null;
type?: string | null;
value?: string | null; // safe display value only; never raw secrets
};
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
title?: string;
sectionLabel?: string;
items: Array<ParameterItem>;
};
export function ParametersDialogBase({
open,
onOpenChange,
title = "Parameters",
sectionLabel = "Parameters",
items,
}: Props) {
const [revealedIds, setRevealedIds] = useState<Set<string>>(new Set());
function toggleReveal(id: string) {
setRevealedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function renderRow(item: ParameterItem) {
const revealed =
item.value !== undefined &&
item.value !== null &&
item.value !== "" &&
revealedIds.has(item.id);
const isRevealable =
item.value !== undefined && item.value !== null && item.value !== "";
return (
<div key={item.id} className="rounded-md border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="break-all font-mono text-sm">{item.key}</div>
{item.description ? (
<div className="text-xs text-slate-400">
{item.description}
</div>
) : null}
</div>
</div>
<div className="flex items-center gap-2">
{item.type ? <Badge variant="secondary">{item.type}</Badge> : null}
{isRevealable ? (
<Button
size="sm"
variant="outline"
onClick={() => toggleReveal(item.id)}
title={revealed ? "Hide value" : "Show value"}
>
{revealed ? (
<EyeClosedIcon className="h-4 w-4" />
) : (
<EyeOpenIcon className="h-4 w-4" />
)}
</Button>
) : null}
</div>
</div>
{isRevealable ? (
<div className="mt-2">
<div className="rounded bg-slate-elevation2 p-2 font-mono text-xs">
{revealed ? item.value : "••••••"}
</div>
</div>
) : null}
</div>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{items.length === 0 ? (
<div className="text-sm text-slate-400">No parameters.</div>
) : (
<div className="space-y-3">
<Label className="text-xs">{sectionLabel}</Label>
<ScrollArea>
<ScrollAreaViewport className="max-h-[420px]">
<div className="space-y-3">
{items.map((it) => renderRow(it))}
</div>
</ScrollAreaViewport>
</ScrollArea>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,179 @@
import { useMemo } from "react";
import { ParametersDialogBase } from "./ParametersDialogBase";
import {
WorkflowApiResponse,
WorkflowParameter,
WorkflowParameterTypes,
Parameter,
CredentialParameter,
AWSSecretParameter,
OnePasswordCredentialParameter,
AzureVaultCredentialParameter,
BitwardenLoginCredentialParameter,
BitwardenSensitiveInformationParameter,
BitwardenCreditCardDataParameter,
ContextParameter,
} from "../types/workflowTypes";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
workflowId: string | null;
workflows: Array<WorkflowApiResponse>;
};
function getParameterId(param: Parameter): string {
if ("workflow_parameter_id" in param && param.workflow_parameter_id)
return param.workflow_parameter_id;
if ("credential_parameter_id" in param && param.credential_parameter_id)
return param.credential_parameter_id;
if ("aws_secret_parameter_id" in param && param.aws_secret_parameter_id)
return param.aws_secret_parameter_id;
if (
"onepassword_credential_parameter_id" in param &&
param.onepassword_credential_parameter_id
)
return param.onepassword_credential_parameter_id;
if (
"azure_vault_credential_parameter_id" in param &&
param.azure_vault_credential_parameter_id
)
return param.azure_vault_credential_parameter_id;
if (
"bitwarden_login_credential_parameter_id" in param &&
param.bitwarden_login_credential_parameter_id
)
return param.bitwarden_login_credential_parameter_id;
if (
"bitwarden_sensitive_information_parameter_id" in param &&
param.bitwarden_sensitive_information_parameter_id
)
return param.bitwarden_sensitive_information_parameter_id;
if (
"bitwarden_credit_card_data_parameter_id" in param &&
param.bitwarden_credit_card_data_parameter_id
)
return param.bitwarden_credit_card_data_parameter_id;
if ("output_parameter_id" in param && param.output_parameter_id)
return param.output_parameter_id;
return param.key;
}
function getParameterDisplayType(param: Parameter): string {
return param.parameter_type;
}
function getParameterDisplayValue(param: Parameter): string | null {
switch (param.parameter_type) {
case "workflow": {
const p = param as WorkflowParameter;
const value = p.default_value;
try {
return value === null || value === undefined
? ""
: typeof value === "string"
? value
: JSON.stringify(value);
} catch {
return String(value);
}
}
case "credential": {
// Show referenced credential id; do not reveal secrets
return "credential_id" in param
? String((param as CredentialParameter).credential_id)
: null;
}
case "aws_secret": {
// Show the AWS secret key reference only
return "aws_key" in param
? String((param as AWSSecretParameter).aws_key)
: null;
}
case "onepassword": {
const p = param as OnePasswordCredentialParameter;
if (p.vault_id && p.item_id) return `${p.vault_id} / ${p.item_id}`;
return null;
}
case "azure_vault_credential": {
const p = param as AzureVaultCredentialParameter;
return p.vault_name ? `${p.vault_name}` : null;
}
case "bitwarden_login_credential": {
const p = param as BitwardenLoginCredentialParameter;
return p.bitwarden_item_id ?? p.bitwarden_collection_id ?? null;
}
case "bitwarden_sensitive_information": {
const p = param as BitwardenSensitiveInformationParameter;
return p.bitwarden_identity_key ?? null;
}
case "bitwarden_credit_card_data": {
const p = param as BitwardenCreditCardDataParameter;
return p.bitwarden_item_id ?? null;
}
case "context": {
const p = param as ContextParameter;
if ("value" in p && p.value !== undefined) {
try {
return typeof p.value === "string"
? p.value
: JSON.stringify(p.value);
} catch {
return String(p.value);
}
}
return null;
}
default:
return null;
}
}
// Row rendering moved inside component to access local reveal state
export function WorkflowParametersDialog({
open,
onOpenChange,
workflowId,
workflows,
}: Props) {
const workflow = useMemo(
() => workflows?.find((w) => w.workflow_permanent_id === workflowId),
[workflows, workflowId],
);
const items = useMemo(() => {
const params = workflow
? (workflow.workflow_definition.parameters.filter(
(p) =>
p.parameter_type === WorkflowParameterTypes.Workflow ||
p.parameter_type === "credential" ||
p.parameter_type === "aws_secret" ||
p.parameter_type === "onepassword" ||
p.parameter_type === "azure_vault_credential" ||
p.parameter_type === "bitwarden_login_credential" ||
p.parameter_type === "bitwarden_sensitive_information" ||
p.parameter_type === "bitwarden_credit_card_data" ||
p.parameter_type === "context",
) as Parameter[])
: ([] as Parameter[]);
return params.map((param) => ({
id: getParameterId(param),
key: param.key,
description:
"description" in param ? param.description ?? undefined : undefined,
type: getParameterDisplayType(param),
value: getParameterDisplayValue(param),
}));
}, [workflow]);
return (
<ParametersDialogBase
open={open}
onOpenChange={onOpenChange}
title="Parameters"
sectionLabel="Workflow-level parameters"
items={items}
/>
);
}

View File

@@ -14,19 +14,27 @@ type Props = {
workflowPermanentId?: string;
statusFilters?: Array<Status>;
page: number;
search?: string;
} & UseQueryOptions;
function useWorkflowRunsQuery({
workflowPermanentId,
statusFilters,
page,
search,
...queryOptions
}: Props) {
const { data: globalWorkflows } = useGlobalWorkflowsQuery();
const credentialGetter = useCredentialGetter();
return useQuery<Array<WorkflowRunApiResponse>>({
queryKey: ["workflowRuns", { statusFilters }, workflowPermanentId, page],
queryKey: [
"workflowRuns",
{ statusFilters },
workflowPermanentId,
page,
search,
],
queryFn: async () => {
const client = await getClient(credentialGetter);
const params = new URLSearchParams();
@@ -42,6 +50,9 @@ function useWorkflowRunsQuery({
params.append("status", status);
});
}
if (search) {
params.append("search_key", search);
}
return client
.get(`/workflows/${workflowPermanentId}/runs`, {

View File

@@ -0,0 +1,82 @@
import { ParametersDialogBase } from "../components/ParametersDialogBase";
import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { getClient } from "@/api/AxiosClient";
import { useQuery } from "@tanstack/react-query";
import { useWorkflowQuery } from "../hooks/useWorkflowQuery";
import { WorkflowRunStatusApiResponse } from "@/api/types";
import { Parameter } from "../types/workflowTypes";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
workflowPermanentId?: string;
workflowRunId: string | null;
};
export function RunParametersDialog({
open,
onOpenChange,
workflowPermanentId,
workflowRunId,
}: Props) {
const { data: globalWorkflows } = useGlobalWorkflowsQuery();
const credentialGetter = useCredentialGetter();
const { data: workflow } = useWorkflowQuery({ workflowPermanentId });
const { data: run } = useQuery<WorkflowRunStatusApiResponse>({
queryKey: ["workflowRun", workflowPermanentId, workflowRunId, "dialog"],
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,
});
const defByKey = new Map(
(workflow?.workflow_definition.parameters ?? []).map((p: Parameter) => [
p.key,
p,
]),
);
const items = Object.entries(run?.parameters ?? {}).map(([key, value]) => {
const def = defByKey.get(key);
const description =
def && "description" in def ? def.description ?? undefined : undefined;
const type = def ? def.parameter_type ?? undefined : undefined;
const displayValue =
value === null || value === undefined
? ""
: typeof value === "string"
? value
: JSON.stringify(value);
return {
id: key,
key,
description,
type,
value: displayValue,
};
});
return (
<ParametersDialogBase
open={open}
onOpenChange={onOpenChange}
title="Run Parameters"
sectionLabel="Input parameters for this run"
items={items}
/>
);
}