diff --git a/keep-ui/app/(keep)/incidents/[id]/getIncidentWithErrorHandling.tsx b/keep-ui/app/(keep)/incidents/[id]/getIncidentWithErrorHandling.tsx index 29091a184..ad6b5f033 100644 --- a/keep-ui/app/(keep)/incidents/[id]/getIncidentWithErrorHandling.tsx +++ b/keep-ui/app/(keep)/incidents/[id]/getIncidentWithErrorHandling.tsx @@ -3,10 +3,17 @@ import { createServerApiClient } from "@/shared/api/server"; import { notFound } from "next/navigation"; import { KeepApiError } from "@/shared/api"; import { IncidentDto } from "@/entities/incidents/model"; +import { cache } from "react"; -export async function getIncidentWithErrorHandling( - id: string, - redirect = true +/** + * Fetches an incident by ID with error handling for 404 cases + * @param id - The unique identifier of the incident to retrieve + * @returns Promise containing the incident data + * @throws {Error} If the API request fails for reasons other than 404 + * @throws {never} If 404 error occurs (handled by Next.js notFound) + */ +async function _getIncidentWithErrorHandling( + id: string // @ts-ignore ignoring since not found will be handled by nextjs ): Promise { try { @@ -14,10 +21,15 @@ export async function getIncidentWithErrorHandling( const incident = await getIncident(api, id); return incident; } catch (error) { - if (error instanceof KeepApiError && error.statusCode === 404 && redirect) { + if (error instanceof KeepApiError && error.statusCode === 404) { notFound(); } else { throw error; } } } + +// cache the function for server side, so we can use it in the layout, metadata and in the page itself +export const getIncidentWithErrorHandling = cache( + _getIncidentWithErrorHandling +); diff --git a/keep-ui/app/(keep)/incidents/[id]/layout.tsx b/keep-ui/app/(keep)/incidents/[id]/layout.tsx index c16338dd0..0fa3690a0 100644 --- a/keep-ui/app/(keep)/incidents/[id]/layout.tsx +++ b/keep-ui/app/(keep)/incidents/[id]/layout.tsx @@ -13,7 +13,7 @@ export default async function Layout({ const AIEnabled = !!process.env.OPEN_AI_API_KEY || !!process.env.OPENAI_API_KEY; try { - const incident = await getIncidentWithErrorHandling(serverParams.id, false); + const incident = await getIncidentWithErrorHandling(serverParams.id); return ( {children} diff --git a/keep-ui/app/(keep)/incidents/layout.tsx b/keep-ui/app/(keep)/incidents/layout.tsx index 68c3ab1c5..1e9800390 100644 --- a/keep-ui/app/(keep)/incidents/layout.tsx +++ b/keep-ui/app/(keep)/incidents/layout.tsx @@ -1,5 +1,5 @@ "use client"; -import { IncidentFilterContextProvider } from "../../../features/incident-list/ui/incident-table-filters-context"; +import { IncidentFilterContextProvider } from "@/features/incident-list/ui/incident-table-filters-context"; export default function Layout({ children }: { children: any }) { return ( diff --git a/keep-ui/app/(keep)/workflows/[workflow_id]/layout.tsx b/keep-ui/app/(keep)/workflows/[workflow_id]/layout.tsx index e88491aab..2bfbea978 100644 --- a/keep-ui/app/(keep)/workflows/[workflow_id]/layout.tsx +++ b/keep-ui/app/(keep)/workflows/[workflow_id]/layout.tsx @@ -1,37 +1,22 @@ -"use client"; - -import { Link } from "@/components/ui"; -import { ArrowRightIcon } from "@heroicons/react/16/solid"; -import { Icon, Subtitle } from "@tremor/react"; -import { useParams } from "next/navigation"; +import { getWorkflowWithRedirectSafe } from "@/shared/api/workflows"; +import { WorkflowBreadcrumbs } from "./workflow-breadcrumbs"; import WorkflowDetailHeader from "./workflow-detail-header"; -export default function Layout({ +export default async function Layout({ children, params, }: { - children: any; + children: React.ReactNode; params: { workflow_id: string }; }) { - const clientParams = useParams(); + const workflow = await getWorkflowWithRedirectSafe(params.workflow_id); return (
- - All Workflows{" "} - {" "} - {clientParams.workflow_execution_id ? ( - <> - - Workflow Details - - Workflow - Execution Details - - ) : ( - "Workflow Details" - )} - - + +
{children}
); diff --git a/keep-ui/app/(keep)/workflows/[workflow_id]/page.tsx b/keep-ui/app/(keep)/workflows/[workflow_id]/page.tsx index bb4d2ff6c..facbd0c29 100644 --- a/keep-ui/app/(keep)/workflows/[workflow_id]/page.tsx +++ b/keep-ui/app/(keep)/workflows/[workflow_id]/page.tsx @@ -1,8 +1,14 @@ import { Metadata } from "next"; import WorkflowDetailPage from "./workflow-detail-page"; +import { getWorkflowWithRedirectSafe } from "@/shared/api/workflows"; -export default function Page({ params }: { params: { workflow_id: string } }) { - return ; +export default async function Page({ + params, +}: { + params: { workflow_id: string }; +}) { + const initialData = await getWorkflowWithRedirectSafe(params.workflow_id); + return ; } export const metadata: Metadata = { diff --git a/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-breadcrumbs.tsx b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-breadcrumbs.tsx new file mode 100644 index 000000000..258a04b28 --- /dev/null +++ b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-breadcrumbs.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Icon } from "@tremor/react"; +import { useParams } from "next/navigation"; +import { Link } from "@/components/ui"; +import { Subtitle } from "@tremor/react"; +import { ArrowRightIcon } from "@heroicons/react/16/solid"; + +export function WorkflowBreadcrumbs({ workflowId }: { workflowId: string }) { + const clientParams = useParams(); + + return ( + + All Workflows{" "} + {" "} + {clientParams.workflow_execution_id ? ( + <> + Workflow Details + Workflow + Execution Details + + ) : ( + "Workflow Details" + )} + + ); +} diff --git a/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-detail-header.tsx b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-detail-header.tsx index 34c39252f..89bdf088c 100644 --- a/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-detail-header.tsx +++ b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-detail-header.tsx @@ -9,9 +9,11 @@ import { useWorkflowRun } from "@/utils/hooks/useWorkflowRun"; import AlertTriggerModal from "../workflow-run-with-alert-modal"; export default function WorkflowDetailHeader({ - workflow_id, + workflowId: workflow_id, + initialData, }: { - workflow_id: string; + workflowId: string; + initialData?: Workflow; }) { const api = useApi(); const { @@ -20,10 +22,10 @@ export default function WorkflowDetailHeader({ error, } = useSWR>( api.isReady() ? `/workflows/${workflow_id}` : null, - (url: string) => api.get(url) + (url: string) => api.get(url), + { fallbackData: initialData, revalidateOnMount: false } ); - const { isRunning, handleRunClick, @@ -36,14 +38,18 @@ export default function WorkflowDetailHeader({ return
Error loading workflow
; } - if (isLoading || !workflow) { + if (!workflow) { return ( -
-

- -

- - +
+
+ +
+
+ +
+
+ +
); } @@ -52,10 +58,12 @@ export default function WorkflowDetailHeader({
-

{workflow.name}

+

+ {workflow.name} +

{workflow.description && ( - {workflow.description} + {workflow.description} )}
diff --git a/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-detail-page.tsx b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-detail-page.tsx index b630f6a59..6a38fb3a9 100644 --- a/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-detail-page.tsx +++ b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-detail-page.tsx @@ -14,7 +14,6 @@ import { CodeBracketIcon, WrenchIcon, } from "@heroicons/react/24/outline"; -import Loading from "@/app/(keep)/loading"; import { Workflow } from "@/shared/api/workflows"; import useSWR from "swr"; import { WorkflowBuilderPageClient } from "../builder/page.client"; @@ -23,11 +22,14 @@ import { useApi } from "@/shared/lib/hooks/useApi"; import { useConfig } from "utils/hooks/useConfig"; import { AiOutlineSwap } from "react-icons/ai"; import { ErrorComponent, TabNavigationLink, YAMLCodeblock } from "@/shared/ui"; +import Skeleton from "react-loading-skeleton"; export default function WorkflowDetailPage({ params, + initialData, }: { params: { workflow_id: string }; + initialData?: Workflow; }) { const api = useApi(); const { data: configData } = useConfig(); @@ -37,9 +39,12 @@ export default function WorkflowDetailPage({ data: workflow, isLoading, error, - } = useSWR>( + } = useSWR( api.isReady() ? `/workflows/${params.workflow_id}` : null, - (url: string) => api.get(url) + (url: string) => api.get(url), + { + fallbackData: initialData, + } ); const docsUrl = configData?.KEEP_DOCS_URL || "https://docs.keephq.dev"; @@ -48,10 +53,6 @@ export default function WorkflowDetailPage({ return ; } - if (isLoading || !workflow) { - return ; - } - // TODO: change url to /workflows/[workflow_id]/[tab] or use the file-based routing const handleTabChange = (index: number) => { setTabIndex(index); @@ -84,20 +85,28 @@ export default function WorkflowDetailPage({ - - - + {!workflow ? ( + + ) : ( + + + + )} - - - + {!workflow ? ( + + ) : ( + + + + )} diff --git a/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-overview-skeleton.tsx b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-overview-skeleton.tsx new file mode 100644 index 000000000..24be49130 --- /dev/null +++ b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-overview-skeleton.tsx @@ -0,0 +1,28 @@ +import Skeleton from "react-loading-skeleton"; + +export function WorkflowOverviewSkeleton() { + return ( +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+ ); +} diff --git a/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-overview.tsx b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-overview.tsx index 394501bf8..532a0cf4d 100644 --- a/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-overview.tsx +++ b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-overview.tsx @@ -10,6 +10,7 @@ import { Workflow } from "@/shared/api/workflows"; import WorkflowGraph from "../workflow-graph"; import { TableFilters } from "./table-filters"; import { ExecutionTable } from "./workflow-execution-table"; +import { WorkflowOverviewSkeleton } from "./workflow-overview-skeleton"; interface Pagination { limit: number; @@ -80,7 +81,7 @@ export default function WorkflowOverview({ return (
{/* TODO: Add a working time filter */} - {!data || isLoading || (isValidating && )} + {(!data || isLoading || isValidating) && } {data?.items && (
diff --git a/keep-ui/app/(keep)/workflows/builder/CustomEdge.tsx b/keep-ui/app/(keep)/workflows/builder/CustomEdge.tsx index 502f3b90b..6b4a9d2b3 100644 --- a/keep-ui/app/(keep)/workflows/builder/CustomEdge.tsx +++ b/keep-ui/app/(keep)/workflows/builder/CustomEdge.tsx @@ -120,6 +120,11 @@ const CustomEdge: React.FC = ({ onClick={(e) => { setSelectedEdge(id); }} + data-testid={ + source === "trigger_start" + ? "wf-add-trigger-button" + : "wf-add-step-button" + } > import("./builder"), { ssr: false, // Prevents server-side rendering @@ -13,32 +15,21 @@ const Builder = dynamic(() => import("./builder"), { interface Props { fileContents: string | null; fileName: string; - enableButtons: () => void; - enableGenerate: (state: boolean) => void; - triggerGenerate: number; - triggerSave: number; - triggerRun: number; workflow?: string; workflowId?: string; - isPreview?: boolean; } export function BuilderCard({ fileContents, fileName, - enableButtons, - enableGenerate, - triggerGenerate, - triggerRun, - triggerSave, workflow, workflowId, - isPreview, }: Props) { const [providers, setProviders] = useState(null); const [installedProviders, setInstalledProviders] = useState< Provider[] | null >(null); + const { enableButtons } = useWorkflowBuilderContext(); const { data, error, isLoading } = useProviders(); @@ -46,14 +37,14 @@ export function BuilderCard({ if (data && !providers && !installedProviders) { setProviders(data.providers); setInstalledProviders(data.installed_providers); - enableButtons(); + enableButtons(true); } }, [data, providers, installedProviders, enableButtons]); if (!providers || isLoading) return ( - + ); @@ -75,7 +66,7 @@ export function BuilderCard({ if (fileContents == "" && !workflow) { return ( - + ); } @@ -86,13 +77,8 @@ export function BuilderCard({ installedProviders={installedProviders} loadedAlertFile={fileContents} fileName={fileName} - enableGenerate={enableGenerate} - triggerGenerate={triggerGenerate} - triggerSave={triggerSave} - triggerRun={triggerRun} workflow={workflow} workflowId={workflowId} - isPreview={isPreview} /> ); } diff --git a/keep-ui/app/(keep)/workflows/builder/builder-modal.tsx b/keep-ui/app/(keep)/workflows/builder/builder-modal.tsx index 578dcfe6b..c8608827d 100644 --- a/keep-ui/app/(keep)/workflows/builder/builder-modal.tsx +++ b/keep-ui/app/(keep)/workflows/builder/builder-modal.tsx @@ -1,12 +1,12 @@ import { XMarkIcon } from "@heroicons/react/24/outline"; import { Button, Card, Subtitle, Title } from "@tremor/react"; import { stringify } from "yaml"; -import { Alert } from "./legacy-workflow.types"; +import { LegacyWorkflow } from "./legacy-workflow.types"; import { YAMLCodeblock } from "@/shared/ui"; interface Props { closeModal: () => void; - compiledAlert: Alert | string | null; + compiledAlert: LegacyWorkflow | string | null; id?: string; hideCloseButton?: boolean; } diff --git a/keep-ui/app/(keep)/workflows/builder/builder-store.tsx b/keep-ui/app/(keep)/workflows/builder/builder-store.tsx index d16d3b2b5..6dbbc3654 100644 --- a/keep-ui/app/(keep)/workflows/builder/builder-store.tsx +++ b/keep-ui/app/(keep)/workflows/builder/builder-store.tsx @@ -131,8 +131,28 @@ export type FlowState = { setSynced: (synced: boolean) => void; canDeploy: boolean; setCanDeploy: (deploy: boolean) => void; + reset: () => void; }; +export type FlowStateValues = Pick< + FlowState, + | "nodes" + | "edges" + | "selectedNode" + | "v2Properties" + | "openGlobalEditor" + | "stepEditorOpenForNode" + | "toolboxConfiguration" + | "isLayouted" + | "selectedEdge" + | "changes" + | "firstInitilisationDone" + | "lastSavedChanges" + | "errorNode" + | "synced" + | "canDeploy" +>; + export type StoreGet = () => FlowState; export type StoreSet = ( state: @@ -266,7 +286,7 @@ function addNodeBetween( } } -const useStore = create((set, get) => ({ +const defaultState: FlowStateValues = { nodes: [], edges: [], selectedNode: null, @@ -282,6 +302,10 @@ const useStore = create((set, get) => ({ errorNode: null, synced: true, canDeploy: false, +}; + +const useStore = create((set, get) => ({ + ...defaultState, setCanDeploy: (deploy) => set({ canDeploy: deploy }), setSynced: (sync) => set({ synced: sync }), setErrorNode: (id) => set({ errorNode: id }), @@ -553,6 +577,7 @@ const useStore = create((set, get) => ({ }; set({ nodes: [...get().nodes, newNode] }); }, + reset: () => set(defaultState), })); export default useStore; diff --git a/keep-ui/app/(keep)/workflows/builder/builder.tsx b/keep-ui/app/(keep)/workflows/builder/builder.tsx index 6de4924c7..7ee1670a0 100644 --- a/keep-ui/app/(keep)/workflows/builder/builder.tsx +++ b/keep-ui/app/(keep)/workflows/builder/builder.tsx @@ -1,21 +1,22 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Callout, Card } from "@tremor/react"; import { Provider } from "../../providers/providers"; import { parseWorkflow, generateWorkflow, getToolboxConfiguration, - buildAlert, + getWorkflowFromDefinition, wrapDefinitionV2, + DefinitionV2, } from "./utils"; import { CheckCircleIcon, ExclamationCircleIcon, } from "@heroicons/react/20/solid"; import { globalValidatorV2, stepValidatorV2 } from "./builder-validators"; -import { Alert } from "./legacy-workflow.types"; +import { LegacyWorkflow } from "./legacy-workflow.types"; import BuilderModalContent from "./builder-modal"; -import Loader from "./loader"; +import { EmptyBuilderState } from "./empty-builder-state"; import { stringify } from "yaml"; import { useRouter, useSearchParams } from "next/navigation"; import { v4 as uuidv4 } from "uuid"; @@ -35,19 +36,16 @@ import useStore from "./builder-store"; import { toast } from "react-toastify"; import { useApi } from "@/shared/lib/hooks/useApi"; import { KeepApiError } from "@/shared/api"; -import { showErrorToast, showSuccessToast } from "@/shared/ui"; +import { showErrorToast } from "@/shared/ui"; import { YAMLException } from "js-yaml"; -import { revalidatePath } from "next/cache"; import Modal from "@/components/ui/Modal"; +import { useWorkflowActions } from "@/entities/workflows/model/useWorkflowActions"; +import { useWorkflowBuilderContext } from "./workflow-builder-context"; interface Props { loadedAlertFile: string | null; fileName: string; providers: Provider[]; - enableGenerate: (status: boolean) => void; - triggerGenerate: number; - triggerSave: number; - triggerRun: number; workflow?: string; workflowId?: string; installedProviders?: Provider[] | undefined | null; @@ -64,10 +62,6 @@ function Builder({ loadedAlertFile, fileName, providers, - enableGenerate, - triggerGenerate, - triggerSave, - triggerRun, workflow, workflowId, installedProviders, @@ -87,50 +81,47 @@ function Builder({ const [runningWorkflowExecution, setRunningWorkflowExecution] = useState< WorkflowExecutionDetail | WorkflowExecutionFailure | null >(null); - const [compiledAlert, setCompiledAlert] = useState(null); + const [legacyWorkflow, setLegacyWorkflow] = useState( + null + ); + const { createWorkflow, updateWorkflow } = useWorkflowActions(); + const { + enableGenerate, + triggerGenerate, + triggerSave, + triggerRun, + setIsSaving, + } = useWorkflowBuilderContext(); const router = useRouter(); const searchParams = useSearchParams(); - const { errorNode, setErrorNode, canDeploy, synced } = useStore(); - - const setStepValidationErrorV2 = (step: V2Step, error: string | null) => { - setStepValidationError(error); - if (error && step) { - return setErrorNode(step.id); - } - setErrorNode(null); - }; + const { errorNode, setErrorNode, synced, reset, canDeploy } = useStore(); - const setGlobalValidationErrorV2 = ( - id: string | null, - error: string | null - ) => { - setGlobalValidationError(error); - if (error && id) { - return setErrorNode(id); - } - setErrorNode(null); - }; + const setStepValidationErrorV2 = useCallback( + (step: V2Step, error: string | null) => { + setStepValidationError(error); + if (error && step) { + return setErrorNode(step.id); + } + setErrorNode(null); + }, + [setStepValidationError, setErrorNode] + ); - const updateWorkflow = useCallback(() => { - const body = stringify(buildAlert(definition.value)); - api - .request(`/workflows/${workflowId}`, { - method: "PUT", - body, - headers: { "Content-Type": "text/html" }, - }) - .then(() => { - showSuccessToast("Workflow deployed successfully"); - }) - .catch((error: any) => { - showErrorToast(error, "Failed to add workflow"); - }); - }, [api, definition.value, workflowId]); + const setGlobalValidationErrorV2 = useCallback( + (id: string | null, error: string | null) => { + setGlobalValidationError(error); + if (error && id) { + return setErrorNode(id); + } + setErrorNode(null); + }, + [setGlobalValidationError, setErrorNode] + ); const testRunWorkflow = () => { setTestRunModalOpen(true); - const body = stringify(buildAlert(definition.value)); + const body = stringify(getWorkflowFromDefinition(definition.value)); api .request(`/workflows/test`, { method: "POST", @@ -150,72 +141,64 @@ function Builder({ }); }; - const addWorkflow = useCallback(() => { - const body = stringify(buildAlert(definition.value)); - api - .request(`/workflows/json`, { - method: "POST", - body, - headers: { "Content-Type": "text/html" }, - }) - .then(({ workflow_id }) => { - // This is important because it makes sure we will re-fetch the workflow if we get to this page again. - // router.push for instance, optimizes re-render of same pages and we don't want that here because of "cache". - showSuccessToast("Workflow added successfully"); - revalidatePath("/workflows/builder"); - router.push(`/workflows/${workflow_id}`); - }) - .catch((error) => { - alert(`Error: ${error}`); - }); - }, [api, definition.value, router]); - - useEffect(() => { - setIsLoading(true); - try { - if (workflow) { - setDefinition( - wrapDefinitionV2({ - ...parseWorkflow(workflow, providers), - isValid: true, - }) - ); - } else if (loadedAlertFile == null) { - const alertUuid = uuidv4(); - const alertName = searchParams?.get("alertName"); - const alertSource = searchParams?.get("alertSource"); - let triggers = {}; - if (alertName && alertSource) { - triggers = { alert: { source: alertSource, name: alertName } }; + useEffect( + function updateDefinitionFromInput() { + setIsLoading(true); + try { + if (workflow) { + setDefinition( + wrapDefinitionV2({ + ...parseWorkflow(workflow, providers), + isValid: true, + }) + ); + } else if (loadedAlertFile == null) { + const alertUuid = uuidv4(); + const alertName = searchParams?.get("alertName"); + const alertSource = searchParams?.get("alertSource"); + let triggers = {}; + if (alertName && alertSource) { + triggers = { alert: { source: alertSource, name: alertName } }; + } + setDefinition( + wrapDefinitionV2({ + ...generateWorkflow( + alertUuid, + "", + "", + false, + {}, + [], + [], + triggers + ), + isValid: true, + }) + ); + } else { + const parsedDefinition = parseWorkflow(loadedAlertFile!, providers); + setDefinition( + wrapDefinitionV2({ + ...parsedDefinition, + isValid: true, + }) + ); + } + } catch (error) { + if (error instanceof YAMLException) { + showErrorToast(error, "Invalid YAML: " + error.message); + } else { + showErrorToast(error, "Failed to load workflow"); } - setDefinition( - wrapDefinitionV2({ - ...generateWorkflow(alertUuid, "", "", false, {}, [], [], triggers), - isValid: true, - }) - ); - } else { - const parsedDefinition = parseWorkflow(loadedAlertFile!, providers); - setDefinition( - wrapDefinitionV2({ - ...parsedDefinition, - isValid: true, - }) - ); - } - } catch (error) { - if (error instanceof YAMLException) { - showErrorToast(error, "Invalid YAML: " + error.message); - } else { - showErrorToast(error, "Failed to load workflow"); } - } - setIsLoading(false); - }, [loadedAlertFile, workflow, searchParams, providers]); + setIsLoading(false); + }, + [loadedAlertFile, workflow, searchParams, providers] + ); useEffect(() => { if (triggerGenerate) { - setCompiledAlert(buildAlert(definition.value)); + setLegacyWorkflow(getWorkflowFromDefinition(definition.value)); if (!generateModalIsOpen) setGenerateModalIsOpen(true); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -228,47 +211,71 @@ function Builder({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [triggerRun]); - useEffect(() => { - if (triggerSave) { - if (!synced) { - toast( - "Please save the previous step or wait while properties sync with the workflow." - ); - return; - } - if (workflowId) { - updateWorkflow(); - } else { - addWorkflow(); - } + const saveWorkflow = useCallback(async () => { + if (!synced) { + toast( + "Please save the previous step or wait while properties sync with the workflow." + ); + return; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [triggerSave]); - - useEffect(() => { - if (canDeploy && !errorNode && definition.isValid) { - if (!synced) { - toast( - "Please save the previous step or wait while properties sync with the workflow." - ); - return; - } + if (errorNode || !definition.isValid) { + showErrorToast("Please fix the errors in the workflow before saving."); + return; + } + try { + setIsSaving(true); if (workflowId) { - updateWorkflow(); + await updateWorkflow(workflowId, definition.value); } else { - addWorkflow(); + const response = await createWorkflow(definition.value); + if (response?.workflow_id) { + router.push(`/workflows/${response.workflow_id}`); + } } + } catch (error) { + console.error(error); + } finally { + setIsSaving(false); } }, [ - canDeploy, + synced, errorNode, definition.isValid, - synced, + definition.value, + setIsSaving, workflowId, updateWorkflow, - addWorkflow, + createWorkflow, + router, ]); + // save workflow on "Deploy" button click + useEffect(() => { + if (triggerSave) { + saveWorkflow(); + } + // ignore since we want the latest values, but to run effect only when triggerSave changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [triggerSave]); + + // save workflow on "Save & Deploy" button click from FlowEditor + useEffect(() => { + if (canDeploy) { + saveWorkflow(); + } + // ignore since we want the latest values, but to run effect only when triggerSave changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [canDeploy]); + + useEffect( + function resetZustandStateOnUnMount() { + return () => { + reset(); + }; + }, + [reset] + ); + useEffect(() => { enableGenerate( (definition.isValid && @@ -283,14 +290,6 @@ function Builder({ definition.isValid, ]); - if (isLoading) { - return ( - - - - ); - } - const ValidatorConfigurationV2: { step: ( step: V2Step, @@ -298,11 +297,21 @@ function Builder({ definition?: ReactFlowDefinition ) => boolean; root: (def: FlowDefinition) => boolean; - } = { - step: (step, parent, definition) => - stepValidatorV2(step, setStepValidationErrorV2, parent, definition), - root: (def) => globalValidatorV2(def, setGlobalValidationErrorV2), - }; + } = useMemo(() => { + return { + step: (step, parent, definition) => + stepValidatorV2(step, setStepValidationErrorV2, parent, definition), + root: (def) => globalValidatorV2(def, setGlobalValidationErrorV2), + }; + }, [setStepValidationErrorV2, setGlobalValidationErrorV2]); + + if (isLoading) { + return ( + + + + ); + } function closeGenerateModal() { setGenerateModalIsOpen(false); @@ -344,7 +353,7 @@ function Builder({ > Please start by loading or creating a new workflow - Load YAML or use the "New " button from the top right menu + Load YAML or use the "New" button from the top right menu
); diff --git a/keep-ui/app/(keep)/workflows/builder/legacy-workflow.types.ts b/keep-ui/app/(keep)/workflows/builder/legacy-workflow.types.ts index 4fa28c200..594a74c60 100644 --- a/keep-ui/app/(keep)/workflows/builder/legacy-workflow.types.ts +++ b/keep-ui/app/(keep)/workflows/builder/legacy-workflow.types.ts @@ -30,7 +30,7 @@ export interface Action extends Step { foreach?: string; } -export interface Alert { +export interface LegacyWorkflow { id: string; description?: string; owners?: string[]; diff --git a/keep-ui/app/(keep)/workflows/builder/page.client.tsx b/keep-ui/app/(keep)/workflows/builder/page.client.tsx index 618a261e2..99dd3cd3a 100644 --- a/keep-ui/app/(keep)/workflows/builder/page.client.tsx +++ b/keep-ui/app/(keep)/workflows/builder/page.client.tsx @@ -12,20 +12,20 @@ import { BuilderCard } from "./builder-card"; import { loadWorkflowYAML } from "./utils"; import { showErrorToast } from "@/shared/ui"; import { YAMLException } from "js-yaml"; +import { WorkflowBuilderContext } from "./workflow-builder-context"; export function WorkflowBuilderPageClient({ workflowRaw: workflow, workflowId, - isPreview, }: { workflowRaw?: string; workflowId?: string; - isPreview?: boolean; }) { const [buttonsEnabled, setButtonsEnabled] = useState(false); const [generateEnabled, setGenerateEnabled] = useState(false); const [triggerGenerate, setTriggerGenerate] = useState(0); const [triggerSave, setTriggerSave] = useState(0); + const [isSaving, setIsSaving] = useState(false); const [triggerRun, setTriggerRun] = useState(0); const [fileContents, setFileContents] = useState(""); const [fileName, setFileName] = useState(""); @@ -49,7 +49,7 @@ export function WorkflowBuilderPageClient({ } } - const enableButtons = () => setButtonsEnabled(true); + const enableButtons = (state: boolean) => setButtonsEnabled(state); const enableGenerate = (state: boolean) => setGenerateEnabled(state); function handleFileChange(event: any) { @@ -80,90 +80,96 @@ export function WorkflowBuilderPageClient({ return (
-
-
- {workflow ? "Edit" : "New"} Workflow -
-
- {!workflow && ( - <> - - - - - )} - - - {!workflow && ( + +
+
+ {workflowId ? "Edit" : "New"} Workflow +
+
+ {!workflow && ( + <> + + + + + )} + - )} + {!workflow && ( + + )} +
-
- + +
); } diff --git a/keep-ui/app/(keep)/workflows/builder/utils.tsx b/keep-ui/app/(keep)/workflows/builder/utils.tsx index d9d67243e..98e6151ec 100644 --- a/keep-ui/app/(keep)/workflows/builder/utils.tsx +++ b/keep-ui/app/(keep)/workflows/builder/utils.tsx @@ -1,6 +1,6 @@ import { load, JSON_SCHEMA } from "js-yaml"; import { Provider } from "../../providers/providers"; -import { Action, Alert } from "./legacy-workflow.types"; +import { Action, LegacyWorkflow } from "./legacy-workflow.types"; import { v4 as uuidv4 } from "uuid"; import { Definition, @@ -396,7 +396,9 @@ function getActionsFromCondition( return compiledActions; } -export function buildAlert(definition: Definition): Alert { +export function getWorkflowFromDefinition( + definition: Definition +): LegacyWorkflow { const alert = definition; const alertId = alert.properties.id as string; const name = (alert.properties.name as string) ?? ""; @@ -544,9 +546,17 @@ export function buildAlert(definition: Definition): Alert { consts: consts, steps: steps, actions: actions, - } as Alert; + } as LegacyWorkflow; } +export type DefinitionV2 = { + value: { + sequence: V2Step[]; + properties: V2Properties; + }; + isValid: boolean; +}; + export function wrapDefinitionV2({ properties, sequence, @@ -555,7 +565,7 @@ export function wrapDefinitionV2({ properties: V2Properties; sequence: V2Step[]; isValid?: boolean; -}) { +}): DefinitionV2 { return { value: { sequence: sequence, diff --git a/keep-ui/app/(keep)/workflows/builder/workflow-builder-context.ts b/keep-ui/app/(keep)/workflows/builder/workflow-builder-context.ts new file mode 100644 index 000000000..8c381d358 --- /dev/null +++ b/keep-ui/app/(keep)/workflows/builder/workflow-builder-context.ts @@ -0,0 +1,33 @@ +import { createContext, useContext } from "react"; + +interface WorkflowBuilderContextType { + enableButtons: (state: boolean) => void; + enableGenerate: (state: boolean) => void; + triggerGenerate: number; + triggerSave: number; + triggerRun: number; + setIsSaving: (state: boolean) => void; + isSaving: boolean; +} + +export const WorkflowBuilderContext = createContext( + { + enableButtons: (state: boolean) => {}, + enableGenerate: (state: boolean) => {}, + triggerGenerate: 0, + triggerSave: 0, + triggerRun: 0, + setIsSaving: (state: boolean) => {}, + isSaving: false, + } +); + +export function useWorkflowBuilderContext() { + const context = useContext(WorkflowBuilderContext); + if (context === undefined) { + throw new Error( + "useWorkflowBuilderContext must be used within a WorkflowBuilderProvider" + ); + } + return context; +} diff --git a/keep-ui/app/(keep)/workflows/mockworkflows.tsx b/keep-ui/app/(keep)/workflows/mockworkflows.tsx index a6fb570fa..7b1602b0c 100644 --- a/keep-ui/app/(keep)/workflows/mockworkflows.tsx +++ b/keep-ui/app/(keep)/workflows/mockworkflows.tsx @@ -1,10 +1,19 @@ import React, { useState } from "react"; -import { MockStep, MockWorkflow } from "@/shared/api/workflows"; -import Loading from "@/app/(keep)/loading"; -import { Button, Card, Tab, TabGroup, TabList } from "@tremor/react"; +import { + MockStep, + MockWorkflow, + WorkflowTemplate, +} from "@/shared/api/workflows"; +import { Button, Card } from "@tremor/react"; import { useRouter } from "next/navigation"; import Image from "next/image"; import { TiArrowRight } from "react-icons/ti"; +import Skeleton from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import useSWR from "swr"; +import { useApi } from "@/shared/lib/hooks/useApi"; +import { ErrorComponent } from "@/shared/ui"; +import { ArrowPathIcon } from "@heroicons/react/24/outline"; export function WorkflowSteps({ workflow }: { workflow: MockWorkflow }) { const isStepPresent = @@ -18,157 +27,141 @@ export function WorkflowSteps({ workflow }: { workflow: MockWorkflow }) { if (["threshold", "assert", "foreach"].includes(provider?.type)) { return null; } - return ( - <> - {provider && ( -
- {index > 0 && ( - - )} - {provider?.type} -
- )} - - ); + return provider ? ( +
+ {index > 0 && } + {provider?.type} +
+ ) : null; })} {workflow?.actions?.map((action: any, index: number) => { const provider = action?.provider; if (["threshold", "assert", "foreach"].includes(provider?.type)) { return null; } - return ( - <> - {provider && ( -
- {(index > 0 || isStepPresent) && ( - - )} - {provider?.type} -
+ return provider ? ( +
+ {(index > 0 || isStepPresent) && ( + )} - - ); + {provider?.type} +
+ ) : null; })}
); } -export const MockFilterTabs = ({ - tabs, -}: { - tabs: { name: string; onClick?: () => void }[]; -}) => ( -
- - - {tabs?.map( - (tab: { name: string; onClick?: () => void }, index: number) => ( - - {tab.name} - - ) - )} - - -
-); - -export default function MockWorkflowCardSection({ - mockWorkflows, - mockError, - mockLoading, -}: { - mockWorkflows: MockWorkflow[]; - mockError: any; - mockLoading: boolean | null; -}) { +export function WorkflowTemplates() { + const api = useApi(); const router = useRouter(); const [loadingId, setLoadingId] = useState(null); + /** + Add Mock Workflows (6 Random Workflows on Every Request) + To add mock workflows, a new backend API endpoint has been created: /workflows/random-templates. + 1. Fetching Random Templates: When a request is made to this endpoint, all workflow YAML/YML files are read and + shuffled randomly. + 2. Response: Only the first 6 files are parsed and sent in the response. + **/ + const { + data: mockWorkflows, + error: mockError, + isLoading: mockLoading, + mutate: refresh, + } = useSWR( + api.isReady() ? `/workflows/random-templates` : null, + (url: string) => api.get(url), + { + revalidateOnFocus: false, + } + ); + const getNameFromId = (id: string) => { if (!id) { return ""; } - return id.split("-").join(" "); }; - // if mockError is not null, handle the error case - if (mockError) { - return

Error: {mockError.message}

; - } + const handlePreview = (template: WorkflowTemplate) => { + setLoadingId(template.workflow_raw_id); + localStorage.setItem("preview_workflow", JSON.stringify(template)); + router.push(`/workflows/preview/${template.workflow_raw_id}`); + }; return (
-

+

Discover workflow templates -

- {/* TODO: Implement the commented out code block */} - {/* This is a placeholder comment until the commented out code block is implemented */} - {/*
-
- - +
+
- -
*/} - {mockError && ( -

- Error: {mockError.message || "Something went wrong!"} -

+ + + {/* TODO: Filters and search */} + {!mockLoading && !mockError && mockWorkflows?.length === 0 && ( +

No workflow templates found

)} - {!mockLoading && !mockError && mockWorkflows.length === 0 && ( -

No workflows found

+ {mockError && ( + refresh()} /> )}
- {mockError && ( -

- Error: {mockError.message || "Something went wrong!"} -

+ {mockLoading && ( + <> + {Array.from({ length: 8 }).map((_, index) => ( +
+ +
+ ))} + )} - {mockLoading && } {!mockLoading && - mockWorkflows.length > 0 && - mockWorkflows.map((template: any, index: number) => { + mockWorkflows?.length && + mockWorkflows?.map((template, index: number) => { const workflow = template.workflow; return ( { + e.preventDefault(); + e.stopPropagation(); + handlePreview(template); + }} > -
+

- {workflow.name || getNameFromId(workflow.id)} + {getNameFromId(workflow.id)}

{workflow.description} @@ -176,16 +169,7 @@ export default function MockWorkflowCardSection({

-
- )} - - - {({ active }) => ( - - )} - - - {({ active }) => ( - - )} - - - {({ active }) => ( - - )} - - - {({ active }) => ( -
- - {provisioned && ( -
- Cannot delete a provisioned workflow -
- )} -
- )} -
-
- - - - {showTooltip && isRunButtonDisabled && runButtonToolTip && ( -
+ + { + e.preventDefault(); + e.stopPropagation(); + onRun?.(); + }} + title={runButtonToolTip} + disabled={isRunButtonDisabled} + /> + { + e.preventDefault(); + e.stopPropagation(); + onDownload?.(); + }} + /> + { + e.preventDefault(); + e.stopPropagation(); + onView?.(); + }} + /> + { + e.preventDefault(); + e.stopPropagation(); + onBuilder?.(); + }} + /> + { + e.preventDefault(); + e.stopPropagation(); + onDelete?.(); }} - > - {runButtonToolTip} -
- )} + disabled={provisioned} + title={provisioned ? "Cannot delete a provisioned workflow" : ""} + /> +
); } diff --git a/keep-ui/app/(keep)/workflows/workflow-tile.tsx b/keep-ui/app/(keep)/workflows/workflow-tile.tsx index 72f3e1a37..e30573df8 100644 --- a/keep-ui/app/(keep)/workflows/workflow-tile.tsx +++ b/keep-ui/app/(keep)/workflows/workflow-tile.tsx @@ -6,19 +6,7 @@ import { useRouter } from "next/navigation"; import WorkflowMenu from "./workflow-menu"; import Loading from "@/app/(keep)/loading"; import { Trigger, Provider, Workflow } from "@/shared/api/workflows"; -import { - Button, - Text, - Card, - Title, - Icon, - ListItem, - List, - Accordion, - AccordionBody, - AccordionHeader, - Badge, -} from "@tremor/react"; +import { Button, Text, Card, Icon, ListItem, List, Badge } from "@tremor/react"; import ProviderForm from "@/app/(keep)/providers/provider-form"; import SlidingPanel from "react-sliding-side-panel"; import { useFetchProviders } from "@/app/(keep)/providers/page.client"; @@ -40,45 +28,10 @@ import { } from "react-icons/md"; import { HiBellAlert } from "react-icons/hi2"; import { useWorkflowRun } from "utils/hooks/useWorkflowRun"; -import { useApi } from "@/shared/lib/hooks/useApi"; +import { useWorkflowActions } from "@/entities/workflows/model/useWorkflowActions"; import { DynamicIcon } from "@/components/ui"; import "./workflow-tile.css"; -function WorkflowMenuSection({ - onDelete, - onRun, - onDownload, - onView, - onBuilder, - isRunButtonDisabled, - runButtonToolTip, - provisioned, -}: { - onDelete: () => Promise; - onRun: () => Promise; - onDownload: () => void; - onView: () => void; - onBuilder: () => void; - isRunButtonDisabled: boolean; - runButtonToolTip?: string; - provisioned?: boolean; -}) { - // Determine if all providers are installed - - return ( - - ); -} - function TriggerTile({ trigger }: { trigger: Trigger }) { return ( @@ -104,69 +57,6 @@ function TriggerTile({ trigger }: { trigger: Trigger }) { ); } -function ProviderTile({ - provider, - onConnectClick, -}: { - provider: FullProvider; - onConnectClick: (provider: FullProvider) => void; -}) { - const [isHovered, setIsHovered] = useState(false); - - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - className={`relative group flex flex-col justify-around items-center bg-white rounded-lg w-24 h-28 mt-2.5 mr-2.5 hover:grayscale-0 shadow-md hover:shadow-lg`} - title={`${provider.details.name} (${provider.type})`} - > - {provider.installed ? ( - - ) : ( - - )} - {provider.type} - -
- {!provider.installed && isHovered ? ( - - ) : ( -

- {provider.details.name} -

- )} -
-
- ); -} - export const ProvidersCarousel = ({ providers, onConnectClick, @@ -175,7 +65,6 @@ export const ProvidersCarousel = ({ onConnectClick: (provider: FullProvider) => void; }) => { const [currentIndex, setCurrentIndex] = useState(0); - const [isHovered, setIsHovered] = useState(false); const providersPerPage = 3; @@ -212,8 +101,6 @@ export const ProvidersCarousel = ({
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} > {provider.installed ? ( { - try { - await api.delete(`/workflows/${workflow.id}`); - // Workflow deleted successfully - window.location.reload(); - } catch (error) { - console.error("An error occurred while deleting workflow", error); + deleteWorkflow(workflow.id); + }; + + const handleWorkflowClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + const target = e.target as HTMLElement; + if (target.closest(".js-dont-propagate")) { + // do not redirect if the three-dot menu is clicked + return; } + router.push(`/workflows/${workflow.id}`); }; const handleConnecting = (isConnecting: boolean, isConnected: boolean) => { @@ -512,13 +404,7 @@ function WorkflowTile({ workflow }: { workflow: Workflow }) { )} { - e.stopPropagation(); - e.preventDefault(); - if (workflow.id) { - router.push(`/workflows/${workflow.id}`); - } - }} + onClick={handleWorkflowClick} >
{workflow.provisioned && ( @@ -526,17 +412,18 @@ function WorkflowTile({ workflow }: { workflow: Workflow }) { Provisioned )} - {!!handleRunClick && - WorkflowMenuSection({ - onDelete: handleDeleteClick, - onRun: handleRunClick, - onDownload: handleDownloadClick, - onView: handleViewClick, - onBuilder: handleBuilderClick, - runButtonToolTip: message, - isRunButtonDisabled: !!isRunButtonDisabled, - provisioned: workflow.provisioned, - })} + {!!handleRunClick && ( + + )}
@@ -620,7 +507,6 @@ function WorkflowTile({ workflow }: { workflow: Workflow }) { providers={uniqueProviders} onConnectClick={handleConnectProvider} /> - {/*
*/}
( - null - ); - - const { providers } = useFetchProviders(); - const { - isRunning, - handleRunClick, - isRunButtonDisabled, - message, - getTriggerModalProps, - } = useWorkflowRun(workflow!); - - const handleConnectProvider = (provider: FullProvider) => { - setSelectedProvider(provider); - setOpenPanel(true); - }; - - const handleCloseModal = () => { - setOpenPanel(false); - setSelectedProvider(null); - }; - - const handleDeleteClick = async () => { - try { - await api.delete(`/workflows/${workflow.id}`); - - // Workflow deleted successfully - window.location.reload(); - } catch (error) { - console.error("An error occurred while deleting workflow", error); - } - }; - - const handleConnecting = (isConnecting: boolean, isConnected: boolean) => { - if (isConnected) { - handleCloseModal(); - // refresh the page to show the changes - window.location.reload(); - } - }; - const handleDownloadClick = async () => { - try { - // Use the raw workflow data directly, as it is already in YAML format - const workflowYAML = workflow.workflow_raw; - - // Create a Blob object representing the data as a YAML file - const blob = new Blob([workflowYAML], { type: "text/yaml" }); - - // Create an anchor element with a URL object created from the Blob - const url = window.URL.createObjectURL(blob); - - // Create a "hidden" anchor tag with the download attribute and click it - const a = document.createElement("a"); - a.style.display = "none"; - a.href = url; - a.download = `${workflow.workflow_raw_id}.yaml`; // The file will be named after the workflow's id - document.body.appendChild(a); - a.click(); - - // Release the object URL to free up resources - window.URL.revokeObjectURL(url); - } catch (error) { - console.error("An error occurred while downloading the YAML", error); - } - }; - - const handleViewClick = async () => { - router.push(`/workflows/${workflow.id}`); - }; - - const handleBuilderClick = async () => { - router.push(`/workflows/builder/${workflow.id}`); - }; - - const workflowProvidersMap = new Map( - workflow.providers.map((p) => [p.type, p]) - ); - - const uniqueProviders: FullProvider[] = Array.from( - new Set(workflow.providers.map((p) => p.type)) - ) - .map((type) => { - let fullProvider = - providers.find((fp) => fp.type === type) || ({} as FullProvider); - let workflowProvider = - workflowProvidersMap.get(type) || ({} as FullProvider); - - // Merge properties - const mergedProvider: FullProvider = { - ...fullProvider, - ...workflowProvider, - installed: workflowProvider.installed || fullProvider.installed, - details: { - authentication: {}, - name: (workflowProvider as Provider).name || fullProvider.id, - }, - id: fullProvider.type, - }; - - return mergedProvider; - }) - .filter(Boolean) as FullProvider[]; - const triggerTypes = workflow.triggers.map((trigger) => trigger.type); - return ( -
- {isRunning && ( -
- -
- )} - -
- - {workflow.name} - - {!!handleRunClick && - WorkflowMenuSection({ - onDelete: handleDeleteClick, - onRun: handleRunClick, - onDownload: handleDownloadClick, - onView: handleViewClick, - onBuilder: handleBuilderClick, - runButtonToolTip: message, - isRunButtonDisabled: !!isRunButtonDisabled, - provisioned: workflow.provisioned, - })} -
- -
- - {workflow.description} - -
- - - - Created By - {workflow.created_by} - - - Created At - - {workflow.creation_time - ? new Date(workflow.creation_time + "Z").toLocaleString() - : "N/A"} - - - - Last Updated - - {workflow.last_updated - ? new Date(workflow.last_updated + "Z").toLocaleString() - : "N/A"} - - - - Last Execution - - {workflow.last_execution_time - ? new Date(workflow.last_execution_time + "Z").toLocaleString() - : "N/A"} - - - - Last Status - - {workflow.last_execution_status - ? workflow.last_execution_status - : "N/A"} - - - - Disabled - {workflow?.disabled?.toString()} - - - - - - Triggers: - {triggerTypes.map((t) => { - if (t === "alert") { - const alertSource = workflow.triggers - .find((w) => w.type === "alert") - ?.filters?.find((f) => f.key === "source")?.value; - return ( - ( - - )} - key={t} - size="xs" - color="orange" - title={`Source: ${alertSource}`} - > - {t} - - ); - } - return ( - - {t} - - ); - })} - - - {workflow.triggers.length > 0 ? ( - - {workflow.triggers.map((trigger, index) => ( - - ))} - - ) : ( -

- This workflow does not have any triggers. -

- )} -
-
- - - Providers: -
- {uniqueProviders.map((provider) => ( - - ))} -
-
- - {selectedProvider && ( - - )} - -
- {!!getTriggerModalProps && ( - - )} -
- ); -} - export default WorkflowTile; diff --git a/keep-ui/app/(keep)/workflows/workflows.client.tsx b/keep-ui/app/(keep)/workflows/workflows.client.tsx index c995b1f84..79ac6dc40 100644 --- a/keep-ui/app/(keep)/workflows/workflows.client.tsx +++ b/keep-ui/app/(keep)/workflows/workflows.client.tsx @@ -7,7 +7,7 @@ import { ArrowUpOnSquareStackIcon, PlusCircleIcon, } from "@heroicons/react/24/outline"; -import { Workflow, MockWorkflow } from "@/shared/api/workflows"; +import { Workflow } from "@/shared/api/workflows"; import Loading from "@/app/(keep)/loading"; import WorkflowsEmptyState from "./noworkflows"; import WorkflowTile from "./workflow-tile"; @@ -15,7 +15,7 @@ import { Button, Title } from "@tremor/react"; import { ArrowRightIcon } from "@radix-ui/react-icons"; import { useRouter } from "next/navigation"; import Modal from "@/components/ui/Modal"; -import MockWorkflowCardSection from "./mockworkflows"; +import { WorkflowTemplates } from "./mockworkflows"; import { useApi } from "@/shared/lib/hooks/useApi"; import { KeepApiError } from "@/shared/api"; import { showErrorToast, Input, ErrorComponent } from "@/shared/ui"; @@ -87,22 +87,6 @@ export default function WorkflowsPage() { (url: string) => api.get(url) ); - /** - Add Mock Workflows (6 Random Workflows on Every Request) - To add mock workflows, a new backend API endpoint has been created: /workflows/random-templates. - 1. Fetching Random Templates: When a request is made to this endpoint, all workflow YAML/YML files are read and - shuffled randomly. - 2. Response: Only the first 6 files are parsed and sent in the response. - **/ - const { - data: mockWorkflows, - error: mockError, - isLoading: mockLoading, - } = useSWR( - api.isReady() ? `/workflows/random-templates` : null, - (url: string) => api.get(url) - ); - if (error) { return {}} />; } @@ -235,11 +219,7 @@ export default function WorkflowsPage() { )}
- +
Promise; + updateWorkflow: ( + workflowId: string, + definition: Definition + ) => Promise; + deleteWorkflow: (workflowId: string) => void; +}; + +type CreateOrUpdateWorkflowResponse = { + workflow_id: string; + status: "created" | "updated"; + revision: number; +}; + +export function useWorkflowActions(): UseWorkflowActionsReturn { + const api = useApi(); + const revalidateMultiple = useRevalidateMultiple(); + const refreshWorkflows = useCallback(() => { + revalidateMultiple(["/workflows?is_v2=true"], { isExact: true }); + }, [revalidateMultiple]); + + const createWorkflow = useCallback( + async (definition: Definition) => { + try { + const workflow = getWorkflowFromDefinition(definition); + const body = stringify(workflow); + const response = await api.request( + "/workflows/json", + { + method: "POST", + body, + headers: { "Content-Type": "text/html" }, + } + ); + showSuccessToast("Workflow created successfully"); + refreshWorkflows(); + return response; + } catch (error) { + showErrorToast(error, "Failed to create workflow"); + return null; + } + }, + [api, refreshWorkflows] + ); + + const updateWorkflow = useCallback( + async (workflowId: string, definition: Definition) => { + try { + const workflow = getWorkflowFromDefinition(definition); + const body = stringify(workflow); + const response = await api.request( + `/workflows/${workflowId}`, + { + method: "PUT", + body, + headers: { "Content-Type": "text/html" }, + } + ); + showSuccessToast("Workflow updated successfully"); + refreshWorkflows(); + return response; + } catch (error) { + showErrorToast(error, "Failed to update workflow"); + return null; + } + }, + [api, refreshWorkflows] + ); + + const deleteWorkflow = useCallback( + async ( + workflowId: string, + { skipConfirmation = false }: { skipConfirmation?: boolean } = {} + ) => { + if ( + !skipConfirmation && + !confirm("Are you sure you want to delete this workflow?") + ) { + return false; + } + try { + await api.delete(`/workflows/${workflowId}`); + showSuccessToast("Workflow deleted successfully"); + refreshWorkflows(); + } catch (error) { + showErrorToast(error, "An error occurred while deleting workflow"); + } + }, + [api, refreshWorkflows] + ); + + return { + createWorkflow, + updateWorkflow, + deleteWorkflow, + }; +} diff --git a/keep-ui/shared/api/workflows.ts b/keep-ui/shared/api/workflows.ts index 58e331656..4b77f9a11 100644 --- a/keep-ui/shared/api/workflows.ts +++ b/keep-ui/shared/api/workflows.ts @@ -1,3 +1,9 @@ +import { notFound } from "next/navigation"; +import { ApiClient } from "./ApiClient"; +import { KeepApiError } from "./KeepApiError"; +import { createServerApiClient } from "./server"; +import { cache } from "react"; + export type Provider = { id: string; type: string; // This corresponds to the name of the icon, e.g., "slack", "github", etc. @@ -85,3 +91,39 @@ export type MockWorkflow = { steps: MockStep[]; actions: MockAction[]; }; + +export type WorkflowTemplate = { + name: string; + workflow: MockWorkflow; + workflow_raw: string; + workflow_raw_id: string; +}; + +export async function getWorkflow(api: ApiClient, id: string) { + return await api.get(`/workflows/${id}`); +} + +/** + * Fetches a workflow by ID with error handling for 404 cases + * @param id - The unique identifier of the workflow to retrieve + * @returns Promise containing the workflow data or undefined if not found + * @returns {never} If 404 error occurs (handled by Next.js notFound) or if the API request fails for reasons other than 404 + */ +export async function _getWorkflowWithRedirectSafe( + id: string +): Promise { + try { + const api = await createServerApiClient(); + return await getWorkflow(api, id); + } catch (error) { + if (error instanceof KeepApiError && error.statusCode === 404) { + notFound(); + } else { + console.error(error); + return undefined; + } + } +} + +// cache the function for server side, so we can use it in the layout, metadata and in the page itself +export const getWorkflowWithRedirectSafe = cache(_getWorkflowWithRedirectSafe); diff --git a/keep-ui/shared/lib/provider-utils.ts b/keep-ui/shared/lib/provider-utils.ts new file mode 100644 index 000000000..fa3617641 --- /dev/null +++ b/keep-ui/shared/lib/provider-utils.ts @@ -0,0 +1,14 @@ +import { Provider } from "@/app/(keep)/providers/providers"; + +export function isProviderInstalled( + provider: Pick, + providers: Provider[] +) { + return ( + provider.installed || + !Object.values(providers || {}).some( + (p) => + p.type === provider.type && p.config && Object.keys(p.config).length > 0 + ) + ); +} diff --git a/keep-ui/shared/lib/state-utils.ts b/keep-ui/shared/lib/state-utils.ts index 5f1b07d02..04edb073e 100644 --- a/keep-ui/shared/lib/state-utils.ts +++ b/keep-ui/shared/lib/state-utils.ts @@ -1,11 +1,14 @@ import { useSWRConfig } from "swr"; - +import { useCallback } from "react"; export const useRevalidateMultiple = () => { const { mutate } = useSWRConfig(); - return (keys: string[], options: { isExact: boolean } = { isExact: false }) => - mutate( - (key) => - typeof key === "string" && - keys.some((k) => (options.isExact ? k === key : key.startsWith(k))) - ); + return useCallback( + (keys: string[], options: { isExact: boolean } = { isExact: false }) => + mutate( + (key) => + typeof key === "string" && + keys.some((k) => (options.isExact ? k === key : key.startsWith(k))) + ), + [mutate] + ); }; diff --git a/keep-ui/shared/ui/DropdownMenu/DropdownMenu.tsx b/keep-ui/shared/ui/DropdownMenu/DropdownMenu.tsx index 835206a0a..704b60f86 100644 --- a/keep-ui/shared/ui/DropdownMenu/DropdownMenu.tsx +++ b/keep-ui/shared/ui/DropdownMenu/DropdownMenu.tsx @@ -243,6 +243,7 @@ const DropdownDropdownMenuItem = React.forwardRef< className={clsx( "DropdownMenuItem", props.variant === "destructive" && "text-red-500", + disabled && "opacity-50 cursor-not-allowed", props.className )} tabIndex={isActive ? 0 : -1} diff --git a/keep-ui/utils/hooks/useWorkflowRun.ts b/keep-ui/utils/hooks/useWorkflowRun.ts index abaf4d778..035de0af5 100644 --- a/keep-ui/utils/hooks/useWorkflowRun.ts +++ b/keep-ui/utils/hooks/useWorkflowRun.ts @@ -2,13 +2,10 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { useProviders } from "./useProviders"; import { Filter, Workflow } from "@/shared/api/workflows"; -import { Provider } from "@/app/(keep)/providers/providers"; import { useApi } from "@/shared/lib/hooks/useApi"; import { showErrorToast } from "@/shared/ui"; import { useRevalidateMultiple } from "@/shared/lib/state-utils"; -interface ProvidersData { - providers: { [key: string]: { providers: Provider[] } }; -} +import { isProviderInstalled } from "@/shared/lib/provider-utils"; export const useWorkflowRun = (workflow: Workflow) => { const api = useApi(); @@ -20,9 +17,8 @@ export const useWorkflowRun = (workflow: Workflow) => { const [alertDependencies, setAlertDependencies] = useState([]); const revalidateMultiple = useRevalidateMultiple(); - const { data: providersData = { providers: {} } as ProvidersData } = - useProviders(); - const providers = providersData.providers; + const { data: providersData } = useProviders(); + const providers = providersData?.providers ?? []; if (!workflow) { return {}; @@ -30,14 +26,7 @@ export const useWorkflowRun = (workflow: Workflow) => { const notInstalledProviders = workflow?.providers ?.filter( - (workflowProvider) => - !workflowProvider.installed && - Object.values(providers || {}).some( - (provider) => - provider.type === workflowProvider.type && - provider.config && - Object.keys(provider.config).length > 0 - ) + (workflowProvider) => !isProviderInstalled(workflowProvider, providers) ) .map((provider) => provider.type); diff --git a/keep/api/routes/workflows.py b/keep/api/routes/workflows.py index e6504df69..363d45b16 100644 --- a/keep/api/routes/workflows.py +++ b/keep/api/routes/workflows.py @@ -306,7 +306,7 @@ async def run_workflow_from_definition( return workflow_execution -async def __get_workflow_raw_data(request: Request, file: UploadFile) -> dict: +async def __get_workflow_raw_data(request: Request, file: UploadFile | None) -> dict: try: # we support both File upload (from frontend) or raw yaml (e.g. curl) if file: diff --git a/tests/e2e_tests/test_end_to_end.py b/tests/e2e_tests/test_end_to_end.py index b5232fb4d..34530172f 100644 --- a/tests/e2e_tests/test_end_to_end.py +++ b/tests/e2e_tests/test_end_to_end.py @@ -34,6 +34,7 @@ # - Copy the generated code to a new test function. import string import sys +import re from datetime import datetime from playwright.sync_api import expect @@ -134,6 +135,8 @@ def test_providers_page_is_accessible(browser): Test to check if the providers page is accessible """ + log_entries = [] + setup_console_listener(browser, log_entries) try: browser.goto( "http://localhost:3000/signin?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fproviders" @@ -159,17 +162,7 @@ def test_providers_page_is_accessible(browser): provider_button = browser.locator(f"button:has-text('{random_provider_name}')") provider_button.click() except Exception: - # Current file + test name for unique html and png dump. - current_test_name = ( - "playwright_dump_" - + os.path.basename(__file__)[:-3] - + "_" - + sys._getframe().f_code.co_name - ) - - browser.screenshot(path=current_test_name + ".png") - with open(current_test_name + ".html", "w") as f: - f.write(browser.content()) + save_failure_artifacts(browser, log_entries) raise @@ -177,6 +170,8 @@ def test_provider_validation(browser): """ Test field validation for provider fields. """ + log_entries = [] + setup_console_listener(browser, log_entries) try: browser.goto("http://localhost:3000/signin") # using Kibana Provider @@ -286,13 +281,40 @@ def test_provider_validation(browser): host_input.fill("https://host.com:3000") expect(error_msg).to_be_hidden() except Exception: - current_test_name = ( - "playwright_dump_" - + os.path.basename(__file__)[:-3] - + "_" - + sys._getframe().f_code.co_name - ) - browser.screenshot(path=current_test_name + ".png") - with open(current_test_name + ".html", "w") as f: - f.write(browser.content()) + save_failure_artifacts(browser, log_entries) + raise + +def test_add_workflow(browser): + """ + Test to add a workflow node + """ + # browser is actually a page object + page = browser + log_entries = [] + setup_console_listener(page, log_entries) + try: + page.goto("http://localhost:3000/signin") + page.get_by_role("link", name="Workflows").click() + page.get_by_role("button", name="Create a workflow").click() + page.get_by_placeholder("Set the name").click() + page.get_by_placeholder("Set the name").press("ControlOrMeta+a") + page.get_by_placeholder("Set the name").fill("Example Console Workflow") + page.get_by_placeholder("Set the name").press("Tab") + page.get_by_placeholder("Set the description").fill("Example workflow description") + page.get_by_test_id("wf-add-trigger-button").first.click() + page.get_by_text("Manual").click() + page.get_by_test_id("wf-add-step-button").first.click() + page.get_by_placeholder("Search...").click() + page.get_by_placeholder("Search...").fill("cons") + page.get_by_text("console-action").click() + page.wait_for_timeout(500) + page.locator(".react-flow__node:has-text('console-action')").click() + page.get_by_placeholder("message").click() + page.get_by_placeholder("message").fill("Hello world!") + page.get_by_role("button", name="Save & Deploy").click() + page.wait_for_url(re.compile("http://localhost:3000/workflows/.*")) + expect(page.get_by_test_id("wf-name")).to_contain_text("Example Console Workflow") + expect(page.get_by_test_id("wf-description")).to_contain_text("Example workflow description") + except Exception: + save_failure_artifacts(page, log_entries) raise