From add75fb2aa8bbd8ed47f68dfc3d242cb78cd68d8 Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda <38752904+rajeshj11@users.noreply.github.com> Date: Fri, 1 Nov 2024 00:16:10 +0530 Subject: [PATCH] =?UTF-8?q?fix:=20refactor=20the=20workflow=20execution=20?= =?UTF-8?q?component=20to=20reduce=20repaint/rend=E2=80=A6=20(#2211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tal Co-authored-by: Kirill Chernakov --- keep-ui/app/alerts/alert-quality-table.tsx | 17 +- .../workflows/[workflow_id]/executions.tsx | 264 ++++-------------- .../app/workflows/[workflow_id]/layout.tsx | 6 +- .../workflows/[workflow_id]/side-nav-bar.tsx | 153 ++++++---- .../[workflow_id]/workflow-overview.tsx | 226 +++++++++++++++ .../app/workflows/builder/builder-modal.tsx | 31 +- keep-ui/components/ui/discolsure-section.tsx | 73 +++-- keep-ui/utils/hooks/useAlertQuality.ts | 26 +- keep-ui/utils/hooks/useWorkflowExecutions.ts | 2 +- keep/api/routes/workflows.py | 46 ++- 10 files changed, 531 insertions(+), 313 deletions(-) create mode 100644 keep-ui/app/workflows/[workflow_id]/workflow-overview.tsx diff --git a/keep-ui/app/alerts/alert-quality-table.tsx b/keep-ui/app/alerts/alert-quality-table.tsx index bdde64bc8..88dcf3e2a 100644 --- a/keep-ui/app/alerts/alert-quality-table.tsx +++ b/keep-ui/app/alerts/alert-quality-table.tsx @@ -11,11 +11,12 @@ import { GenericTable } from "@/components/table/GenericTable"; import { useAlertQualityMetrics } from "utils/hooks/useAlertQuality"; import { useProviders } from "utils/hooks/useProviders"; import { Provider, ProvidersResponse } from "app/providers/providers"; -import { TabGroup, TabList, Tab } from "@tremor/react"; +import { TabGroup, TabList, Tab, Callout } from "@tremor/react"; import { GenericFilters } from "@/components/filters/GenericFilters"; import { useSearchParams } from "next/navigation"; import { AlertKnownKeys } from "./models"; import { createColumnHelper, DisplayColumnDef } from "@tanstack/react-table"; +import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; const tabs = [ { name: "All", value: "all" }, @@ -340,6 +341,20 @@ const AlertQuality = ({ isDashBoard ? (fieldsValue as string | string[]) : "" ); + + if (error) { + return ( + + Failed to load Alert Quality Metrics + + ); + } + return ( >; - tab: number; -}) => { - return ( -
- { - setTab(index); - }} - > - - {tabs.map((tabItem, index) => ( - {tabItem.name} - ))} - - -
- ); -}; +import useSWR from "swr"; +import { fetcher } from "@/utils/fetcher"; +import BuilderModalContent from "../builder/builder-modal"; +import PageClient from "../builder/page.client"; +import { useApiUrl } from "@/utils/hooks/useConfig"; +import WorkflowOverview from "./workflow-overview"; -export function StatsCard({ - children, - data, -}: { - children: any; - data?: string; -}) { - return ( - - {!!data && ( -
- {data} -
- )} - {children} -
- ); -} -interface Pagination { - limit: number; - offset: number; -} export default function WorkflowDetailPage({ params, @@ -91,39 +25,33 @@ export default function WorkflowDetailPage({ params: { workflow_id: string }; }) { const router = useRouter(); - const { data: session, status, update } = useSession(); + const { data: session, status } = useSession(); + const [navlink, setNavLink] = useState("overview"); - const [executionPagination, setExecutionPagination] = useState({ - limit: 25, - offset: 0, - }); - const [tab, setTab] = useState(1); - const searchParams = useSearchParams(); - - useEffect(() => { - setExecutionPagination({ - ...executionPagination, - offset: 0, - }); - }, [tab, searchParams]); + const apiUrl = useApiUrl(); - const { data, isLoading, error } = useWorkflowExecutionsV2( - params.workflow_id, - tab, - executionPagination.limit, - executionPagination.offset + const { + data: workflow, + isLoading, + error, + } = useSWR>( + () => (session ? `${apiUrl}/workflows/${params.workflow_id}` : null), + (url: string) => fetcher(url, session?.accessToken) ); - const { - isRunning, - handleRunClick, - getTriggerModalProps, - isRunButtonDisabled, - message, - } = useWorkflowRun(data?.workflow!); + useEffect(() => { + if (status === "unauthenticated") { + router.push("/signin"); + } + }, [status, router]); + + // Render loading state if session is loading + // If the user is unauthenticated, display a loading state until the side effect is processed; after that, it will automatically redirect. + if (status === "loading" || status === "unauthenticated") return ; - if (isLoading || !data) return ; + // Handle error state for fetching workflow data + if (isLoading) return ; if (error) { return ( ); } - if (status === "loading" || isLoading || !data) return ; - if (status === "unauthenticated") router.push("/signin"); - - const parsedWorkflowFile = load(data?.workflow?.workflow_raw ?? "", { - schema: JSON_SCHEMA, - }) as any; - const formatNumber = (num: number) => { - if (num >= 1_000_000) { - return `${(num / 1_000_000).toFixed(1)}m`; - } else if (num >= 1_000) { - return `${(num / 1_000).toFixed(1)}k`; - } else { - return num?.toString() ?? ""; - } - }; + if (!workflow) { + return null; + } - const workflow = { last_executions: data.items } as Partial; return ( - <> - - -
-
-
- {/*TO DO update searchParams for these filters*/} - -
- {!!data.workflow && ( - - )} + + +
+ {navlink === "overview" && ( + + )} + {navlink === "builder" && ( +
+
- {data?.items && ( -
-
- - Total Executions -
-

- {formatNumber(data.count ?? 0)} -

-
-
- - Pass / Fail ratio -
-

- {formatNumber(data.passCount)} - {"/"} - {formatNumber(data.failCount)} -

-
-
- - Success % -
-

- {(data.count - ? (data.passCount / data.count) * 100 - : 0 - ).toFixed(2)} - {"%"} -

-
-
- - Avg. duration -
-

- {(data.avgDuration ?? 0).toFixed(2)} -

-
-
- - Involved Services - - -
- -

Execution History

- - -
+ )} +
+ {navlink === "view_yaml" && ( + {}} + compiledAlert={workflow.workflow_raw!} + id={workflow.id} + hideCloseButton={true} + /> )}
- - {!!data.workflow && !!getTriggerModalProps && ( - - )} - +
+
); } diff --git a/keep-ui/app/workflows/[workflow_id]/layout.tsx b/keep-ui/app/workflows/[workflow_id]/layout.tsx index f374dfdf1..427aefd89 100644 --- a/keep-ui/app/workflows/[workflow_id]/layout.tsx +++ b/keep-ui/app/workflows/[workflow_id]/layout.tsx @@ -2,7 +2,6 @@ import { ArrowLeftIcon } from "@radix-ui/react-icons"; import Link from "next/link"; - export default function Layout({ children, params, @@ -10,18 +9,17 @@ export default function Layout({ children: any; params: { workflow_id: string }; }) { - return ( <> -
+
Back to Workflows +
{children}
-
{children}
); } diff --git a/keep-ui/app/workflows/[workflow_id]/side-nav-bar.tsx b/keep-ui/app/workflows/[workflow_id]/side-nav-bar.tsx index d119778c7..8e35b3252 100644 --- a/keep-ui/app/workflows/[workflow_id]/side-nav-bar.tsx +++ b/keep-ui/app/workflows/[workflow_id]/side-nav-bar.tsx @@ -1,58 +1,109 @@ -import React, { useState } from 'react'; -import { CiUser } from 'react-icons/ci'; -import { FaSitemap } from 'react-icons/fa'; -import { AiOutlineSwap } from 'react-icons/ai'; -import { Workflow } from '../models'; +import React, { useEffect } from "react"; +import { CiUser } from "react-icons/ci"; +import { FaSitemap } from "react-icons/fa"; +import { AiOutlineSwap } from "react-icons/ai"; +import { Workflow } from "../models"; import { Text } from "@tremor/react"; -import { DisclosureSection } from '@/components/ui/discolsure-section'; -import { useWorkflowRun } from 'utils/hooks/useWorkflowRun'; -import Modal from 'react-modal'; -import BuilderWorkflowTestRunModalContent from '../builder/builder-workflow-testrun-modal'; -import BuilderModalContent from '../builder/builder-modal'; +import { DisclosureSection } from "@/components/ui/discolsure-section"; +import { useRouter, useSearchParams } from "next/navigation"; +import router from "next/router"; -export default function SideNavBar({ workflow }: { workflow: Workflow }) { - const [viewYaml, setviewYaml] = useState(false); +export default function SideNavBar({ + workflow, + handleLink, + navLink, +}: { + navLink: string; + workflow: Partial; + handleLink: React.Dispatch>; +}) { + const searchParams = useSearchParams(); + const router = useRouter(); - const analyseLinks = [ - { href: `/workflows/${workflow.id}`, icon: AiOutlineSwap, label: 'Overview', isLink: true }, - ]; + const analyseLinks = [ + { + href: "#overview", + icon: AiOutlineSwap, + label: "Overview", + key: "overview", + isLink: false, + handleClick: () => { + handleLink("overview"); + }, + }, + ]; - const manageLinks = [ - { href: `/workflows/builder/${workflow.id}`, icon: FaSitemap, label: 'Workflow Builder', isLink: true }, - { href: `/workflows/builder/${workflow.id}`, icon: CiUser, label: 'Workflow YAML definition', isLink: false, handleClick: () => { setviewYaml(true) } }, - ]; + const manageLinks = [ + { + href: `#builder`, + icon: FaSitemap, + label: "Workflow Builder", + key: "builder", + isLink: false, + handleClick: () => { + handleLink("builder"); + }, + }, + { + href: `#view_yaml`, + icon: CiUser, + label: "Workflow YAML definition", + key: "view_yaml", + isLink: false, + handleClick: () => { + handleLink("view_yaml"); + }, + }, + ]; - const learnLinks = [ - { href: `https://www.youtube.com/@keepalerting`, icon: FaSitemap, label: 'Tutorials', isLink: true , newTab:true}, - { href: `https://docs.keephq.dev`, icon: CiUser, label: 'Documentation', isLink: true, newTab: true }, - ]; + const learnLinks = [ + { + href: `https://www.youtube.com/@keepalerting`, + icon: FaSitemap, + label: "Tutorials", + isLink: true, + newTab: true, + key: "tutorials", + }, + { + href: `https://docs.keephq.dev`, + icon: CiUser, + label: "Documentation", + isLink: true, + newTab: true, + key: "documentation", + }, + ]; - return ( -
-
-

{workflow.name}

- {workflow.description && ( - - {workflow.description} - - )} -
-
- - - -
- { setviewYaml(false); }} - className="bg-gray-50 p-4 md:p-10 mx-auto max-w-7xl mt-20 border border-orange-600/50 rounded-md" - > - { setviewYaml(false) }} - compiledAlert={workflow.workflow_raw!} - id={workflow.id} - /> - -
- ); + return ( +
+
+

+ {workflow.name} +

+ {workflow.description && ( + + {workflow.description} + + )} +
+
+ + + +
+
+ ); } diff --git a/keep-ui/app/workflows/[workflow_id]/workflow-overview.tsx b/keep-ui/app/workflows/[workflow_id]/workflow-overview.tsx new file mode 100644 index 000000000..baabbeed3 --- /dev/null +++ b/keep-ui/app/workflows/[workflow_id]/workflow-overview.tsx @@ -0,0 +1,226 @@ +import { useWorkflowExecutionsV2 } from "@/utils/hooks/useWorkflowExecutions"; +import { useWorkflowRun } from "@/utils/hooks/useWorkflowRun"; +import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; +import { Callout, Button, Title, Card, Tab, TabGroup, TabList } from "@tremor/react"; +import { load, JSON_SCHEMA } from "js-yaml"; +import { useSearchParams } from "next/navigation"; +import { useState, useEffect, Dispatch, SetStateAction } from "react"; +import Loading from "react-loading"; +import { WorkflowSteps } from "../mockworkflows"; +import { Workflow } from "../models"; +import WorkflowGraph from "../workflow-graph"; +import AlertTriggerModal from "../workflow-run-with-alert-modal"; +import { TableFilters } from "./table-filters"; +import { ExecutionTable } from "./workflow-execution-table"; + +interface Pagination { + limit: number; + offset: number; +} + +const tabs = [ + { name: "All Time", value: "alltime" }, + { name: "Last 30d", value: "last_30d" }, + { name: "Last 7d", value: "last_7d" }, + { name: "Today", value: "today" }, +]; + +export function StatsCard({ + children, + data, + }: { + children: any; + data?: string; + }) { + return ( + + {!!data && ( +
+ {data} +
+ )} + {children} +
+ ); + } + +export const FilterTabs = ({ + tabs, + setTab, + tab, + }: { + tabs: { name: string; value: string }[]; + setTab: Dispatch>; + tab: number; + }) => { + return ( +
+ { + setTab(index); + }} + > + + {tabs.map((tabItem, index) => ( + {tabItem.name} + ))} + + +
+ ); + }; +export default function WorkflowOverview({ + workflow_id, +}: { + workflow_id: string; +}) { + const [executionPagination, setExecutionPagination] = useState({ + limit: 25, + offset: 0, + }); + const [tab, setTab] = useState(1); + const searchParams = useSearchParams(); + + useEffect(() => { + setExecutionPagination({ + ...executionPagination, + offset: 0, + }); + }, [tab, searchParams]); + + const { data, isLoading, error } = useWorkflowExecutionsV2( + workflow_id, + tab, + executionPagination.limit, + executionPagination.offset + ); + + const { + isRunning, + handleRunClick, + getTriggerModalProps, + isRunButtonDisabled, + message, + } = useWorkflowRun(data?.workflow!); + + if (isLoading) return ; + + if (error) { + return ( + + Failed to load workflow + + ); + } + + const parsedWorkflowFile = load(data?.workflow?.workflow_raw ?? "", { + schema: JSON_SCHEMA, + }) as any; + + const formatNumber = (num: number) => { + if (num >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}m`; + } else if (num >= 1_000) { + return `${(num / 1_000).toFixed(1)}k`; + } else { + return num?.toString() ?? ""; + } + }; + + const workflow = { last_executions: data?.items } as Partial; + + return ( + <> +
+
+ {/*TO DO update searchParams for these filters*/} + +
+ {!!data?.workflow && ( + + )} +
+ {data?.items && ( +
+
+ + Total Executions +
+

+ {formatNumber(data.count ?? 0)} +

+
+
+ + Pass / Fail ratio +
+

+ {formatNumber(data.passCount)} + {"/"} + {formatNumber(data.failCount)} +

+
+
+ + Success % +
+

+ {(data.count + ? (data.passCount / data.count) * 100 + : 0 + ).toFixed(2)} + {"%"} +

+
+
+ + Avg. duration +
+

+ {(data.avgDuration ?? 0).toFixed(2)} +

+
+
+ + Involved Services + + +
+ +

Execution History

+ + +
+ )} + {!!data?.workflow && !!getTriggerModalProps && ( + + )} + + ); +} diff --git a/keep-ui/app/workflows/builder/builder-modal.tsx b/keep-ui/app/workflows/builder/builder-modal.tsx index f58abd140..c7267e793 100644 --- a/keep-ui/app/workflows/builder/builder-modal.tsx +++ b/keep-ui/app/workflows/builder/builder-modal.tsx @@ -11,13 +11,15 @@ import { downloadFileFromString } from "./utils"; interface Props { closeModal: () => void; compiledAlert: Alert | string | null; - id?:string + id?: string; + hideCloseButton?: boolean; } export default function BuilderModalContent({ closeModal, compiledAlert, id, + hideCloseButton, }: Props) { const [isLoading, setIsLoading] = useState(true); @@ -26,10 +28,13 @@ export default function BuilderModalContent({ setIsLoading(false); }, Math.floor(Math.random() * 2500 + 1000)); - const alertYaml = typeof compiledAlert !== 'string' ? stringify(compiledAlert) : compiledAlert; + const alertYaml = + typeof compiledAlert !== "string" + ? stringify(compiledAlert) + : compiledAlert; function download() { - const fileName = typeof compiledAlert == 'string' ? id : compiledAlert!.id; + const fileName = typeof compiledAlert == "string" ? id : compiledAlert!.id; downloadFileFromString(alertYaml, `${fileName}.yaml`); } @@ -52,15 +57,17 @@ export default function BuilderModalContent({ Keep alert specification ready to use
- + {!hideCloseButton && ( + + )}
diff --git a/keep-ui/components/ui/discolsure-section.tsx b/keep-ui/components/ui/discolsure-section.tsx index 8be3ab7ed..318d17fb6 100644 --- a/keep-ui/components/ui/discolsure-section.tsx +++ b/keep-ui/components/ui/discolsure-section.tsx @@ -1,24 +1,31 @@ -import { Disclosure } from '@headlessui/react'; -import classNames from 'classnames'; -import { IoChevronUp } from 'react-icons/io5'; +import { Disclosure } from "@headlessui/react"; +import classNames from "classnames"; +import { IoChevronUp } from "react-icons/io5"; import { Title, Subtitle, Button } from "@tremor/react"; -import { LinkWithIcon } from '@/components/LinkWithIcon'; -import React from 'react'; -import { IconType } from 'react-icons'; +import { LinkWithIcon } from "@/components/LinkWithIcon"; +import React from "react"; +import { IconType } from "react-icons"; interface DisclosureSectionProps { title: string; + activeLink: string; links: Array<{ href: string; icon: IconType | React.ReactNode; label: string; isLink: boolean; - handleClick?: (e:any) => void; + handleClick?: (e: any) => void; newTab?: boolean; + active?: boolean; + key: string; }>; } -export function DisclosureSection({ title, links }: DisclosureSectionProps) { +export function DisclosureSection({ + title, + links, + activeLink, +}: DisclosureSectionProps) { return ( {({ open }) => ( @@ -29,8 +36,8 @@ export function DisclosureSection({ title, links }: DisclosureSectionProps) {
@@ -38,18 +45,40 @@ export function DisclosureSection({ title, links }: DisclosureSectionProps) { as="ul" className="space-y-2 overflow-auto min-w-[max-content] py-2 pr-4" > - {links.map((link, index) => ( -
  • - - {}}>{link.label} - -
  • - ))} + {links.map((link, index) => { + const CustomIcon = link.icon as IconType; + return ( +
  • + {link.isLink && ( + + {}} + > + {link.label} + + + )} + {!link.isLink && ( +
    +
    + + {link.label} +
    +
    + )} +
  • + ); + })} )} diff --git a/keep-ui/utils/hooks/useAlertQuality.ts b/keep-ui/utils/hooks/useAlertQuality.ts index 1a928fd09..9921da550 100644 --- a/keep-ui/utils/hooks/useAlertQuality.ts +++ b/keep-ui/utils/hooks/useAlertQuality.ts @@ -1,33 +1,27 @@ import { useSession } from "next-auth/react"; -import { getApiURL } from "../apiUrl"; import { SWRConfiguration } from "swr"; import { fetcher } from "../fetcher"; import useSWRImmutable from "swr/immutable"; import { useSearchParams } from "next/navigation"; -import { useMemo } from "react"; +import { useMemo } from "react"; +import { useApiUrl } from "./useConfig"; export const useAlertQualityMetrics = ( fields: string | string[], options: SWRConfiguration = {} ) => { const { data: session } = useSession(); - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const searchParams = useSearchParams(); - ``; - let filters = useMemo(() => { - let params = searchParams?.toString(); + const filters = useMemo(() => { + const params = new URLSearchParams(searchParams?.toString() || ""); if (fields) { - fields = Array.isArray(fields) ? fields : [fields]; - let fieldParams = new URLSearchParams(""); - fields.forEach((field) => { - fieldParams.append("fields", field); - }); - params = params - ? `${params}&${fieldParams.toString()}` - : fieldParams.toString(); + const fieldArray = Array.isArray(fields) ? fields : [fields]; + fieldArray.forEach((field) => params.append("fields", field)); } - return params; - }, [fields?.toString(), searchParams?.toString()]); + + return params.toString(); + }, [fields, searchParams]); // TODO: Proper type needs to be defined. return useSWRImmutable>>( () => diff --git a/keep-ui/utils/hooks/useWorkflowExecutions.ts b/keep-ui/utils/hooks/useWorkflowExecutions.ts index 2763ff3aa..8e6fccc27 100644 --- a/keep-ui/utils/hooks/useWorkflowExecutions.ts +++ b/keep-ui/utils/hooks/useWorkflowExecutions.ts @@ -49,7 +49,7 @@ export const useWorkflowExecutionsV2 = ( return useSWR( () => session - ? `${apiUrl}/workflows/${workflowId}?v2=true&tab=${tab}&limit=${limit}&offset=${offset}${ + ? `${apiUrl}/workflows/${workflowId}/runs?v2=true&tab=${tab}&limit=${limit}&offset=${offset}${ searchParams ? `&${searchParams.toString()}` : "" }` : null, diff --git a/keep/api/routes/workflows.py b/keep/api/routes/workflows.py index ae8a837e0..8d355d08f 100644 --- a/keep/api/routes/workflows.py +++ b/keep/api/routes/workflows.py @@ -491,6 +491,50 @@ def get_raw_workflow_by_id( ) }, ) + +@router.get("/{workflow_id}", description="Get workflow by ID") +def get_workflow_by_id( + workflow_id: str, + authenticated_entity: AuthenticatedEntity = Depends( + IdentityManagerFactory.get_auth_verifier(["read:workflows"]) + ), +): + tenant_id = authenticated_entity.tenant_id + # get all workflow + workflow = get_workflow(tenant_id=tenant_id, workflow_id=workflow_id) + + if not workflow: + logger.warning( + f"Tenant tried to get workflow {workflow_id} that does not exist", + extra={"tenant_id": tenant_id}, + ) + raise HTTPException(404, "Workflow not found") + + try: + workflow_yaml = yaml.safe_load(workflow.workflow_raw) + valid_workflow_yaml = {"workflow": workflow_yaml} + final_workflow_raw = yaml.dump(valid_workflow_yaml) + workflow_dto = WorkflowDTO( + id=workflow.id, + name=workflow.name, + description=workflow.description + or "[This workflow has no description]", + created_by=workflow.created_by, + creation_time=workflow.creation_time, + interval=workflow.interval, + providers=[], + triggers=[], + workflow_raw=final_workflow_raw, + revision=workflow.revision, + last_updated=workflow.last_updated, + disabled=workflow.is_disabled, + provisioned=workflow.provisioned, + ) + return workflow_dto + except yaml.YAMLError: + logger.exception("Invalid YAML format") + raise HTTPException(status_code=500, detail="Error fetching workflow meta data") + @router.get("/executions", description="Get workflow executions by alert fingerprint") @@ -519,7 +563,7 @@ def get_workflow_executions_by_alert_fingerprint( ] -@router.get("/{workflow_id}", description="Get workflow executions by ID") +@router.get("/{workflow_id}/runs", description="Get workflow executions by ID") def get_workflow_by_id( workflow_id: str, tab: int = 1,