From 2677f6844e1b42bbb67df23bd35dfcab571f47c0 Mon Sep 17 00:00:00 2001 From: Kirill Chernakov Date: Mon, 13 Jan 2025 12:15:24 +0400 Subject: [PATCH] feat: workflow execution logs ux (#3006) Co-authored-by: Shahar Glazner Co-authored-by: Tal --- .../workflows/incident-workflow-sidebar.tsx | 8 +- .../workflows/incident-workflow-table.tsx | 8 +- .../runs/[workflow_execution_id]/page.tsx | 13 +- .../[workflow_id]/workflow-detail-header.tsx | 7 +- .../[workflow_id]/workflow-detail-page.tsx | 2 +- .../workflow-execution-table.tsx | 14 +- .../[workflow_id]/workflow-overview.tsx | 2 +- .../builder-workflow-testrun-modal.tsx | 20 +- .../app/(keep)/workflows/builder/builder.tsx | 44 +-- keep-ui/app/(keep)/workflows/builder/types.ts | 40 -- .../builder/workflow-execution-results.tsx | 295 --------------- .../app/(keep)/workflows/mockworkflows.tsx | 8 +- keep-ui/app/(keep)/workflows/noworkflows.tsx | 3 +- .../workflows/workflow-definition-yaml.tsx | 136 ------- .../app/(keep)/workflows/workflow-graph.tsx | 9 +- .../app/(keep)/workflows/workflow-tile.tsx | 10 +- .../{workflowutils.ts => workflow-utils.ts} | 17 +- .../app/(keep)/workflows/workflows.client.tsx | 345 ++++++++++-------- .../incident-list/ui/incident-pagination.tsx | 103 ------ .../workflow-execution-results/index.ts | 1 + .../lib/logs-utils.ts | 35 ++ .../ui/WorkflowDefinitionYAML.tsx | 187 ++++++++++ .../ui/WorkflowExecutionError.tsx | 51 +++ .../ui/WorkflowExecutionLogs.tsx | 331 +++++++++++++++++ .../ui/WorkflowExecutionResults.tsx | 211 +++++++++++ keep-ui/public/keep-failure.png | Bin 0 -> 1462 bytes keep-ui/public/keep-pending.png | Bin 0 -> 1310 bytes keep-ui/public/keep-success.png | Bin 0 -> 1499 bytes keep-ui/shared/api/workflow-executions.ts | 42 +++ .../models.tsx => shared/api/workflows.ts} | 11 +- keep-ui/shared/ui/utils/favicon.ts | 21 ++ keep-ui/utils/hooks/useIncidents.ts | 4 +- keep-ui/utils/hooks/useWorkflowExecutions.ts | 6 +- keep-ui/utils/hooks/useWorkflowRun.ts | 7 +- keep-ui/utils/hooks/useWorkflows.ts | 2 +- keep/api/logging.py | 12 +- keep/step/step.py | 56 ++- keep/workflowmanager/workflow.py | 34 +- 38 files changed, 1250 insertions(+), 845 deletions(-) delete mode 100644 keep-ui/app/(keep)/workflows/builder/workflow-execution-results.tsx delete mode 100644 keep-ui/app/(keep)/workflows/workflow-definition-yaml.tsx rename keep-ui/app/(keep)/workflows/{workflowutils.ts => workflow-utils.ts} (92%) delete mode 100644 keep-ui/features/incident-list/ui/incident-pagination.tsx create mode 100644 keep-ui/features/workflow-execution-results/index.ts create mode 100644 keep-ui/features/workflow-execution-results/lib/logs-utils.ts create mode 100644 keep-ui/features/workflow-execution-results/ui/WorkflowDefinitionYAML.tsx create mode 100644 keep-ui/features/workflow-execution-results/ui/WorkflowExecutionError.tsx create mode 100644 keep-ui/features/workflow-execution-results/ui/WorkflowExecutionLogs.tsx create mode 100644 keep-ui/features/workflow-execution-results/ui/WorkflowExecutionResults.tsx create mode 100644 keep-ui/public/keep-failure.png create mode 100644 keep-ui/public/keep-pending.png create mode 100644 keep-ui/public/keep-success.png create mode 100644 keep-ui/shared/api/workflow-executions.ts rename keep-ui/{app/(keep)/workflows/models.tsx => shared/api/workflows.ts} (90%) create mode 100644 keep-ui/shared/ui/utils/favicon.ts diff --git a/keep-ui/app/(keep)/incidents/[id]/workflows/incident-workflow-sidebar.tsx b/keep-ui/app/(keep)/incidents/[id]/workflows/incident-workflow-sidebar.tsx index de1907f43..c65b299f7 100644 --- a/keep-ui/app/(keep)/incidents/[id]/workflows/incident-workflow-sidebar.tsx +++ b/keep-ui/app/(keep)/incidents/[id]/workflows/incident-workflow-sidebar.tsx @@ -2,18 +2,18 @@ import { Fragment } from "react"; import { Dialog, Transition } from "@headlessui/react"; import { Text, Button, TextInput, Badge, Title, Card } from "@tremor/react"; import { IoMdClose } from "react-icons/io"; -import { WorkflowExecution } from "@/app/(keep)/workflows/builder/types"; import { getIcon, getTriggerIcon, extractTriggerValue, } from "@/app/(keep)/workflows/[workflow_id]/workflow-execution-table"; import { useWorkflowExecution } from "utils/hooks/useWorkflowExecutions"; +import { WorkflowExecutionDetail } from "@/shared/api/workflow-executions"; interface IncidentWorkflowSidebarProps { isOpen: boolean; toggle: VoidFunction; - selectedExecution: WorkflowExecution; + selectedExecution: WorkflowExecutionDetail; } const IncidentWorkflowSidebar: React.FC = ({ @@ -60,8 +60,8 @@ const IncidentWorkflowSidebar: React.FC = ({ selectedExecution.status === "error" ? "red" : selectedExecution.status === "success" - ? "green" - : "orange" + ? "green" + : "orange" } > {selectedExecution.status} diff --git a/keep-ui/app/(keep)/incidents/[id]/workflows/incident-workflow-table.tsx b/keep-ui/app/(keep)/incidents/[id]/workflows/incident-workflow-table.tsx index 3143adf65..0f7ca0a0c 100644 --- a/keep-ui/app/(keep)/incidents/[id]/workflows/incident-workflow-table.tsx +++ b/keep-ui/app/(keep)/incidents/[id]/workflows/incident-workflow-table.tsx @@ -26,7 +26,7 @@ import { getIcon, getTriggerIcon, } from "@/app/(keep)/workflows/[workflow_id]/workflow-execution-table"; -import { WorkflowExecution } from "@/app/(keep)/workflows/builder/types"; +import { WorkflowExecutionDetail } from "@/shared/api/workflow-executions"; import { useEffect, useState } from "react"; import Skeleton from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; @@ -44,7 +44,7 @@ interface Pagination { offset: number; } -const columnHelper = createColumnHelper(); +const columnHelper = createColumnHelper(); export default function IncidentWorkflowTable({ incident }: Props) { const [workflowsPagination, setWorkflowsPagination] = useState({ @@ -53,7 +53,7 @@ export default function IncidentWorkflowTable({ incident }: Props) { }); const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [selectedExecution, setSelectedExecution] = - useState(null); + useState(null); const { data: workflows, @@ -94,7 +94,7 @@ export default function IncidentWorkflowTable({ incident }: Props) { setIsSidebarOpen(!isSidebarOpen); }; - const handleRowClick = (execution: WorkflowExecution) => { + const handleRowClick = (execution: WorkflowExecutionDetail) => { setSelectedExecution(execution); toggleSidebar(); }; diff --git a/keep-ui/app/(keep)/workflows/[workflow_id]/runs/[workflow_execution_id]/page.tsx b/keep-ui/app/(keep)/workflows/[workflow_id]/runs/[workflow_execution_id]/page.tsx index d171ca97c..ba0020ddf 100644 --- a/keep-ui/app/(keep)/workflows/[workflow_id]/runs/[workflow_execution_id]/page.tsx +++ b/keep-ui/app/(keep)/workflows/[workflow_id]/runs/[workflow_execution_id]/page.tsx @@ -1,6 +1,6 @@ -"use client"; import React from "react"; -import WorkflowExecutionResults from "@/app/(keep)/workflows/builder/workflow-execution-results"; +import { Metadata } from "next"; +import { WorkflowExecutionResults } from "@/features/workflow-execution-results"; export default function WorkflowExecutionPage({ params, @@ -9,8 +9,13 @@ export default function WorkflowExecutionPage({ }) { return ( ); } + +export const metadata: Metadata = { + title: "Keep - Workflow Execution Results", +}; 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 89e4876a9..34c39252f 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 @@ -1,8 +1,7 @@ "use client"; import { useApi } from "@/shared/lib/hooks/useApi"; -import { Workflow } from "../models"; - +import { Workflow } from "@/shared/api/workflows"; import useSWR from "swr"; import Skeleton from "react-loading-skeleton"; import { Button, Text } from "@tremor/react"; @@ -53,9 +52,7 @@ export default function WorkflowDetailHeader({
-

- {workflow.name} -

+

{workflow.name}

{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 2362cba1e..b086813fb 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 @@ -15,7 +15,7 @@ import { WrenchIcon, } from "@heroicons/react/24/outline"; import Loading from "@/app/(keep)/loading"; -import { Workflow } from "../models"; +import { Workflow } from "@/shared/api/workflows"; import useSWR from "swr"; import { WorkflowBuilderPageClient } from "../builder/page.client"; import WorkflowOverview from "./workflow-overview"; diff --git a/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-execution-table.tsx b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-execution-table.tsx index 9f0938922..c70245445 100644 --- a/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-execution-table.tsx +++ b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-execution-table.tsx @@ -5,8 +5,8 @@ import { } from "@tanstack/react-table"; import { PaginatedWorkflowExecutionDto, - WorkflowExecution, -} from "../builder/types"; + WorkflowExecutionDetail, +} from "@/shared/api/workflow-executions"; import { GenericTable } from "@/components/table/GenericTable"; import Link from "next/link"; import { Dispatch, Fragment, SetStateAction } from "react"; @@ -34,7 +34,7 @@ interface Props { setPagination: Dispatch>; } -function ExecutionRowMenu({ row }: { row: Row }) { +function ExecutionRowMenu({ row }: { row: Row }) { const stopPropagation = (e: React.MouseEvent) => { e.stopPropagation(); }; @@ -180,7 +180,7 @@ export function extractTriggerDetails( } export function ExecutionTable({ executions, setPagination }: Props) { - const columnHelper = createColumnHelper(); + const columnHelper = createColumnHelper(); const router = useRouter(); const columns = [ @@ -289,11 +289,11 @@ export function ExecutionTable({ executions, setPagination }: Props) { header: "", cell: ({ row }) => , }), - ] as DisplayColumnDef[]; + ] as DisplayColumnDef[]; //To DO pagiantion limit and offest can also be added to url searchparams return ( - + data={executions.items} columns={columns} rowCount={executions.count ?? 0} // Assuming pagination is not needed, you can adjust this if you have pagination @@ -302,7 +302,7 @@ export function ExecutionTable({ executions, setPagination }: Props) { onPaginationChange={(newLimit: number, newOffset: number) => setPagination({ limit: newLimit, offset: newOffset }) } - onRowClick={(row: WorkflowExecution) => { + onRowClick={(row: WorkflowExecutionDetail) => { router.push(`/workflows/${row.workflow_id}/runs/${row.id}`); }} /> 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 a5cd8d96e..394501bf8 100644 --- a/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-overview.tsx +++ b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-overview.tsx @@ -6,7 +6,7 @@ import { useSearchParams } from "next/navigation"; import { useState, useEffect } from "react"; import Loading from "@/app/(keep)/loading"; import { WorkflowSteps } from "../mockworkflows"; -import { Workflow } from "../models"; +import { Workflow } from "@/shared/api/workflows"; import WorkflowGraph from "../workflow-graph"; import { TableFilters } from "./table-filters"; import { ExecutionTable } from "./workflow-execution-table"; diff --git a/keep-ui/app/(keep)/workflows/builder/builder-workflow-testrun-modal.tsx b/keep-ui/app/(keep)/workflows/builder/builder-workflow-testrun-modal.tsx index 6205fa1ba..bae36f238 100644 --- a/keep-ui/app/(keep)/workflows/builder/builder-workflow-testrun-modal.tsx +++ b/keep-ui/app/(keep)/workflows/builder/builder-workflow-testrun-modal.tsx @@ -1,13 +1,17 @@ import { XMarkIcon } from "@heroicons/react/24/outline"; import { Button, Title } from "@tremor/react"; import ReactLoading from "react-loading"; -import { ExecutionResults } from "./workflow-execution-results"; -import { WorkflowExecution, WorkflowExecutionFailure } from "./types"; +import { + isWorkflowExecution, + WorkflowExecutionDetail, + WorkflowExecutionFailure, +} from "@/shared/api/workflow-executions"; +import { WorkflowExecutionResults } from "@/features/workflow-execution-results"; interface Props { closeModal: () => void; workflowId: string; - workflowExecution: WorkflowExecution | WorkflowExecutionFailure | null; + workflowExecution: WorkflowExecutionDetail | WorkflowExecutionFailure | null; workflowRaw: string; } @@ -37,10 +41,14 @@ export default function BuilderWorkflowTestRunModalContent({
{workflowExecution ? ( - ) : (
diff --git a/keep-ui/app/(keep)/workflows/builder/builder.tsx b/keep-ui/app/(keep)/workflows/builder/builder.tsx index 2c0100459..6de4924c7 100644 --- a/keep-ui/app/(keep)/workflows/builder/builder.tsx +++ b/keep-ui/app/(keep)/workflows/builder/builder.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Callout, Card } from "@tremor/react"; import { Provider } from "../../providers/providers"; import { @@ -17,16 +17,18 @@ import { Alert } from "./legacy-workflow.types"; import BuilderModalContent from "./builder-modal"; import Loader from "./loader"; import { stringify } from "yaml"; -import { useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { v4 as uuidv4 } from "uuid"; import BuilderWorkflowTestRunModalContent from "./builder-workflow-testrun-modal"; import { Definition as FlowDefinition, ReactFlowDefinition, V2Step, - WorkflowExecution, - WorkflowExecutionFailure, } from "./types"; +import { + WorkflowExecutionDetail, + WorkflowExecutionFailure, +} from "@/shared/api/workflow-executions"; import ReactFlowBuilder from "./ReactFlowBuilder"; import { ReactFlowProvider } from "@xyflow/react"; import useStore from "./builder-store"; @@ -35,7 +37,6 @@ import { useApi } from "@/shared/lib/hooks/useApi"; import { KeepApiError } from "@/shared/api"; import { showErrorToast, showSuccessToast } from "@/shared/ui"; import { YAMLException } from "js-yaml"; -import WorkflowDefinitionYAML from "../workflow-definition-yaml"; import { revalidatePath } from "next/cache"; import Modal from "@/components/ui/Modal"; @@ -59,15 +60,6 @@ const INITIAL_DEFINITION = wrapDefinitionV2({ isValid: false, }); -const YAMLSidebar = ({ yaml }: { yaml?: string }) => { - return ( -
-

YAML

- {yaml && } -
- ); -}; - function Builder({ loadedAlertFile, fileName, @@ -93,9 +85,10 @@ function Builder({ const [generateModalIsOpen, setGenerateModalIsOpen] = useState(false); const [testRunModalOpen, setTestRunModalOpen] = useState(false); const [runningWorkflowExecution, setRunningWorkflowExecution] = useState< - WorkflowExecution | WorkflowExecutionFailure | null + WorkflowExecutionDetail | WorkflowExecutionFailure | null >(null); const [compiledAlert, setCompiledAlert] = useState(null); + const router = useRouter(); const searchParams = useSearchParams(); const { errorNode, setErrorNode, canDeploy, synced } = useStore(); @@ -119,7 +112,7 @@ function Builder({ setErrorNode(null); }; - const updateWorkflow = () => { + const updateWorkflow = useCallback(() => { const body = stringify(buildAlert(definition.value)); api .request(`/workflows/${workflowId}`, { @@ -133,7 +126,7 @@ function Builder({ .catch((error: any) => { showErrorToast(error, "Failed to add workflow"); }); - }; + }, [api, definition.value, workflowId]); const testRunWorkflow = () => { setTestRunModalOpen(true); @@ -157,7 +150,7 @@ function Builder({ }); }; - const addWorkflow = () => { + const addWorkflow = useCallback(() => { const body = stringify(buildAlert(definition.value)); api .request(`/workflows/json`, { @@ -165,16 +158,17 @@ function Builder({ body, headers: { "Content-Type": "text/html" }, }) - .then(() => { + .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); @@ -265,7 +259,15 @@ function Builder({ addWorkflow(); } } - }, [canDeploy, errorNode, definition.isValid, synced, workflowId]); + }, [ + canDeploy, + errorNode, + definition.isValid, + synced, + workflowId, + updateWorkflow, + addWorkflow, + ]); useEffect(() => { enableGenerate( diff --git a/keep-ui/app/(keep)/workflows/builder/types.ts b/keep-ui/app/(keep)/workflows/builder/types.ts index 593da16de..dd12ea3ff 100644 --- a/keep-ui/app/(keep)/workflows/builder/types.ts +++ b/keep-ui/app/(keep)/workflows/builder/types.ts @@ -1,4 +1,3 @@ -import { Workflow } from "../models"; import { Edge, Node, @@ -7,45 +6,6 @@ import { OnNodesChange, } from "@xyflow/react"; -export interface LogEntry { - timestamp: string; - message: string; - context: string; -} - -export interface WorkflowExecution { - id: string; - workflow_id: string; - tenant_id: string; - started: string; - triggered_by: string; - status: string; - results: Record; - workflow_name?: string; - logs?: LogEntry[] | null; - error?: string | null; - execution_time?: number; - event_id?: string; - event_type?: string; -} - -export interface PaginatedWorkflowExecutionDto { - limit: number; - offset: number; - count: number; - items: WorkflowExecution[]; - workflow: Workflow; - avgDuration: number; - passCount: number; - failCount: number; -} - -export type WorkflowExecutionFailure = Pick; - -export function isWorkflowExecution(data: any): data is WorkflowExecution { - return "id" in data; -} - export type V2Properties = Record; export type Definition = { sequence: V2Step[]; diff --git a/keep-ui/app/(keep)/workflows/builder/workflow-execution-results.tsx b/keep-ui/app/(keep)/workflows/builder/workflow-execution-results.tsx deleted file mode 100644 index f78b8ae94..000000000 --- a/keep-ui/app/(keep)/workflows/builder/workflow-execution-results.tsx +++ /dev/null @@ -1,295 +0,0 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import { Card, Title, Button } from "@tremor/react"; -import Loading from "@/app/(keep)/loading"; -import { ExclamationCircleIcon } from "@heroicons/react/24/outline"; -import { - Callout, - Table, - TableBody, - TableCell, - TableHead, - TableRow, -} from "@tremor/react"; -import useSWR from "swr"; -import { - WorkflowExecution, - WorkflowExecutionFailure, - isWorkflowExecution, -} from "./types"; -import { useApi } from "@/shared/lib/hooks/useApi"; -import WorkflowDefinitionYAML from "../workflow-definition-yaml"; - -interface WorkflowResultsProps { - workflow_id: string; - workflow_execution_id: string; -} - -export default function WorkflowExecutionResults({ - workflow_id, - workflow_execution_id, -}: WorkflowResultsProps) { - const api = useApi(); - const [refreshInterval, setRefreshInterval] = useState(1000); - const [checks, setChecks] = useState(1); - const [error, setError] = useState(null); - - const { data: executionData, error: executionError } = useSWR( - api.isReady() - ? `/workflows/${workflow_id}/runs/${workflow_execution_id}` - : null, - async (url) => { - const fetchedData = await api.get(url); - if (fetchedData.status === "in_progress") { - setChecks((c) => c + 1); - } - return fetchedData; - }, - { - refreshInterval: refreshInterval, - } - ); - - // Get workflow definition - const { data: workflowData, error: workflowError } = useSWR( - api.isReady() ? `/workflows/${workflow_id}` : null, - (url) => api.get(url) - ); - - useEffect(() => { - if (!executionData) return; - - if (executionData?.status !== "in_progress") { - console.log("Stopping refresh interval"); - setRefreshInterval(0); - } - if (executionData.error) { - setError(executionData?.error); - console.log("Stopping refresh interval"); - setRefreshInterval(0); - } else if (executionData?.status === "success") { - setError(executionData?.error); - setRefreshInterval(0); - } - }, [executionData]); - - if (executionError) { - console.error("Error fetching execution status", executionError); - } - - if (!executionData || !workflowData) return ; - - if (executionError) { - return ( - - Failed to load workflow execution - - ); - } - - if (workflowError) { - return ( - - Failed to load workflow definition - - ); - } - - return ( - - ); -} - -export function ExecutionResults({ - workflowId, - executionData, - workflowRaw, - checks, -}: { - executionData: WorkflowExecution | WorkflowExecutionFailure; - workflowId: string | undefined; - workflowRaw: string | undefined; - checks?: number; -}) { - const api = useApi(); - - let status: WorkflowExecution["status"] | undefined; - let logs: WorkflowExecution["logs"] | undefined; - let results: WorkflowExecution["results"] | undefined; - let eventId: string | undefined; - let eventType: string | undefined; - - const error = executionData.error; - - if (isWorkflowExecution(executionData)) { - status = executionData.status; - logs = executionData.logs; - results = executionData.results; - eventId = executionData.event_id; - eventType = executionData.event_type; - } - - const getCurlCommand = () => { - let token = api.getToken(); - let url = api.getApiBaseUrl(); - // Only include workflow ID if workflowData is available - const workflowIdParam = workflowId ? `/${workflowId}` : ""; - return `curl -X POST "${url}/workflows${workflowIdParam}/run?event_type=${eventType}&event_id=${eventId}" \\ - -H "Authorization: Bearer ${token}" \\ - -H "Content-Type: application/json"`; - }; - - const copyToClipboard = () => { - navigator.clipboard.writeText(getCurlCommand()); - }; - - return ( -
- {/* Error Card */} - {error && ( - -
-
- {error.split("\n").map((line, index) => ( -

{line}

- ))} -
- {eventId && eventType && ( - - )} -
-
- )} - - {/* Workflow Results Card */} - {/* - -
- {results && Object.keys(results).length > 0 && ( -
- Workflow Results - - - - - Action ID - - - Results - - - - - {Object.entries(results).map(([stepId, stepResults], index) => ( - - - {stepId} - - - - Value - -
-                              {JSON.stringify(stepResults, null, 2)}
-                            
-
-
-
-
- ))} -
-
-
- )} -
-
- */} - - {/* Lower Section with Logs and Definition */} -
- {/* Workflow Logs Card */} - -
- {status === "in_progress" ? ( -
-
-

- The workflow is in progress, will check again in one second - (times checked: {checks}) -

-
- -
- ) : ( -
- Workflow Logs - - - - - Timestamp - - - Message - - - - - {(logs ?? []).map((log, index) => ( - - - {log.timestamp} - - - {log.message} - - - ))} - -
-
- )} -
-
- - {/* Workflow Definition Card */} - - Workflow Definition -
- -
-
-
-
- ); -} diff --git a/keep-ui/app/(keep)/workflows/mockworkflows.tsx b/keep-ui/app/(keep)/workflows/mockworkflows.tsx index 3b2e463d8..a6fb570fa 100644 --- a/keep-ui/app/(keep)/workflows/mockworkflows.tsx +++ b/keep-ui/app/(keep)/workflows/mockworkflows.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { MockStep, MockWorkflow } from "./models"; +import { MockStep, MockWorkflow } from "@/shared/api/workflows"; import Loading from "@/app/(keep)/loading"; import { Button, Card, Tab, TabGroup, TabList } from "@tremor/react"; import { useRouter } from "next/navigation"; @@ -117,8 +117,8 @@ export default function MockWorkflowCardSection({ } return ( -
-

+
+

Discover workflow templates

{/* TODO: Implement the commented out code block */} @@ -149,7 +149,7 @@ export default function MockWorkflowCardSection({

No workflows found

)} -
+
{mockError && (

Error: {mockError.message || "Something went wrong!"} diff --git a/keep-ui/app/(keep)/workflows/noworkflows.tsx b/keep-ui/app/(keep)/workflows/noworkflows.tsx index 6e38bbd9c..d381d1cd5 100644 --- a/keep-ui/app/(keep)/workflows/noworkflows.tsx +++ b/keep-ui/app/(keep)/workflows/noworkflows.tsx @@ -1,8 +1,7 @@ -import React, { useState, useEffect } from "react"; +import React from "react"; import { CircleStackIcon } from "@heroicons/react/24/outline"; import { Callout, Italic, Button } from "@tremor/react"; import Link from "next/link"; -import { Workflow } from "./models"; import { useRouter } from "next/navigation"; import { MdArrowForwardIos } from "react-icons/md"; import { IoMdCard } from "react-icons/io"; diff --git a/keep-ui/app/(keep)/workflows/workflow-definition-yaml.tsx b/keep-ui/app/(keep)/workflows/workflow-definition-yaml.tsx deleted file mode 100644 index 459fa971a..000000000 --- a/keep-ui/app/(keep)/workflows/workflow-definition-yaml.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import React from "react"; -import { load, dump } from "js-yaml"; -import { CheckCircle, XCircle, Clock } from "lucide-react"; - -interface LogEntry { - timestamp: string; - message: string; -} - -interface Props { - workflowRaw: string; - executionLogs?: LogEntry[] | null; - executionStatus?: string; -} - -export default function WorkflowDefinitionYAML({ - workflowRaw, - executionLogs, - executionStatus, -}: Props) { - const reorderWorkflowSections = (yamlString: string) => { - const content = yamlString.startsWith('"') - ? JSON.parse(yamlString) - : yamlString; - - const workflow = load(content) as any; - const workflowData = workflow.workflow; - - const metadataFields = ["id", "name", "description", "disabled"]; - const sectionOrder = [ - "triggers", - "consts", - "owners", - "services", - "steps", - "actions", - ]; - - const orderedWorkflow: any = { - workflow: {}, - }; - - metadataFields.forEach((field) => { - if (workflowData[field] !== undefined) { - orderedWorkflow.workflow[field] = workflowData[field]; - } - }); - - sectionOrder.forEach((section) => { - if (workflowData[section] !== undefined) { - orderedWorkflow.workflow[section] = workflowData[section]; - } - }); - - return dump(orderedWorkflow, { - indent: 2, - lineWidth: -1, - noRefs: true, - sortKeys: false, - quotingType: '"', - }); - }; - - const getStatus = (name: string, isAction: boolean = false) => { - if (!executionLogs || !executionStatus) return "pending"; - if (executionStatus === "in_progress") return "in_progress"; - - const type = isAction ? "Action" : "Step"; - const successPattern = `${type} ${name} ran successfully`; - const failurePattern = `Failed to run ${type.toLowerCase()} ${name}`; - - const hasSuccessLog = executionLogs.some( - (log) => log.message?.includes(successPattern) - ); - const hasFailureLog = executionLogs.some( - (log) => log.message?.includes(failurePattern) - ); - - if (hasSuccessLog) return "success"; - if (hasFailureLog) return "failed"; - return "pending"; - }; - - const getStepIcon = (status: string) => { - switch (status) { - case "success": - return ; - case "failed": - return ; - case "in_progress": - return ; - default: - return null; - } - }; - - const renderYamlWithIcons = () => { - const orderedYaml = reorderWorkflowSections(workflowRaw); - const lines = orderedYaml.split("\n"); - let currentName: string | null = null; - let isInActions = false; - - return lines.map((line, index) => { - const trimmedLine = line.trim(); - - if (trimmedLine === "actions:") { - isInActions = true; - } else if (trimmedLine.startsWith("steps:")) { - isInActions = false; - } - - if (trimmedLine.startsWith("- name:")) { - currentName = trimmedLine.split("name:")[1].trim(); - } - - const status = currentName ? getStatus(currentName, isInActions) : null; - const icon = status ? getStepIcon(status) : null; - - return ( -

-
{icon}
-
{line || "\u00A0"}
-
- ); - }); - }; - - return ( -
- {renderYamlWithIcons()} -
- ); -} diff --git a/keep-ui/app/(keep)/workflows/workflow-graph.tsx b/keep-ui/app/(keep)/workflows/workflow-graph.tsx index 432f760bb..b56d38352 100644 --- a/keep-ui/app/(keep)/workflows/workflow-graph.tsx +++ b/keep-ui/app/(keep)/workflows/workflow-graph.tsx @@ -12,16 +12,14 @@ import { } from "chart.js"; import { Bar } from "react-chartjs-2"; import "chart.js/auto"; -import { Workflow, WorkflowExecution } from "./models"; -import { differenceInSeconds } from "date-fns"; -import { useRouter } from "next/navigation"; +import { Workflow } from "@/shared/api/workflows"; import { getRandomStatus, getLabels, getDataValues, getColors, chartOptions, -} from "./workflowutils"; +} from "./workflow-utils"; Chart.register( CategoryScale, @@ -47,7 +45,6 @@ export default function WorkflowGraph({ size?: string; showAll?: boolean; }) { - const router = useRouter(); const lastExecutions = useMemo(() => { let executions = workflow?.last_executions?.slice(0, limit) || []; if (showAll) { @@ -60,7 +57,7 @@ export default function WorkflowGraph({ return providerNotConfiguredExecutions.length == executions.length ? [] : executions.reverse(); - }, [workflow?.last_executions]); + }, [limit, showAll, workflow?.last_executions]); const hasNoData = !lastExecutions || lastExecutions.length === 0; let status = workflow?.last_execution_status?.toLowerCase() || ""; diff --git a/keep-ui/app/(keep)/workflows/workflow-tile.tsx b/keep-ui/app/(keep)/workflows/workflow-tile.tsx index e6627ed12..c4c06099d 100644 --- a/keep-ui/app/(keep)/workflows/workflow-tile.tsx +++ b/keep-ui/app/(keep)/workflows/workflow-tile.tsx @@ -1,12 +1,11 @@ "use client"; -import { Workflow } from "./models"; import Image from "next/image"; -import React, { useState, useMemo } from "react"; +import React, { useState } from "react"; import { useRouter } from "next/navigation"; import WorkflowMenu from "./workflow-menu"; import Loading from "@/app/(keep)/loading"; -import { Trigger, Provider } from "./models"; +import { Trigger, Provider, Workflow } from "@/shared/api/workflows"; import { Button, Text, @@ -24,7 +23,6 @@ import ProviderForm from "@/app/(keep)/providers/provider-form"; import SlidingPanel from "react-sliding-side-panel"; import { useFetchProviders } from "@/app/(keep)/providers/page.client"; import { Provider as FullProvider } from "@/app/(keep)/providers/providers"; -import "./workflow-tile.css"; import { CheckCircleIcon, CursorArrowRaysIcon, @@ -36,7 +34,6 @@ import TimeAgo, { Formatter, Suffix, Unit } from "react-timeago"; import WorkflowGraph from "./workflow-graph"; import { PiDiamondsFourFill } from "react-icons/pi"; import Modal from "@/components/ui/Modal"; -import { FaHandPointer } from "react-icons/fa"; import { MdOutlineKeyboardArrowRight, MdOutlineKeyboardArrowLeft, @@ -44,6 +41,7 @@ import { import { HiBellAlert } from "react-icons/hi2"; import { useWorkflowRun } from "utils/hooks/useWorkflowRun"; import { useApi } from "@/shared/lib/hooks/useApi"; +import "./workflow-tile.css"; function WorkflowMenuSection({ onDelete, @@ -523,7 +521,7 @@ function WorkflowTile({ workflow }: { workflow: Workflow }) { }; return ( -
+
{isRunning && (
diff --git a/keep-ui/app/(keep)/workflows/workflowutils.ts b/keep-ui/app/(keep)/workflows/workflow-utils.ts similarity index 92% rename from keep-ui/app/(keep)/workflows/workflowutils.ts rename to keep-ui/app/(keep)/workflows/workflow-utils.ts index bf27ec8e6..353ee0625 100644 --- a/keep-ui/app/(keep)/workflows/workflowutils.ts +++ b/keep-ui/app/(keep)/workflows/workflow-utils.ts @@ -1,5 +1,5 @@ import { differenceInSeconds } from "date-fns"; -import { WorkflowExecution } from "./models"; +import { LastWorkflowExecution } from "@/shared/api/workflows"; const demoLabels = [ "Jan", @@ -59,10 +59,7 @@ const demoColors = [ "rgba(255, 99, 132, 1)", // Red ]; export const getLabels = ( - lastExecutions: Pick< - WorkflowExecution, - "execution_time" | "status" | "started" - >[], + lastExecutions: LastWorkflowExecution[], show_real_data?: boolean ) => { if (!lastExecutions || (lastExecutions && lastExecutions.length === 0)) { @@ -77,10 +74,7 @@ export const getLabels = ( }; export const getDataValues = ( - lastExecutions: Pick< - WorkflowExecution, - "execution_time" | "status" | "started" - >[], + lastExecutions: LastWorkflowExecution[], show_real_data?: boolean ) => { if (!lastExecutions || (lastExecutions && lastExecutions.length === 0)) { @@ -109,10 +103,7 @@ const _getColor = (status: string, opacity: number) => { }; export const getColors = ( - lastExecutions: Pick< - WorkflowExecution, - "execution_time" | "status" | "started" - >[], + lastExecutions: LastWorkflowExecution[], status: string, isBgColor?: boolean, show_real_data?: boolean diff --git a/keep-ui/app/(keep)/workflows/workflows.client.tsx b/keep-ui/app/(keep)/workflows/workflows.client.tsx index 7a1849794..c995b1f84 100644 --- a/keep-ui/app/(keep)/workflows/workflows.client.tsx +++ b/keep-ui/app/(keep)/workflows/workflows.client.tsx @@ -7,12 +7,11 @@ import { ArrowUpOnSquareStackIcon, PlusCircleIcon, } from "@heroicons/react/24/outline"; -import { Workflow, MockWorkflow } from "./models"; +import { Workflow, MockWorkflow } from "@/shared/api/workflows"; import Loading from "@/app/(keep)/loading"; -import React from "react"; import WorkflowsEmptyState from "./noworkflows"; import WorkflowTile from "./workflow-tile"; -import { Button, Card, Title } from "@tremor/react"; +import { Button, Title } from "@tremor/react"; import { ArrowRightIcon } from "@radix-ui/react-icons"; import { useRouter } from "next/navigation"; import Modal from "@/components/ui/Modal"; @@ -20,11 +19,54 @@ import MockWorkflowCardSection from "./mockworkflows"; import { useApi } from "@/shared/lib/hooks/useApi"; import { KeepApiError } from "@/shared/api"; import { showErrorToast, Input, ErrorComponent } from "@/shared/ui"; +import { Textarea } from "@/components/ui"; + +const EXAMPLE_WORKFLOW_DEFINITIONS = { + slack: ` + workflow: + id: slack-demo + description: Send a slack message when any alert is triggered or manually + triggers: + - type: alert + - type: manual + actions: + - name: trigger-slack + provider: + type: slack + config: " {{ providers.slack }} " + with: + message: "Workflow ran | reason: {{ event.trigger }}" + `, + sql: ` + workflow: + id: bq-sql-query + description: Run SQL on Bigquery and send the results to slack + triggers: + - type: manual + steps: + - name: get-sql-data + provider: + type: bigquery + config: "{{ providers.bigquery-prod }}" + with: + query: "SELECT * FROM some_database LIMIT 1" + actions: + - name: trigger-slack + provider: + type: slack + config: " {{ providers.slack-prod }} " + with: + message: "Results from the DB: ({{ steps.get-sql-data.results }})" + `, +}; + +type ExampleWorkflowKey = keyof typeof EXAMPLE_WORKFLOW_DEFINITIONS; export default function WorkflowsPage() { const api = useApi(); const router = useRouter(); const fileInputRef = useRef(null); + const [workflowDefinition, setWorkflowDefinition] = useState(""); const [isModalOpen, setIsModalOpen] = useState(false); // Only fetch data when the user is authenticated @@ -113,185 +155,184 @@ export default function WorkflowsPage() { } }; - function handleStaticExampleSelect(example: string) { - // todo: something less static - let hardCodedYaml = ""; - if (example === "slack") { - hardCodedYaml = ` - workflow: - id: slack-demo - description: Send a slack message when any alert is triggered or manually - triggers: - - type: alert - - type: manual - actions: - - name: trigger-slack - provider: - type: slack - config: " {{ providers.slack }} " - with: - message: "Workflow ran | reason: {{ event.trigger }}" - `; - } else { - hardCodedYaml = ` - workflow: - id: bq-sql-query - description: Run SQL on Bigquery and send the results to slack - triggers: - - type: manual - steps: - - name: get-sql-data - provider: - type: bigquery - config: "{{ providers.bigquery-prod }}" - with: - query: "SELECT * FROM some_database LIMIT 1" - actions: - - name: trigger-slack - provider: - type: slack - config: " {{ providers.slack-prod }} " - with: - message: "Results from the DB: ({{ steps.get-sql-data.results }})" - `; - } - const blob = new Blob([hardCodedYaml], { type: "application/x-yaml" }); - const file = new File([blob], `${example}.yml`, { + function handleWorkflowDefinitionString( + workflowDefinition: string, + name: string = "New workflow" + ) { + const blob = new Blob([workflowDefinition], { + type: "application/x-yaml", + }); + const file = new File([blob], `${name}.yml`, { type: "application/x-yaml", }); - const event = { target: { files: [file], }, }; onDrop(event as any); + } + + function handleStaticExampleSelect(exampleKey: ExampleWorkflowKey) { + switch (exampleKey) { + case "slack": + handleWorkflowDefinitionString(EXAMPLE_WORKFLOW_DEFINITIONS.slack); + break; + case "sql": + handleWorkflowDefinitionString(EXAMPLE_WORKFLOW_DEFINITIONS.sql); + break; + default: + throw new Error(`Invalid example workflow key: ${exampleKey}`); + } setIsModalOpen(false); } return ( -
-
-
- Workflows - Automate your alert management with workflows. -
-
- - -
- setIsModalOpen(false)} - title="Upload Workflow files" - > -
-
- { - onDrop(e); - setIsModalOpen(false); // Add this line to close the modal - }} - /> -

- Only .yml and .yaml files are supported. -

+ <> +
+
+
+
+ + Workflows + + + Automate your alert management with workflows. +
-
-

Or just try some from Keep examples:

+
- - -

- More examples at{" "} - - Keep GitHub repo - -

+
+ {data.length === 0 ? ( + + ) : ( +
+ {data.map((workflow) => ( + + ))} +
+ )} +
+
+ +
+
+ setIsModalOpen(false)} + title="Upload Workflow files" + > +
+
+ { + onDrop(e); + setIsModalOpen(false); // Add this line to close the modal + }} + /> +

+ Only .yml and .yaml files are supported. +

+
+
+

Or paste the YAML definition:

+