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..3235a5e8a 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 } ); - const { isRunning, handleRunClick, 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 b086813fb..867d71523 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 @@ -25,8 +25,10 @@ import { ErrorComponent, TabNavigationLink, YAMLCodeblock } from "@/shared/ui"; export default function WorkflowDetailPage({ params, + initialData, }: { params: { workflow_id: string }; + initialData?: Workflow; }) { const api = useApi(); const [tabIndex, setTabIndex] = useState(0); @@ -35,9 +37,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, + } ); if (error) { 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..cc14de36d --- /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/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..ece17fdbe 100644 --- a/keep-ui/app/(keep)/workflows/builder/builder-store.tsx +++ b/keep-ui/app/(keep)/workflows/builder/builder-store.tsx @@ -266,7 +266,7 @@ function addNodeBetween( } } -const useStore = create((set, get) => ({ +const defaultState: FlowState = { nodes: [], edges: [], selectedNode: null, @@ -282,6 +282,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 +557,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..84b75bae8 100644 --- a/keep-ui/app/(keep)/workflows/builder/builder.tsx +++ b/keep-ui/app/(keep)/workflows/builder/builder.tsx @@ -5,7 +5,7 @@ import { parseWorkflow, generateWorkflow, getToolboxConfiguration, - buildAlert, + getWorkflowFromDefinition, wrapDefinitionV2, } from "./utils"; import { @@ -13,7 +13,7 @@ import { 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 { stringify } from "yaml"; @@ -37,8 +37,8 @@ import { useApi } from "@/shared/lib/hooks/useApi"; import { KeepApiError } from "@/shared/api"; import { showErrorToast, showSuccessToast } 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"; interface Props { loadedAlertFile: string | null; @@ -87,11 +87,13 @@ function Builder({ const [runningWorkflowExecution, setRunningWorkflowExecution] = useState< WorkflowExecutionDetail | WorkflowExecutionFailure | null >(null); - const [compiledAlert, setCompiledAlert] = useState(null); + const [legacyWorkflow, setLegacyWorkflow] = useState( + null + ); const router = useRouter(); const searchParams = useSearchParams(); - const { errorNode, setErrorNode, canDeploy, synced } = useStore(); + const { errorNode, setErrorNode, canDeploy, synced, reset } = useStore(); const setStepValidationErrorV2 = (step: V2Step, error: string | null) => { setStepValidationError(error); @@ -113,7 +115,7 @@ function Builder({ }; const updateWorkflow = useCallback(() => { - const body = stringify(buildAlert(definition.value)); + const body = stringify(getWorkflowFromDefinition(definition.value)); api .request(`/workflows/${workflowId}`, { method: "PUT", @@ -130,7 +132,7 @@ function Builder({ const testRunWorkflow = () => { setTestRunModalOpen(true); - const body = stringify(buildAlert(definition.value)); + const body = stringify(getWorkflowFromDefinition(definition.value)); api .request(`/workflows/test`, { method: "POST", @@ -150,25 +152,21 @@ 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]); + const { createWorkflow } = useWorkflowActions(); + + const addWorkflow = useCallback(async () => { + try { + const response = await createWorkflow(definition.value); + // reset the store to clear the nodes and edges + if (response?.workflow_id) { + reset(); + router.push(`/workflows/${response.workflow_id}`); + } + } catch (error) { + // error is handled in the useWorkflowActions hook4 + console.error(error); + } + }, [createWorkflow, definition.value, reset, router]); useEffect(() => { setIsLoading(true); @@ -215,7 +213,7 @@ function Builder({ useEffect(() => { if (triggerGenerate) { - setCompiledAlert(buildAlert(definition.value)); + setLegacyWorkflow(getWorkflowFromDefinition(definition.value)); if (!generateModalIsOpen) setGenerateModalIsOpen(true); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -344,7 +342,7 @@ function Builder({ >
- {workflow ? "Edit" : "New"} Workflow + {workflowId ? "Edit" : "New"} Workflow
{!workflow && ( diff --git a/keep-ui/app/(keep)/workflows/builder/utils.tsx b/keep-ui/app/(keep)/workflows/builder/utils.tsx index d9d67243e..865bc6747 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,7 +546,7 @@ export function buildAlert(definition: Definition): Alert { consts: consts, steps: steps, actions: actions, - } as Alert; + } as LegacyWorkflow; } export function wrapDefinitionV2({ diff --git a/keep-ui/app/(keep)/workflows/mockworkflows.tsx b/keep-ui/app/(keep)/workflows/mockworkflows.tsx index a6fb570fa..087ea17f8 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 = @@ -71,104 +80,98 @@ export function WorkflowSteps({ workflow }: { workflow: MockWorkflow }) { ); } -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 +179,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 c4c06099d..6ba99b7a8 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,44 +28,9 @@ 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 "./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 ( @@ -103,69 +56,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, @@ -174,7 +64,6 @@ export const ProvidersCarousel = ({ onConnectClick: (provider: FullProvider) => void; }) => { const [currentIndex, setCurrentIndex] = useState(0); - const [isHovered, setIsHovered] = useState(false); const providersPerPage = 3; @@ -211,8 +100,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) => { @@ -529,13 +421,7 @@ function WorkflowTile({ workflow }: { workflow: Workflow }) { )} { - e.stopPropagation(); - e.preventDefault(); - if (workflow.id) { - router.push(`/workflows/${workflow.id}`); - } - }} + onClick={handleWorkflowClick} >
{workflow.provisioned && ( @@ -543,17 +429,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 && ( + + )}
@@ -637,7 +524,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 handleImageError = (event: any) => { - event.target.href.baseVal = "/icons/keep-icon.png"; - }; - const alertSource = workflow.triggers - .find((w) => w.type === "alert") - ?.filters?.find((f) => f.key === "source")?.value; - const DynamicIcon = (props: any) => ( - - {" "} - - - ); - return ( - - {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; + 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, "An error occurred while creating 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, + deleteWorkflow, + }; +} diff --git a/keep-ui/shared/api/workflows.ts b/keep-ui/shared/api/workflows.ts index 58e331656..a7e01d7ac 100644 --- a/keep-ui/shared/api/workflows.ts +++ b/keep-ui/shared/api/workflows.ts @@ -1,3 +1,8 @@ +import { notFound } from "next/navigation"; +import { ApiClient } from "./ApiClient"; +import { KeepApiError } from "./KeepApiError"; +import { createServerApiClient } from "./server"; + export type Provider = { id: string; type: string; // This corresponds to the name of the icon, e.g., "slack", "github", etc. @@ -85,3 +90,40 @@ 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}`); +} + +export async function getWorkflowWithErrorHandling( + id: string, + { redirect = true }: { redirect?: boolean } = {} + // @ts-ignore ignoring since not found will be handled by nextjs +): Promise { + try { + const api = await createServerApiClient(); + return await getWorkflow(api, id); + } catch (error) { + if (error instanceof KeepApiError && error.statusCode === 404 && redirect) { + notFound(); + } else { + throw error; + } + } +} + +export async function getWorkflowWithRedirectSafe(id: string) { + try { + return await getWorkflowWithErrorHandling(id, { redirect: false }); + } catch (error) { + console.error(error); + return undefined; + } +} diff --git a/keep-ui/shared/lib/provider-utils.ts b/keep-ui/shared/lib/provider-utils.ts new file mode 100644 index 000000000..0fb0294b3 --- /dev/null +++ b/keep-ui/shared/lib/provider-utils.ts @@ -0,0 +1,18 @@ +import { Provider } from "@/app/(keep)/providers/providers"; + +interface ProvidersData { + providers: { [key: string]: { providers: Provider[] } }; +} + +export function isProviderInstalled( + provider: Provider, + providers: ProvidersData | 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/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..d2df8e579 100644 --- a/keep-ui/utils/hooks/useWorkflowRun.ts +++ b/keep-ui/utils/hooks/useWorkflowRun.ts @@ -6,6 +6,7 @@ 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"; +import { isProviderInstalled } from "@/shared/lib/provider-utils"; interface ProvidersData { providers: { [key: string]: { providers: Provider[] } }; } @@ -30,14 +31,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: