Repository: keephq/keep
Pull request: feat: Filtering/search workflows
<!-- 
Thanks for creating this pull request 🤗

Please make sure that the pull request is limited to one type (docs, feature, etc.) and keep it as small as possible. You can open multiple prs instead of opening a huge one.
-->

<!-- If this pull request closes an issue, please mention the issue number below -->
Closes #3624

## 📑 Description
<!-- Add a brief description of the pr -->
- Make .contains() CEL operator case insensitive



https://github.com/user-attachments/assets/78f63bc0-09d6-498a-a0ed-b1805625a21a




<!-- You can also choose to add a list of changes and if they have been completed or not by using the markdown to-do list syntax
- [ ] Not Completed
- [x] Completed
-->

## ✅ Checks
<!-- Make sure your pr passes the CI checks and do check the following fields as needed - -->
- [ ] My pull request adheres to the code style of this project
- [ ] My code requires changes to the documentation
- [ ] I have updated the documentation as required
- [ ] All the tests have passed

## ℹ Additional Information
<!-- Any additional information like breaking changes, dependencies added, screenshots, comparisons between new and old behavior, etc. -->


Problem statement:
[➕ Feature]: Filtering / search / sort workflows
Customer is heading towards having many workflows & they need tooling to manage them at high scale. They need to:
- Search by Workflows (Minimal: by name. Nice to have: by description as well)
- Sort or filter by last successful or failed execution
- Filter disabled/enabled workflows

Customer didn't mention it explicitly, but I suggest thinking of other possible facets: triggers, used providers etc.

Hints from issue discussion:

Commit message: Merge branch 'main' into 3624-Filtering_search_sort-workflows

Signed-off-by: Ihor Panasiuk <igorskynet13@gmail.com>

Patch to review:
diff --git a/keep-ui/app/(keep)/workflows/page.tsx b/keep-ui/app/(keep)/workflows/page.tsx
index 25f9fce6e9..7776022a59 100644
--- a/keep-ui/app/(keep)/workflows/page.tsx
+++ b/keep-ui/app/(keep)/workflows/page.tsx
@@ -1,8 +1,27 @@
 import React from "react";
 import WorkflowsPage from "./workflows.client";
+import { FacetDto } from "@/features/filter";
+import { createServerApiClient } from "@/shared/api/server";
+import { getInitialFacets } from "@/features/filter/api";
 
-export default function Page() {
-  return <WorkflowsPage />;
+export default async function Page() {
+  let initialFacets: FacetDto[] | null = null;
+
+  try {
+    const api = await createServerApiClient();
+    initialFacets = await getInitialFacets(api, "workflows");
+  } catch (error) {
+    console.log(error);
+  }
+  return (
+    <WorkflowsPage
+      initialFacetsData={
+        initialFacets
+          ? { facets: initialFacets, facetOptions: null }
+          : undefined
+      }
+    />
+  );
 }
 
 export const metadata = {
diff --git a/keep-ui/app/(keep)/workflows/workflows.client.tsx b/keep-ui/app/(keep)/workflows/workflows.client.tsx
index 09ab309d8b..9a0227e957 100644
--- a/keep-ui/app/(keep)/workflows/workflows.client.tsx
+++ b/keep-ui/app/(keep)/workflows/workflows.client.tsx
@@ -1,6 +1,13 @@
 "use client";
 
-import { ChangeEvent, useRef, useState } from "react";
+import {
+  ChangeEvent,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
 import { Subtitle, Button } from "@tremor/react";
 import {
   ArrowUpOnSquareStackIcon,
@@ -15,11 +22,26 @@ import Modal from "@/components/ui/Modal";
 import { WorkflowTemplates } from "./mockworkflows";
 import { useApi } from "@/shared/lib/hooks/useApi";
 import { Input, ErrorComponent } from "@/shared/ui";
-import { Textarea } from "@/components/ui";
-import { useWorkflowsV2 } from "utils/hooks/useWorkflowsV2";
+import { EmptyStateCard, Textarea } from "@/components/ui";
+import { useWorkflowsV2, WorkflowsQuery } from "utils/hooks/useWorkflowsV2";
 import { useWorkflowActions } from "@/entities/workflows/model/useWorkflowActions";
 import { PageSubtitle } from "@/shared/ui/PageSubtitle";
 import { PlusIcon } from "@heroicons/react/20/solid";
+import { UserStatefulAvatar } from "@/entities/users/ui";
+import { FacetsConfig } from "@/features/filter/models";
+import {
+  CheckCircleIcon,
+  XCircleIcon,
+  ExclamationCircleIcon,
+  MagnifyingGlassIcon,
+  FunnelIcon,
+  ArrowPathIcon,
+} from "@heroicons/react/24/outline";
+import { useUser } from "@/entities/users/model/useUser";
+import { Pagination, SearchInput } from "@/features/filter";
+import { FacetsPanelServerSide } from "@/features/filter/facet-panel-server-side";
+import { InitialFacetsData } from "@/features/filter/api";
+import { v4 as uuidV4 } from "uuid";
 
 const EXAMPLE_WORKFLOW_DEFINITIONS = {
   slack: `
@@ -62,11 +84,58 @@ const EXAMPLE_WORKFLOW_DEFINITIONS = {
 
 type ExampleWorkflowKey = keyof typeof EXAMPLE_WORKFLOW_DEFINITIONS;
 
-export default function WorkflowsPage() {
+const AssigneeLabel = ({ email }: { email: string }) => {
+  const user = useUser(email);
+  return user ? user.name : email;
+};
+
+export default function WorkflowsPage({
+  initialFacetsData,
+}: {
+  initialFacetsData?: InitialFacetsData;
+}) {
   const router = useRouter();
   const fileInputRef = useRef<HTMLInputElement | null>(null);
   const [workflowDefinition, setWorkflowDefinition] = useState("");
   const [isModalOpen, setIsModalOpen] = useState(false);
+  const [clearFiltersToken, setClearFiltersToken] = useState<string | null>(
+    null
+  );
+  const [filterCel, setFilterCel] = useState<string | null>(null);
+  const [searchedValue, setSearchedValue] = useState<string | null>(null);
+  const [paginationState, setPaginationState] = useState<{
+    offset: number;
+    limit: number;
+  } | null>(null);
+  const [workflowsQuery, setWorkflowsQuery] = useState<WorkflowsQuery | null>(
+    null
+  );
+
+  const searchCel = useMemo(() => {
+    if (!searchedValue) {
+      return;
+    }
+
+    return `name.contains("${searchedValue}") || description.contains("${searchedValue}")`;
+  }, [searchedValue]);
+
+  useEffect(() => {
+    if (!paginationState) {
+      return;
+    }
+
+    const celList = [searchCel, filterCel].filter((cel) => cel);
+    const cel = celList.join(" && ");
+    const query: WorkflowsQuery = {
+      cel,
+      limit: paginationState?.limit,
+      offset: paginationState?.offset,
+      sortBy: "createdAt",
+      sortDir: "desc",
+    };
+
+    setWorkflowsQuery(query);
+  }, [searchCel, filterCel, paginationState]);
 
   // Only fetch data when the user is authenticated
   /**
@@ -81,15 +150,143 @@ export default function WorkflowsPage() {
           -> last_executions: Used for the workflow execution graph.
           ->last_execution_started: Used for showing the start time of execution in real-time.
   **/
-  const { workflows, error, isLoading } = useWorkflowsV2();
+
+  const {
+    workflows: filteredWorkflows,
+    totalCount: filteredWorkflowsCount,
+    error,
+    isLoading: isFilteredWorkflowsLoading,
+  } = useWorkflowsV2(workflowsQuery, { keepPreviousData: true });
+
+  const isFirstLoading = isFilteredWorkflowsLoading && !filteredWorkflows;
+
   const { uploadWorkflowFiles } = useWorkflowActions();
 
-  if (error) {
-    return <ErrorComponent error={error} reset={() => {}} />;
+  const isTableEmpty = filteredWorkflowsCount === 0;
+  const isEmptyState =
+    !isFilteredWorkflowsLoading && isTableEmpty && !workflowsQuery?.cel;
+
+  const showFilterEmptyState = isTableEmpty && !!filterCel;
+  const showSearchEmptyState =
+    isTableEmpty && !!searchCel && !showFilterEmptyState;
+
+  const setPaginationStateCallback = useCallback(
+    (pageIndex: number, limit: number, offset: number) => {
+      setPaginationState({ limit, offset });
+    },
+    [setPaginationState]
+  );
+
+  const facetsConfig: FacetsConfig = useMemo(() => {
+    return {
+      ["Status"]: {
+        renderOptionIcon: (facetOption) => {
+          switch (facetOption.value) {
+            case "success": {
+              return <CheckCircleIcon className="w-5 h-5 text-green-500" />;
+            }
+            case "failed": {
+              return <XCircleIcon className="w-5 h-5 text-red-500" />;
+            }
+            case "in_progress": {
+              return <ArrowPathIcon className="w-5 h-5 text-orange-500" />;
+            }
+            default: {
+              return (
+                <ExclamationCircleIcon className="w-5 h-5 text-gray-500" />
+              );
+            }
+          }
+        },
+        renderOptionLabel: (facetOption) => {
+          switch (facetOption.value) {
+            case "success": {
+              return "Success";
+            }
+            case "failed": {
+              return "Failed";
+            }
+            case "in_progress": {
+              return "In progress";
+            }
+            default: {
+              return "Not run yet";
+            }
+          }
+        },
+      },
+      ["Created by"]: {
+        renderOptionIcon: (facetOption) => (
+          <UserStatefulAvatar email={facetOption.display_name} size="xs" />
+        ),
+        renderOptionLabel: (facetOption) => {
+          if (facetOption.display_name === "null") {
+            return "Not assigned";
+          }
+          return <AssigneeLabel email={facetOption.display_name} />;
+        },
+      },
+      ["Enabling status"]: {
+        renderOptionLabel: (facetOption) =>
+          facetOption.display_name.toLocaleLowerCase() === "true"
+            ? "Disabled"
+            : "Enabled",
+      },
+    };
+  }, []);
+
+  function renderFilterEmptyState() {
+    return (
+      <>
+        <div className="flex items-center h-full w-full">
+          <div className="flex flex-col justify-center items-center w-full p-4">
+            <EmptyStateCard
+              title="No workflows to display matching your filter"
+              buttonText="Reset filter"
+              renderIcon={() => (
+                <FunnelIcon className="mx-auto h-7 w-7 text-tremor-content-subtle dark:text-dark-tremor-content-subtle" />
+              )}
+              onClick={() => setClearFiltersToken(uuidV4())}
+            />
+          </div>
+        </div>
+      </>
+    );
   }
 
-  if (isLoading || !workflows) {
-    return <KeepLoader />;
+  function renderSearchEmptyState() {
+    return (
+      <>
+        <div className="flex items-center h-full w-full">
+          <div className="flex flex-col justify-center items-center w-full p-4">
+            <EmptyStateCard
+              title="No workflows to display matching your search"
+              buttonText="Clear search"
+              renderIcon={() => (
+                <MagnifyingGlassIcon className="mx-auto h-7 w-7 text-tremor-content-subtle dark:text-dark-tremor-content-subtle" />
+              )}
+              onClick={() => setSearchedValue(null)}
+            />
+          </div>
+        </div>
+      </>
+    );
+  }
+
+  function renderData() {
+    return (
+      <>
+        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 w-full gap-4">
+          {filteredWorkflows?.map((workflow) => (
+            <WorkflowTile key={workflow.id} workflow={workflow} />
+          ))}
+        </div>
+      </>
+    );
+  }
+
+  if (error) {
+    return <ErrorComponent error={error} reset={() => {}} />;
   }
 
   const onDrop = async (files: ChangeEvent<HTMLInputElement>) => {
@@ -147,13 +344,19 @@ export default function WorkflowsPage() {
     <>
       <main className="flex flex-col gap-8">
         <div className="flex flex-col gap-6">
-          <div className="flex justify-between items-center">
+          <div className="flex justify-between items-end">
             <div>
-              <PageTitle>Workflows</PageTitle>
+              <PageTitle>My Workflows</PageTitle>
               <PageSubtitle>
                 Automate your alert management with workflows
               </PageSubtitle>
             </div>
+            <SearchInput
+              className="flex-1 mx-4"
+              placeholder="Search workflows"
+              value={searchedValue as string}
+              onValueChange={setSearchedValue}
+            />
             <div className="flex gap-2">
               <Button
                 color="orange"
@@ -178,13 +381,44 @@ export default function WorkflowsPage() {
               </Button>
             </div>
           </div>
-          {workflows.length === 0 ? (
-            <WorkflowsEmptyState />
+          {isEmptyState ? (
+            <WorkflowsEmptyState isNewUI={true} />
           ) : (
-            <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 w-full gap-4">
-              {workflows.map((workflow) => (
-                <WorkflowTile key={workflow.id} workflow={workflow} />
-              ))}
+            <div className="flex gap-4">
+              <FacetsPanelServerSide
+                entityName={"workflows"}
+                facetsConfig={facetsConfig}
+                facetOptionsCel={searchCel}
+                usePropertyPathsSuggestions={true}
+                clearFiltersToken={clearFiltersToken}
+                initialFacetsData={initialFacetsData}
+                onCelChange={(cel) => setFilterCel(cel)}
+              />
+
+              <div className="flex flex-col flex-1 relative">
+                {isFirstLoading && (
+                  <div className="flex items-center justify-center h-96 w-full">
+                    <KeepLoader includeMinHeight={false} />
+                  </div>
+                )}
+                {!isFirstLoading && (
+                  <>
+                    {showFilterEmptyState && renderFilterEmptyState()}
+                    {showSearchEmptyState && renderSearchEmptyState()}
+                    {!isTableEmpty && renderData()}
+                  </>
+                )}
+                <div className={`mt-4 ${isFirstLoading ? "hidden" : ""}`}>
+                  <Pagination
+                    totalCount={filteredWorkflowsCount}
+                    isRefreshAllowed={false}
+                    isRefreshing={false}
+                    pageSizeOptions={[12, 24, 48]}
+                    onRefresh={() => {}}
+                    onStateChange={setPaginationStateCallback}
+                  />
+                </div>
+              </div>
             </div>
           )}
         </div>
diff --git a/keep-ui/features/filter/index.ts b/keep-ui/features/filter/index.ts
index 1902a050ea..8550167d94 100644
--- a/keep-ui/features/filter/index.ts
+++ b/keep-ui/features/filter/index.ts
@@ -1,8 +1,10 @@
 export { FacetsPanel, type FacetsPanelProps } from "./facets-panel";
+export { Pagination } from "./pagination";
 export {
   useFacetActions,
   useFacets,
   useFacetOptions,
   type UseFacetActionsValue,
 } from "./hooks";
+export { SearchInput } from "./search-input";
 export type { FacetDto, FacetOptionDto, CreateFacetDto } from "./models";
diff --git a/keep-ui/features/filter/pagination.tsx b/keep-ui/features/filter/pagination.tsx
new file mode 100644
index 0000000000..021c241395
--- /dev/null
+++ b/keep-ui/features/filter/pagination.tsx
@@ -0,0 +1,140 @@
+import {
+  ArrowPathIcon,
+  ChevronDoubleLeftIcon,
+  ChevronDoubleRightIcon,
+  ChevronLeftIcon,
+  ChevronRightIcon,
+  TableCellsIcon,
+} from "@heroicons/react/16/solid";
+import { Button, Text } from "@tremor/react";
+import { SingleValueProps, components, GroupBase } from "react-select";
+import { Select } from "@/shared/ui";
+import { useEffect, useMemo, useState } from "react";
+
+interface OptionType {
+  value: string;
+  label: string;
+}
+
+const SingleValue = ({
+  children,
+  ...props
+}: SingleValueProps<OptionType, false, GroupBase<OptionType>>) => (
+  <components.SingleValue {...props}>
+    {children}
+    <TableCellsIcon className="w-4 h-4 ml-2" />
+  </components.SingleValue>
+);
+
+interface Props {
+  totalCount: number;
+  pageSizeOptions?: number[];
+  isRefreshAllowed: boolean;
+  isRefreshing: boolean;
+  onStateChange: (pageIndex: number, pageSize: number, offset: number) => void;
+  onRefresh: () => void;
+}
+
+export function Pagination({
+  totalCount,
+  pageSizeOptions,
+  isRefreshAllowed,
+  isRefreshing,
+  onStateChange,
+  onRefresh,
+}: Props) {
+  const pageSizeOptionsMemoized = useMemo(
+    () => pageSizeOptions || [20, 50, 100],
+    [pageSizeOptions]
+  );
+  const selectOptions = useMemo(
+    () =>
+      pageSizeOptionsMemoized.map((value) => ({
+        value: value.toString(),
+        label: value.toString(),
+      })),
+    [pageSizeOptionsMemoized]
+  );
+
+  const [pageIndex, setPageIndex] = useState(0);
+  const [pageSize, setPageSize] = useState(pageSizeOptionsMemoized[0]);
+  const pagesCount = useMemo(
+    () => Math.ceil(totalCount / pageSize),
+    [totalCount, pageSize]
+  );
+  useEffect(
+    () => onStateChange(pageIndex, pageSize, pageIndex * pageSize),
+    [pageIndex, pageSize, onStateChange]
+  );
+
+  return (
+    <div className="flex justify-between items-center">
+      <Text>
+        Showing {pagesCount === 0 ? 0 : pageIndex + 1} of {pagesCount}
+      </Text>
+      <div className="flex gap-1">
+        <Select
+          components={{ SingleValue }}
+          value={{
+            value: pageSize.toString(),
+            label: pageSize.toString(),
+          }}
+          onChange={(selectedOption) =>
+            setPageSize(Number(selectedOption!.value))
+          }
+          options={selectOptions}
+          menuPlacement="top"
+        />
+        <div className="flex">
+          <Button
+            className="pagination-button"
+            icon={ChevronDoubleLeftIcon}
+            onClick={() => setPageIndex(0)}
+            disabled={pageIndex == 0}
+            size="xs"
+            color="gray"
+            variant="secondary"
+          />
+          <Button
+            className="pagination-button"
+            icon={ChevronLeftIcon}
+            onClick={() => setPageIndex(pageIndex - 1)}
+            disabled={pageIndex == 0}
+            size="xs"
+            color="gray"
+            variant="secondary"
+          />
+          <Button
+            className="pagination-button"
+            icon={ChevronRightIcon}
+            onClick={() => setPageIndex(pageIndex + 1)}
+            disabled={pageIndex == pagesCount - 1}
+            size="xs"
+            color="gray"
+            variant="secondary"
+          />
+          <Button
+            className="pagination-button"
+            icon={ChevronDoubleRightIcon}
+            onClick={() => setPageIndex(pagesCount - 1)}
+            disabled={pageIndex == pagesCount - 1}
+            size="xs"
+            color="gray"
+            variant="secondary"
+          />
+        </div>
+        {isRefreshAllowed && (
+          <Button
+            icon={ArrowPathIcon}
+            color="orange"
+            size="xs"
+            disabled={isRefreshing}
+            loading={isRefreshing}
+            onClick={async () => onRefresh()}
+            title="Refresh"
+          />
+        )}
+      </div>
+    </div>
+  );
+}
diff --git a/keep-ui/features/filter/search-input.tsx b/keep-ui/features/filter/search-input.tsx
new file mode 100644
index 0000000000..3f5fd97a9b
--- /dev/null
+++ b/keep-ui/features/filter/search-input.tsx
@@ -0,0 +1,34 @@
+import { TextInput } from "@/components/ui";
+import { useEffect, useState } from "react";
+
+interface SearchInputProps {
+  className?: string;
+  value?: string;
+  placeholder: string;
+  onValueChange: (value: string) => void;
+}
+
+export const SearchInput = ({
+  className,
+  placeholder,
+  onValueChange,
+  value,
+}: SearchInputProps) => {
+  const [inputValue, setInputValue] = useState("");
+
+  useEffect(() => {
+    const timeoutId = setTimeout(() => onValueChange(inputValue), 500);
+    return () => clearTimeout(timeoutId);
+  }, [inputValue, onValueChange]);
+
+  useEffect(() => setInputValue(value || ""), [value]);
+
+  return (
+    <TextInput
+      className={className}
+      placeholder={placeholder}
+      value={inputValue}
+      onValueChange={setInputValue}
+    />
+  );
+};
diff --git a/keep-ui/utils/hooks/useWorkflows.ts b/keep-ui/utils/hooks/useWorkflows.ts
index eb00a2c2fd..1ac177d313 100644
--- a/keep-ui/utils/hooks/useWorkflows.ts
+++ b/keep-ui/utils/hooks/useWorkflows.ts
@@ -6,9 +6,14 @@ import useSWRImmutable from "swr/immutable";
 export const useWorkflows = (options: SWRConfiguration = {}) => {
   const api = useApi();
 
-  return useSWRImmutable<Workflow[]>(
+  const swr = useSWRImmutable(
     api.isReady() ? "/workflows" : null,
     (url) => api.get(url),
     options
   );
+
+  return {
+    ...swr,
+    data: swr.data?.results as Workflow[],
+  };
 };
diff --git a/keep-ui/utils/hooks/useWorkflowsV2.ts b/keep-ui/utils/hooks/useWorkflowsV2.ts
index cfd8e1896b..098c0e84b8 100644
--- a/keep-ui/utils/hooks/useWorkflowsV2.ts
+++ b/keep-ui/utils/hooks/useWorkflowsV2.ts
@@ -1,24 +1,62 @@
-import useSWR, { mutate } from "swr";
+import useSWR, { mutate, SWRConfiguration } from "swr";
 import { useApi } from "@/shared/lib/hooks/useApi";
 import { Workflow } from "@/shared/api/workflows";
 
-const WORKFLOWS_KEY = "/workflows?is_v2=true";
+export interface WorkflowsQuery {
+  cel?: string;
+  limit?: number;
+  offset?: number;
+  sortBy?: string;
+  sortDir?: "asc" | "desc";
+}
 
-export function useWorkflowsV2() {
+export function useWorkflowsV2(
+  workflowsQuery: WorkflowsQuery | null,
+  swrConfig?: SWRConfiguration
+) {
   const api = useApi();
+  const queryToPost: { [key: string]: any } = {};
+
+  if (workflowsQuery?.cel) {
+    queryToPost.cel = workflowsQuery.cel;
+  }
+
+  if (workflowsQuery?.limit !== undefined) {
+    queryToPost.limit = workflowsQuery.limit;
+  }
+
+  if (workflowsQuery?.offset !== undefined) {
+    queryToPost.offset = workflowsQuery.offset;
+  }
+
+  if (workflowsQuery?.sortBy) {
+    queryToPost.sort_by = workflowsQuery.sortBy;
+  }
+
+  if (workflowsQuery?.sortDir) {
+    queryToPost.sort_dir = workflowsQuery.sortDir;
+  }
+
+  let requestUrl = "/workflows/query?is_v2=true";
 
-  const { data, error, isLoading } = useSWR<Workflow[]>(
-    api.isReady() ? WORKFLOWS_KEY : null,
-    (url: string) => api.get(url)
+  const { data, error, isLoading } = useSWR<any>(
+    api.isReady() && workflowsQuery
+      ? requestUrl + JSON.stringify(queryToPost)
+      : null,
+    () => api.post(requestUrl, queryToPost),
+    swrConfig
   );
 
   const mutateWorkflows = () => {
-    return mutate(WORKFLOWS_KEY);
+    return mutate(
+      (key) => typeof key === "string" && key.startsWith(requestUrl)
+    );
   };
 
   return {
-    workflows: data,
-    isLoading,
+    workflows: data?.results as Workflow[],
+    totalCount: data?.count,
+    isLoading: isLoading || !data,
     error,
     mutateWorkflows,
   };
diff --git a/keep/api/core/cel_to_sql/sql_providers/mysql.py b/keep/api/core/cel_to_sql/sql_providers/mysql.py
index 39fdd34d87..27f10ddada 100644
--- a/keep/api/core/cel_to_sql/sql_providers/mysql.py
+++ b/keep/api/core/cel_to_sql/sql_providers/mysql.py
@@ -46,7 +46,7 @@ def _visit_contains_method_calling(
             raise ValueError(f'{property_path}.contains accepts 1 argument but got {len(method_args)}')
         processed_literal = self.literal_proc(method_args[0].value)
         unquoted_literal = processed_literal[1:-1]
-        return f"{property_path} IS NOT NULL AND {property_path} LIKE '%{unquoted_literal}%'"
+        return f"{property_path} IS NOT NULL AND {property_path} COLLATE utf8mb4_general_ci LIKE '%{unquoted_literal}%'"
 
     def _visit_starts_with_method_calling(
         self, property_path: str, method_args: List[ConstantNode]
@@ -55,7 +55,7 @@ def _visit_starts_with_method_calling(
             raise ValueError(f'{property_path}.startsWith accepts 1 argument but got {len(method_args)}')
         processed_literal = self.literal_proc(method_args[0].value)
         unquoted_literal = processed_literal[1:-1]
-        return f"{property_path} IS NOT NULL AND {property_path} LIKE '{unquoted_literal}%'"
+        return f"{property_path} IS NOT NULL AND {property_path} COLLATE utf8mb4_general_ci LIKE '{unquoted_literal}%'"
 
     def _visit_ends_with_method_calling(
         self, property_path: str, method_args: List[ConstantNode]
@@ -64,4 +64,4 @@ def _visit_ends_with_method_calling(
             raise ValueError(f'{property_path}.endsWith accepts 1 argument but got {len(method_args)}')
         processed_literal = self.literal_proc(method_args[0].value)
         unquoted_literal = processed_literal[1:-1]
-        return f"{property_path} IS NOT NULL AND {property_path} LIKE '%{unquoted_literal}'"
+        return f"{property_path} IS NOT NULL AND {property_path} COLLATE utf8mb4_general_ci LIKE '%{unquoted_literal}'"
diff --git a/keep/api/core/cel_to_sql/sql_providers/postgresql.py b/keep/api/core/cel_to_sql/sql_providers/postgresql.py
index b04bf9eb9a..5696b2eb14 100644
--- a/keep/api/core/cel_to_sql/sql_providers/postgresql.py
+++ b/keep/api/core/cel_to_sql/sql_providers/postgresql.py
@@ -52,7 +52,7 @@ def _visit_contains_method_calling(
 
         processed_literal = self.literal_proc(method_args[0].value)
         unquoted_literal = processed_literal[1:-1]
-        return f"{property_path} IS NOT NULL AND {property_path} LIKE '%{unquoted_literal}%'"
+        return f"{property_path} IS NOT NULL AND {property_path} ILIKE '%{unquoted_literal}%'"
 
     def _visit_starts_with_method_calling(
         self, property_path: str, method_args: List[ConstantNode]
@@ -61,7 +61,7 @@ def _visit_starts_with_method_calling(
             raise ValueError(f'{property_path}.startsWith accepts 1 argument but got {len(method_args)}')
         processed_literal = self.literal_proc(method_args[0].value)
         unquoted_literal = processed_literal[1:-1]
-        return f"{property_path} IS NOT NULL AND {property_path} LIKE '{unquoted_literal}%'"
+        return f"{property_path} IS NOT NULL AND {property_path} ILIKE '{unquoted_literal}%'"
 
     def _visit_ends_with_method_calling(
         self, property_path: str, method_args: List[ConstantNode]
@@ -70,4 +70,4 @@ def _visit_ends_with_method_calling(
             raise ValueError(f'{property_path}.endsWith accepts 1 argument but got {len(method_args)}')
         processed_literal = self.literal_proc(method_args[0].value)
         unquoted_literal = processed_literal[1:-1]
-        return f"{property_path} IS NOT NULL AND {property_path} LIKE '%{unquoted_literal}'"
+        return f"{property_path} IS NOT NULL AND {property_path} ILIKE '%{unquoted_literal}'"
diff --git a/keep/api/core/cel_to_sql/sql_providers/sqlite.py b/keep/api/core/cel_to_sql/sql_providers/sqlite.py
index 3eb7fb51fa..b6a42f4b84 100644
--- a/keep/api/core/cel_to_sql/sql_providers/sqlite.py
+++ b/keep/api/core/cel_to_sql/sql_providers/sqlite.py
@@ -59,20 +59,18 @@ def _visit_contains_method_calling(
         if len(method_args) != 1:
             raise ValueError(f'{property_path}.contains accepts 1 argument but got {len(method_args)}')
 
-        processed_literal = self.literal_proc(f"*{method_args[0].value}*")
-        return (
-            f"{property_path} IS NOT NULL AND {property_path} GLOB {processed_literal}"
-        )
+        processed_literal = self.literal_proc(method_args[0].value)
+        unquoted_literal = processed_literal[1:-1]
+        return f"{property_path} IS NOT NULL AND {property_path} LIKE '%{unquoted_literal}%'"
 
     def _visit_starts_with_method_calling(
         self, property_path: str, method_args: List[ConstantNode]
     ) -> str:
         if len(method_args) != 1:
             raise ValueError(f'{property_path}.startsWith accepts 1 argument but got {len(method_args)}')
-        processed_literal = self.literal_proc(f"{method_args[0].value}*")
-        return (
-            f"{property_path} IS NOT NULL AND {property_path} GLOB {processed_literal}"
-        )
+        processed_literal = self.literal_proc(method_args[0].value)
+        unquoted_literal = processed_literal[1:-1]
+        return f"{property_path} IS NOT NULL AND {property_path} LIKE '{unquoted_literal}%'"
 
     def _visit_ends_with_method_calling(
         self, property_path: str, method_args: List[ConstantNode]
@@ -80,7 +78,6 @@ def _visit_ends_with_method_calling(
         if len(method_args) != 1:
             raise ValueError(f'{property_path}.endsWith accepts 1 argument but got {len(method_args)}')
 
-        processed_literal = self.literal_proc(f"*{method_args[0].value}")
-        return (
-            f"{property_path} IS NOT NULL AND {property_path} GLOB {processed_literal}"
-        )
+        processed_literal = self.literal_proc(method_args[0].value)
+        unquoted_literal = processed_literal[1:-1]
+        return f"{property_path} IS NOT NULL AND {property_path} LIKE '%{unquoted_literal}'"
diff --git a/keep/api/core/db.py b/keep/api/core/db.py
index 34dd3ad0b4..b5fc759db6 100644
--- a/keep/api/core/db.py
+++ b/keep/api/core/db.py
@@ -3090,60 +3090,6 @@ def get_alert_audit(
     return result
 
 
-def get_workflows_with_last_executions_v2(
-    tenant_id: str, fetch_last_executions: int = 15
-) -> list[dict]:
-    if fetch_last_executions is not None and fetch_last_executions > 20:
-        fetch_last_executions = 20
-
-    # List first 1000 worflows and thier last executions in the last 7 days which are active)
-    with Session(engine) as session:
-        latest_executions_subquery = (
-            select(
-                WorkflowExecution.workflow_id,
-                WorkflowExecution.started,
-                WorkflowExecution.execution_time,
-                WorkflowExecution.status,
-                func.row_number()
-                .over(
-                    partition_by=WorkflowExecution.workflow_id,
-                    order_by=desc(WorkflowExecution.started),
-                )
-                .label("row_num"),
-            )
-            .where(WorkflowExecution.tenant_id == tenant_id)
-            .where(
-                WorkflowExecution.started
-                >= datetime.now(tz=timezone.utc) - timedelta(days=7)
-            )
-            .cte("latest_executions_subquery")
-        )
-
-        workflows_with_last_executions_query = (
-            select(
-                Workflow,
-                latest_executions_subquery.c.started,
-                latest_executions_subquery.c.execution_time,
-                latest_executions_subquery.c.status,
-            )
-            .outerjoin(
-                latest_executions_subquery,
-                and_(
-                    Workflow.id == latest_executions_subquery.c.workflow_id,
-                    latest_executions_subquery.c.row_num <= fetch_last_executions,
-                ),
-            )
-            .where(Workflow.tenant_id == tenant_id)
-            .where(Workflow.is_deleted == False)
-            .order_by(Workflow.id, desc(latest_executions_subquery.c.started))
-            .limit(15000)
-        ).distinct()
-
-        result = session.execute(workflows_with_last_executions_query).all()
-
-    return result
-
-
 def get_incidents_meta_for_tenant(tenant_id: str) -> dict:
     with Session(engine) as session:
 
diff --git a/keep/api/core/workflows.py b/keep/api/core/workflows.py
new file mode 100644
index 0000000000..833a9e70f8
--- /dev/null
+++ b/keep/api/core/workflows.py
@@ -0,0 +1,328 @@
+"""
+Keep main database module.
+
+This module contains the CRUD database functions for Keep.
+"""
+
+from datetime import datetime, timedelta, timezone
+from typing import Tuple
+
+from sqlalchemy import and_, asc, case, desc, func, literal_column, select, text
+from sqlmodel import Session
+
+from keep.api.core.cel_to_sql.properties_metadata import (
+    FieldMappingConfiguration,
+    JsonFieldMapping,
+    PropertiesMetadata,
+    SimpleFieldMapping,
+)
+from keep.api.core.cel_to_sql.sql_providers.get_cel_to_sql_provider_for_dialect import (
+    get_cel_to_sql_provider,
+)
+from keep.api.core.db import engine
+from keep.api.core.facets import get_facet_options, get_facets
+from keep.api.models.db.facet import FacetType
+from keep.api.models.db.workflow import Workflow, WorkflowExecution
+from keep.api.models.facet import FacetDto, FacetOptionDto, FacetOptionsQueryDto
+
+
+workflow_field_configurations = [
+    FieldMappingConfiguration("name", "filter_workflow_name"),
+    FieldMappingConfiguration("description", "filter_workflow_description"),
+    FieldMappingConfiguration("started", "filter_started"),
+    FieldMappingConfiguration("status", "filter_status"),
+    FieldMappingConfiguration("executionTime", "filter_execution_time"),
+    FieldMappingConfiguration("isDisabled", "filter_workflow_is_disabled"),
+    FieldMappingConfiguration("lastUpdated", "filter_workflow_last_updated"),
+    FieldMappingConfiguration("createdAt", "filter_workflow_creation_time"),
+    FieldMappingConfiguration("createdBy", "filter_workflow_created_by"),
+    FieldMappingConfiguration("updatedBy", "filter_workflow_updated_by"),
+]
+alias_column_mapping = {
+    "filter_workflow_name": "workflow.name",
+    "filter_workflow_description": "workflow.description",
+    "filter_workflow_is_disabled": "workflow.is_disabled",
+    "filter_workflow_last_updated": "workflow.last_updated",
+    "filter_workflow_creation_time": "workflow.creation_time",
+    "filter_workflow_updated_by": "workflow.updated_by",
+    "filter_started": "started",
+    "filter_status": "status",
+    "filter_execution_time": "execution_time",
+    "filter_workflow_created_by": "workflow.created_by",
+}
+
+properties_metadata = PropertiesMetadata(workflow_field_configurations)
+
+static_facets = [
+    FacetDto(
+        id="558a5844-55a1-45ad-b190-8848a389007d",
+        property_path="status",
+        name="Status",
+        is_static=True,
+        type=FacetType.str,
+    ),
+    FacetDto(
+        id="6672d434-36d6-4e48-b5ec-3123a7b38cf8",
+        property_path="isDisabled",
+        name="Enabling status",
+        is_static=True,
+        type=FacetType.str,
+    ),
+    FacetDto(
+        id="77325333-7710-4904-bf06-6c3d58aa5787",
+        property_path="createdBy",
+        name="Created by",
+        is_static=True,
+        type=FacetType.str,
+    ),
+]
+static_facets_dict = {facet.id: facet for facet in static_facets}
+
+
+def __build_base_query(
+    tenant_id: str,
+    fetch_last_executions: int = 15,
+):
+    columns_to_select = []
+
+    for key, value in alias_column_mapping.items():
+        if key == "filter_status":
+            continue
+        columns_to_select.append(f"{value} AS {key}")
+    latest_executions_subquery = (
+        select(
+            WorkflowExecution.workflow_id,
+            WorkflowExecution.started,
+            WorkflowExecution.execution_time,
+            WorkflowExecution.status,
+            func.row_number()
+            .over(
+                partition_by=WorkflowExecution.workflow_id,
+                order_by=desc(WorkflowExecution.started),
+            )
+            .label("row_num"),
+        )
+        .where(WorkflowExecution.tenant_id == tenant_id)
+        .where(
+            WorkflowExecution.started
+            >= datetime.now(tz=timezone.utc) - timedelta(days=7)
+        )
+        .cte("latest_executions_subquery")
+    )
+
+    workflows_with_last_executions_query = (
+        select(
+            Workflow,
+            Workflow.id.label("entity_id"),
+            # here it creates aliases for table columns that will be used in filtering and faceting
+            text(",".join(columns_to_select)),
+            case(
+                (
+                    literal_column("status").isnot(None),
+                    literal_column("status"),
+                ),
+                else_="",
+            ).label("filter_status"),
+        )
+        .outerjoin(
+            latest_executions_subquery,
+            and_(
+                Workflow.id == latest_executions_subquery.c.workflow_id,
+                latest_executions_subquery.c.row_num <= fetch_last_executions,
+            ),
+        )
+        .where(Workflow.tenant_id == tenant_id)
+        .where(Workflow.is_deleted == False)
+    )
+
+    return workflows_with_last_executions_query
+
+
+def build_workflows_total_count_query(
+    tenant_id: str,
+    cel: str,
+    fetch_last_executions: int = 15,
+):
+    base_query = __build_base_query(
+        tenant_id=tenant_id, fetch_last_executions=fetch_last_executions
+    ).cte("base_query")
+
+    query = select(func.count(func.distinct(base_query.c.entity_id))).select_from(
+        base_query
+    )
+
+    if cel:
+        cel_to_sql_instance = get_cel_to_sql_provider(properties_metadata)
+        sql_filter_str = cel_to_sql_instance.convert_to_sql_str(cel)
+        query = query.filter(text(sql_filter_str))
+
+    query = query.distinct()
+
+    return query
+
+
+def build_workflows_query(
+    tenant_id: str,
+    cel: str,
+    limit: int,
+    offset: int,
+    sort_by: str,
+    sort_dir: str,
+    fetch_last_executions: int = 15,
+):
+    limit = limit if limit is not None else 20
+    offset = offset if offset is not None else 0
+    cel_to_sql_instance = get_cel_to_sql_provider(properties_metadata)
+
+    base_query = __build_base_query(tenant_id, fetch_last_executions).cte("base_query")
+
+    if not sort_by:
+        sort_by = "started"
+        sort_dir = "desc"
+
+    metadata = properties_metadata.get_property_metadata(sort_by)
+    group_by_exp = []
+
+    for item in metadata.field_mappings:
+        if isinstance(item, JsonFieldMapping):
+            group_by_exp.append(
+                cel_to_sql_instance.json_extract_as_text(
+                    item.json_prop, item.prop_in_json
+                )
+            )
+        elif isinstance(metadata.field_mappings[0], SimpleFieldMapping):
+            group_by_exp.append(alias_column_mapping[item.map_to])
+
+    query = (
+        select(
+            Workflow,
+            literal_column("filter_started").label("started"),
+            literal_column("filter_execution_time").label("execution_time"),
+            literal_column("filter_status").label("status"),
+        )
+        .select_from(base_query)
+        .outerjoin(
+            Workflow,
+            and_(
+                Workflow.id == base_query.c.entity_id, Workflow.tenant_id == tenant_id
+            ),
+        )
+    )
+
+    if len(group_by_exp) > 1:
+        order_by_field = cel_to_sql_instance.coalesce(
+            [cel_to_sql_instance.cast(item, str) for item in group_by_exp]
+        )
+    else:
+        order_by_field = group_by_exp[0]
+
+    if sort_dir == "desc":
+        query = query.order_by(desc(text(order_by_field)), Workflow.id)
+    else:
+        query = query.order_by(asc(text(order_by_field)), Workflow.id)
+
+    query = query.limit(limit).offset(offset)
+
+    query = query.distinct(text(order_by_field), Workflow.id)
+
+    if cel:
+        sql_filter_str = cel_to_sql_instance.convert_to_sql_str(cel)
+        query = query.filter(text(sql_filter_str))
+
+    return query
+
+
+def get_workflows_with_last_executions_v2(
+    tenant_id: str,
+    cel: str,
+    limit: int,
+    offset: int,
+    sort_by: str,
+    sort_dir: str,
+    fetch_last_executions: int = 15,
+) -> Tuple[list[dict], int]:
+    with Session(engine) as session:
+        total_count_query = build_workflows_total_count_query(
+            tenant_id=tenant_id,
+            cel=cel,
+            fetch_last_executions=fetch_last_executions,
+        )
+
+        count = session.exec(total_count_query).one()[0]
+
+        if count == 0:
+            return [], count
+
+        workflows_query = build_workflows_query(
+            tenant_id=tenant_id,
+            cel=cel,
+            limit=limit,
+            offset=offset,
+            sort_by=sort_by,
+            sort_dir=sort_dir,
+            fetch_last_executions=fetch_last_executions,
+        )
+
+        query_result = session.execute(workflows_query).all()
+        result = []
+        for workflow, started, execution_time, status in query_result:
+            # workaround for filter. In query status is empty string if it is NULL in DB
+            status = workflow if workflow == "" else None
+            result.append(tuple([workflow, started, execution_time, status]))
+
+    return result, count
+
+
+def get_workflow_facets(
+    tenant_id: str, facet_ids_to_load: list[str] = None
+) -> list[FacetDto]:
+    not_static_facet_ids = []
+    facets = []
+
+    if not facet_ids_to_load:
+        return static_facets + get_facets(tenant_id, "workflow")
+
+    if facet_ids_to_load:
+        for facet_id in facet_ids_to_load:
+            if facet_id not in static_facets_dict:
+                not_static_facet_ids.append(facet_id)
+                continue
+
+            facets.append(static_facets_dict[facet_id])
+
+    if not_static_facet_ids:
+        facets += get_facets(tenant_id, "workflow", not_static_facet_ids)
+
+    return facets
+
+
+def get_workflow_facets_data(
+    tenant_id: str,
+    facet_options_query: FacetOptionsQueryDto,
+) -> dict[str, list[FacetOptionDto]]:
+    if facet_options_query and facet_options_query.facet_queries:
+        facets = get_workflow_facets(
+            tenant_id, facet_options_query.facet_queries.keys()
+        )
+    else:
+        facets = static_facets
+
+    base_query = select(
+        # here it creates aliases for table columns that will be used in filtering and faceting
+        text(",".join(["entity_id"] + [key for key in alias_column_mapping.keys()]))
+    ).select_from(__build_base_query(tenant_id).cte("workflows_query"))
+
+    return get_facet_options(
+        base_query=base_query,
+        facets=facets,
+        facet_options_query=facet_options_query,
+        properties_metadata=properties_metadata,
+    )
+
+
+def get_workflow_potential_facet_fields(tenant_id: str) -> list[str]:
+    return [
+        field_configuration.map_from_pattern
+        for field_configuration in workflow_field_configurations
+        if "*" not in field_configuration.map_from_pattern
+    ]
diff --git a/keep/api/routes/facets.py b/keep/api/routes/facets.py
index f369b02c67..083921c77a 100644
--- a/keep/api/routes/facets.py
+++ b/keep/api/routes/facets.py
@@ -19,6 +19,7 @@
 entity_name_to_entity_type = {
     "incidents": "incident",
     "alerts": "alert",
+    "workflows": "workflow",
 }
 
 @router.post(
diff --git a/keep/api/routes/workflows.py b/keep/api/routes/workflows.py
index 49e369eb3f..8ff9e2f7ce 100644
--- a/keep/api/routes/workflows.py
+++ b/keep/api/routes/workflows.py
@@ -28,7 +28,14 @@
     get_workflow_by_name,
 )
 from keep.api.core.db import get_workflow_executions as get_workflow_executions_db
+from keep.api.core.workflows import (
+    get_workflow_facets,
+    get_workflow_facets_data,
+    get_workflow_potential_facet_fields,
+)
 from keep.api.models.alert import AlertDto, IncidentDto
+from keep.api.models.facet import FacetOptionsQueryDto
+from keep.api.models.query import QueryDto
 from keep.api.models.workflow import (
     WorkflowCreateOrUpdateDTO,
     WorkflowDTO,
@@ -52,6 +59,98 @@
 PLATFORM_URL = config("KEEP_PLATFORM_URL", default="https://platform.keephq.dev")
 
 
+@router.post(
+    "/facets/options",
+    description="Query workflows facet options. Accepts dictionary where key is facet id and value is cel to query facet",
+)
+def fetch_facet_options(
+    facet_options_query: FacetOptionsQueryDto,
+    authenticated_entity: AuthenticatedEntity = Depends(
+        IdentityManagerFactory.get_auth_verifier(["read:workflows"])
+    ),
+) -> dict:
+    tenant_id = authenticated_entity.tenant_id
+
+    logger.info(
+        "Fetching workflow facets from DB",
+        extra={
+            "tenant_id": tenant_id,
+        },
+    )
+
+    facet_options = get_workflow_facets_data(
+        tenant_id=tenant_id, facet_options_query=facet_options_query
+    )
+
+    logger.info(
+        "Fetched workflow facets from DB",
+        extra={
+            "tenant_id": tenant_id,
+        },
+    )
+
+    return facet_options
+
+
+@router.get(
+    "/facets",
+    description="Get workflow facets",
+)
+def fetch_facets(
+    authenticated_entity: AuthenticatedEntity = Depends(
+        IdentityManagerFactory.get_auth_verifier(["read:workflows"])
+    ),
+) -> list:
+    tenant_id = authenticated_entity.tenant_id
+
+    logger.info(
+        "Fetching workflow facets from DB",
+        extra={
+            "tenant_id": tenant_id,
+        },
+    )
+
+    facets = get_workflow_facets(tenant_id=tenant_id)
+
+    logger.info(
+        "Fetched workflow facets from DB",
+        extra={
+            "tenant_id": tenant_id,
+        },
+    )
+
+    return facets
+
+
+@router.get(
+    "/facets/fields",
+    description="Get potential fields for workflow facets",
+)
+def fetch_facet_fields(
+    authenticated_entity: AuthenticatedEntity = Depends(
+        IdentityManagerFactory.get_auth_verifier(["read:workflows"])
+    ),
+) -> list:
+    tenant_id = authenticated_entity.tenant_id
+
+    logger.info(
+        "Fetching workflow facet fields from DB",
+        extra={
+            "tenant_id": tenant_id,
+        },
+    )
+
+    fields = get_workflow_potential_facet_fields(tenant_id=tenant_id)
+
+    logger.info(
+        "Fetched workflow facet fields from DB",
+        extra={
+            "tenant_id": tenant_id,
+        },
+    )
+    return fields
+
+
 # Redesign the workflow Card
 #   The workflow card needs execution records (currently limited to 15) for the graph. To achieve this, the following changes
 #   were made in the backend:
@@ -66,12 +165,28 @@
     "",
     description="Get workflows",
 )
+# TODO: this should be deprecated and removed
 def get_workflows(
     authenticated_entity: AuthenticatedEntity = Depends(
         IdentityManagerFactory.get_auth_verifier(["read:workflows"])
     ),
     is_v2: Optional[bool] = Query(False, alias="is_v2", type=bool),
 ) -> list[WorkflowDTO] | list[dict]:
+    query_result = query_workflows(QueryDto(), authenticated_entity, is_v2)
+    return query_result["results"]
+
+
+@router.post(
+    "/query",
+    description="Query workflows",
+)
+def query_workflows(
+    query: QueryDto,
+    authenticated_entity: AuthenticatedEntity = Depends(
+        IdentityManagerFactory.get_auth_verifier(["read:workflows"])
+    ),
+    is_v2: Optional[bool] = Query(False, alias="is_v2", type=bool),
+) -> dict:
     tenant_id = authenticated_entity.tenant_id
     workflowstore = WorkflowStore()
     workflows_dto = []
@@ -87,8 +202,14 @@ def get_workflows(
                 installed_provider.name
             ] = installed_provider
     # get all workflows
-    workflows = workflowstore.get_all_workflows_with_last_execution(
-        tenant_id=tenant_id, is_v2=is_v2
+    workflows, count = workflowstore.get_all_workflows_with_last_execution(
+        tenant_id=tenant_id,
+        cel=query.cel,
+        limit=query.limit,
+        offset=query.offset,
+        sort_by=query.sort_by,
+        sort_dir=query.sort_dir,
+        is_v2=is_v2,
     )
 
     # Group last workflow executions by workflow
@@ -150,7 +271,12 @@ def get_workflows(
             logger.error(f"Error creating workflow DTO: {e}")
             continue
         workflows_dto.append(workflow_dto)
-    return workflows_dto
+    return {
+        "count": count,
+        "results": workflows_dto,
+        "limit": query.limit,
+        "offset": query.offset,
+    }
 
 
 @router.get(
diff --git a/keep/workflowmanager/workflowstore.py b/keep/workflowmanager/workflowstore.py
index 7c650f7905..3b6b29d024 100644
--- a/keep/workflowmanager/workflowstore.py
+++ b/keep/workflowmanager/workflowstore.py
@@ -2,6 +2,7 @@
 import logging
 import os
 import random
+from typing import Tuple
 import uuid
 
 import requests
@@ -19,8 +20,8 @@
     get_workflow,
     get_workflow_execution,
     get_workflows_with_last_execution,
-    get_workflows_with_last_executions_v2,
 )
+from keep.api.core.workflows import get_workflows_with_last_executions_v2
 from keep.api.models.db.workflow import Workflow as WorkflowModel
 from keep.api.models.workflow import ProviderDTO
 from keep.functions import cyaml
@@ -145,15 +146,28 @@ def get_all_workflows(self, tenant_id: str) -> list[WorkflowModel]:
         return workflows
 
     def get_all_workflows_with_last_execution(
-        self, tenant_id: str, is_v2: bool = False
-    ) -> list[dict]:
+        self,
+        tenant_id: str,
+        cel: str = None,
+        limit: int = None,
+        offset: int = None,
+        sort_by: str = None,
+        sort_dir: str = None,
+        is_v2: bool = False,
+    ) -> Tuple[list[dict], int]:
         # list all tenant's workflows
         if is_v2:
-            workflows = get_workflows_with_last_executions_v2(tenant_id, 15)
-        else:
-            workflows = get_workflows_with_last_execution(tenant_id)
-
-        return workflows
+            return get_workflows_with_last_executions_v2(
+                tenant_id=tenant_id,
+                cel=cel,
+                limit=limit,
+                offset=offset,
+                sort_by=sort_by,
+                sort_dir=sort_dir,
+                fetch_last_executions=15,
+            )
+        workflows = get_workflows_with_last_execution(tenant_id)
+        return workflows, len(workflows)
 
     def get_all_workflows_yamls(self, tenant_id: str) -> list[str]:
         # list all tenant's workflows yamls (Workflow.workflow_raw)
diff --git a/tests/cel_to_sql/cel-to-sql-test-cases.json b/tests/cel_to_sql/cel-to-sql-test-cases.json
index f9c917c971..f8cb1d3f3c 100644
--- a/tests/cel_to_sql/cel-to-sql-test-cases.json
+++ b/tests/cel_to_sql/cel-to-sql-test-cases.json
@@ -39,18 +39,18 @@
     "input_cel": "alert.provider_type.contains('graf')",
     "description": "Contains operator with field refering to one column",
     "expected_sql_dialect_based": {
-      "sqlite": "incident_alert_provider_type IS NOT NULL AND incident_alert_provider_type GLOB '*graf*'",
-      "mysql": "incident_alert_provider_type IS NOT NULL AND incident_alert_provider_type LIKE '%graf%'",
-      "postgresql": "incident_alert_provider_type IS NOT NULL AND incident_alert_provider_type LIKE '%graf%'"
+      "sqlite": "incident_alert_provider_type IS NOT NULL AND incident_alert_provider_type LIKE '%graf%'",
+      "mysql": "incident_alert_provider_type IS NOT NULL AND incident_alert_provider_type COLLATE utf8mb4_general_ci LIKE '%graf%'",
+      "postgresql": "incident_alert_provider_type IS NOT NULL AND incident_alert_provider_type ILIKE '%graf%'"
     }
   },
   {
     "input_cel": "alert.some_json_prop.contains('lorem')",
     "description": "Contains operator with field refering to multiple JSON columns",
     "expected_sql_dialect_based": {
-      "sqlite": "(json_extract(alert_enrichments, '$.some_json_prop') IS NOT NULL AND json_extract(alert_enrichments, '$.some_json_prop') GLOB '*lorem*' OR json_extract(alert_event, '$.some_json_prop') IS NOT NULL AND json_extract(alert_event, '$.some_json_prop') GLOB '*lorem*')",
-      "mysql": "(JSON_UNQUOTE(JSON_EXTRACT(alert_enrichments, '$.some_json_prop')) IS NOT NULL AND JSON_UNQUOTE(JSON_EXTRACT(alert_enrichments, '$.some_json_prop')) LIKE '%lorem%' OR JSON_UNQUOTE(JSON_EXTRACT(alert_event, '$.some_json_prop')) IS NOT NULL AND JSON_UNQUOTE(JSON_EXTRACT(alert_event, '$.some_json_prop')) LIKE '%lorem%')",
-      "postgresql": "((alert_enrichments) ->> 'some_json_prop' IS NOT NULL AND (alert_enrichments) ->> 'some_json_prop' LIKE '%lorem%' OR (alert_event) ->> 'some_json_prop' IS NOT NULL AND (alert_event) ->> 'some_json_prop' LIKE '%lorem%')"
+      "sqlite": "(json_extract(alert_enrichments, '$.some_json_prop') IS NOT NULL AND json_extract(alert_enrichments, '$.some_json_prop') LIKE '%lorem%' OR json_extract(alert_event, '$.some_json_prop') IS NOT NULL AND json_extract(alert_event, '$.some_json_prop') LIKE '%lorem%')",
+      "mysql": "(JSON_UNQUOTE(JSON_EXTRACT(alert_enrichments, '$.some_json_prop')) IS NOT NULL AND JSON_UNQUOTE(JSON_EXTRACT(alert_enrichments, '$.some_json_prop')) COLLATE utf8mb4_general_ci LIKE '%lorem%' OR JSON_UNQUOTE(JSON_EXTRACT(alert_event, '$.some_json_prop')) IS NOT NULL AND JSON_UNQUOTE(JSON_EXTRACT(alert_event, '$.some_json_prop')) COLLATE utf8mb4_general_ci LIKE '%lorem%')",
+      "postgresql": "((alert_enrichments) ->> 'some_json_prop' IS NOT NULL AND (alert_enrichments) ->> 'some_json_prop' ILIKE '%lorem%' OR (alert_event) ->> 'some_json_prop' IS NOT NULL AND (alert_event) ->> 'some_json_prop' ILIKE '%lorem%')"
     }
   },
   {
@@ -201,18 +201,18 @@
     "input_cel": "alert.tags.tagKey.startsWith(\"with 'single-quotes'\")",
     "description": "When startsWith is used with literal containing quoted substring",
     "expected_sql_dialect_based": {
-      "sqlite": "json_extract(alert_event, '$.tagsContainer.tagKey') IS NOT NULL AND json_extract(alert_event, '$.tagsContainer.tagKey') GLOB 'with ''single-quotes''*'",
-      "mysql": "JSON_UNQUOTE(JSON_EXTRACT(alert_event, '$.tagsContainer.tagKey')) IS NOT NULL AND JSON_UNQUOTE(JSON_EXTRACT(alert_event, '$.tagsContainer.tagKey')) LIKE 'with ''single-quotes''%'",
-      "postgresql": "(alert_event -> 'tagsContainer') ->> 'tagKey' IS NOT NULL AND (alert_event -> 'tagsContainer') ->> 'tagKey' LIKE 'with ''single-quotes''%'"
+      "sqlite": "json_extract(alert_event, '$.tagsContainer.tagKey') IS NOT NULL AND json_extract(alert_event, '$.tagsContainer.tagKey') LIKE 'with ''single-quotes''%'",
+      "mysql": "JSON_UNQUOTE(JSON_EXTRACT(alert_event, '$.tagsContainer.tagKey')) IS NOT NULL AND JSON_UNQUOTE(JSON_EXTRACT(alert_event, '$.tagsContainer.tagKey')) COLLATE utf8mb4_general_ci LIKE 'with ''single-quotes''%'",
+      "postgresql": "(alert_event -> 'tagsContainer') ->> 'tagKey' IS NOT NULL AND (alert_event -> 'tagsContainer') ->> 'tagKey' ILIKE 'with ''single-quotes''%'"
     }
   },
   {
     "input_cel": "alert.tags.tagKey.endsWith(\"with 'single-quotes'\")",
     "description": "When endsWith is used with literal containing quoted substring",
     "expected_sql_dialect_based": {
-      "sqlite": "json_extract(alert_event, '$.tagsContainer.tagKey') IS NOT NULL AND json_extract(alert_event, '$.tagsContainer.tagKey') GLOB '*with ''single-quotes'''",
-      "mysql": "JSON_UNQUOTE(JSON_EXTRACT(alert_event, '$.tagsContainer.tagKey')) IS NOT NULL AND JSON_UNQUOTE(JSON_EXTRACT(alert_event, '$.tagsContainer.tagKey')) LIKE '%with ''single-quotes'''",
-      "postgresql": "(alert_event -> 'tagsContainer') ->> 'tagKey' IS NOT NULL AND (alert_event -> 'tagsContainer') ->> 'tagKey' LIKE '%with ''single-quotes'''"
+      "sqlite": "json_extract(alert_event, '$.tagsContainer.tagKey') IS NOT NULL AND json_extract(alert_event, '$.tagsContainer.tagKey') LIKE '%with ''single-quotes'''",
+      "mysql": "JSON_UNQUOTE(JSON_EXTRACT(alert_event, '$.tagsContainer.tagKey')) IS NOT NULL AND JSON_UNQUOTE(JSON_EXTRACT(alert_event, '$.tagsContainer.tagKey')) COLLATE utf8mb4_general_ci LIKE '%with ''single-quotes'''",
+      "postgresql": "(alert_event -> 'tagsContainer') ->> 'tagKey' IS NOT NULL AND (alert_event -> 'tagsContainer') ->> 'tagKey' ILIKE '%with ''single-quotes'''"
     }
   }
 ]


Code review comments:
diff_hunk: @@ -15,11 +22,26 @@ import Modal from "@/components/ui/Modal";
 import { WorkflowTemplates } from "./mockworkflows";
 import { useApi } from "@/shared/lib/hooks/useApi";
 import { Input, ErrorComponent } from "@/shared/ui";
-import { Textarea } from "@/components/ui";
-import { useWorkflowsV2 } from "utils/hooks/useWorkflowsV2";
+import { EmptyStateCard, Textarea } from "@/components/ui";
path: keep-ui/app/(keep)/workflows/workflows.client.tsx
text: @user2:
_⚠️ Potential issue_

**Build failure: 'EmptyStateCard' not exported from '@/components/ui'.**

The pipeline logs show a type error. Ensure that “EmptyStateCard” is actually exported or imported from the correct module. You could remove or replace it if it is no longer valid, for example:

```diff
-import { EmptyStateCard, Textarea } from "@/components/ui";
+import { Textarea } from "@/components/ui";
// or import EmptyStateCard from "another/path" if it actually exists
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
import { Textarea } from "@/components/ui";
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 GitHub Actions: Tests (E2E)</summary>

[error] 25-25: Type error: Module '@user1/ui' has no exported member 'EmptyStateCard'.

</details>

</details>

<!-- This is an auto-generated reply by CodeRabbit -->

@user3:
@author 

@user2:
> Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @user2 in a new comment, and I'll come hopping out!

<!-- This is an auto-generated mute comment by CodeRabbit -->

diff_hunk: @@ -2,23 +1,62 @@
+import useSWR, { mutate, SWRConfiguration } from "swr";
 import { useApi } from "@/shared/lib/hooks/useApi";
 import { Workflow } from "@/shared/api/workflows";
 
-const WORKFLOWS_KEY = "/workflows?is_v2=true";
+export interface WorkflowsQuery {
+  cel?: string;
+  limit?: number;
+  offset?: number;
+  sortBy?: string;
+  sortDir?: "asc" | "desc";
+}
 
-export function useWorkflowsV2() {
+export function useWorkflowsV2(
+  workflowsQuery: WorkflowsQuery | null,
+  swrConfig?: SWRConfiguration
+) {
   const api = useApi();
+  const queryToPost: { [key: string]: any } = {};
+
+  if (workflowsQuery?.cel) {
+    queryToPost.cel = workflowsQuery.cel;
+  }
+
+  if (workflowsQuery?.limit !== undefined) {
+    queryToPost.limit = workflowsQuery.limit;
+  }
+
+  if (workflowsQuery?.offset !== undefined) {
+    queryToPost.offset = workflowsQuery.offset;
+  }
+
+  if (workflowsQuery?.sortBy) {
+    queryToPost.sort_by = workflowsQuery.sortBy;
+  }
+
+  if (workflowsQuery?.sortDir) {
+    queryToPost.sort_dir = workflowsQuery.sortDir;
+  }
+
+  let requestUrl = "/workflows/query?is_v2=true";
 
-  const { data, error, isLoading } = useSWR<Workflow[]>(
-    api.isReady() ? WORKFLOWS_KEY : null,
-    (url: string) => api.get(url)
+  const { data, error, isLoading } = useSWR<any>(
+    api.isReady() && workflowsQuery
+      ? requestUrl + JSON.stringify(queryToPost)
+      : null,
+    () => api.post(requestUrl, queryToPost),
+    swrConfig
   );
 
   const mutateWorkflows = () => {
-    return mutate(WORKFLOWS_KEY);
+    return mutate(
+      (key) => typeof key === "string" && key.startsWith(requestUrl)
+    );
   };
path: keep-ui/utils/hooks/useWorkflowsV2.ts
text: @user1:
_🛠️ Refactor suggestion_

**Fix SWR cache invalidation function**

The `mutateWorkflows` function should be updated to match the new cache key structure.


```diff
const mutateWorkflows = () => {
-  return mutate(
-    (key) => typeof key === "string" && key.startsWith(requestUrl)
-  );
+  return mutate(
+    (key) => typeof key === "string" && key.includes(requestUrl)
+  );
};
```

If you implement the improved cache key suggestion from above, update this accordingly:

```diff
const mutateWorkflows = () => {
-  return mutate(
-    (key) => typeof key === "string" && key.startsWith(requestUrl)
-  );
+  return mutate(
+    (key) => typeof key === "string" && key.split('::')[0] === requestUrl
+  );
};
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
  const mutateWorkflows = () => {
    return mutate(
      (key) => typeof key === "string" && key.split('::')[0] === requestUrl
    );
  };
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

diff_hunk: @@ -1,24 +1,62 @@
-import useSWR, { mutate } from "swr";
+import useSWR, { mutate, SWRConfiguration } from "swr";
 import { useApi } from "@/shared/lib/hooks/useApi";
 import { Workflow } from "@/shared/api/workflows";
 
-const WORKFLOWS_KEY = "/workflows?is_v2=true";
+export interface WorkflowsQuery {
+  cel?: string;
+  limit?: number;
+  offset?: number;
+  sortBy?: string;
+  sortDir?: "asc" | "desc";
+}
 
-export function useWorkflowsV2() {
+export function useWorkflowsV2(
+  workflowsQuery: WorkflowsQuery | null,
+  swrConfig?: SWRConfiguration
+) {
   const api = useApi();
+  const queryToPost: { [key: string]: any } = {};
+
+  if (workflowsQuery?.cel) {
+    queryToPost.cel = workflowsQuery.cel;
+  }
+
+  if (workflowsQuery?.limit !== undefined) {
+    queryToPost.limit = workflowsQuery.limit;
+  }
+
+  if (workflowsQuery?.offset !== undefined) {
+    queryToPost.offset = workflowsQuery.offset;
+  }
+
+  if (workflowsQuery?.sortBy) {
+    queryToPost.sort_by = workflowsQuery.sortBy;
+  }
+
+  if (workflowsQuery?.sortDir) {
+    queryToPost.sort_dir = workflowsQuery.sortDir;
+  }
+
+  let requestUrl = "/workflows/query?is_v2=true";
 
-  const { data, error, isLoading } = useSWR<Workflow[]>(
-    api.isReady() ? WORKFLOWS_KEY : null,
-    (url: string) => api.get(url)
+  const { data, error, isLoading } = useSWR<any>(
+    api.isReady() && workflowsQuery
+      ? requestUrl + JSON.stringify(queryToPost)
+      : null,
+    () => api.post(requestUrl, queryToPost),
+    swrConfig
   );
path: keep-ui/utils/hooks/useWorkflowsV2.ts
text: @user1:
_🛠️ Refactor suggestion_

**Improve request caching strategy and refactor query construction**

The current implementation has two issues:

1. The SWR cache key includes JSON.stringify of the query object, which may not be reliable if property order changes.
2. The manual construction of the query object is verbose and error-prone.


Consider using a more reliable key generation and refactoring the query construction:

```diff
- const queryToPost: { [key: string]: any } = {};
-
- if (workflowsQuery?.cel) {
-   queryToPost.cel = workflowsQuery.cel;
- }
-
- if (workflowsQuery?.limit !== undefined) {
-   queryToPost.limit = workflowsQuery.limit;
- }
-
- if (workflowsQuery?.offset !== undefined) {
-   queryToPost.offset = workflowsQuery.offset;
- }
-
- if (workflowsQuery?.sortBy) {
-   queryToPost.sort_by = workflowsQuery.sortBy;
- }
-
- if (workflowsQuery?.sortDir) {
-   queryToPost.sort_dir = workflowsQuery.sortDir;
- }
+ const queryToPost = workflowsQuery 
+   ? {
+       ...(workflowsQuery.cel !== undefined && { cel: workflowsQuery.cel }),
+       ...(workflowsQuery.limit !== undefined && { limit: workflowsQuery.limit }),
+       ...(workflowsQuery.offset !== undefined && { offset: workflowsQuery.offset }),
+       ...(workflowsQuery.sortBy !== undefined && { sort_by: workflowsQuery.sortBy }),
+       ...(workflowsQuery.sortDir !== undefined && { sort_dir: workflowsQuery.sortDir }),
+     }
+   : {};

let requestUrl = "/workflows/query?is_v2=true";

+ // Create a stable cache key
+ const cacheKey = api.isReady() && workflowsQuery 
+   ? [requestUrl, 
+      queryToPost.cel, 
+      queryToPost.limit, 
+      queryToPost.offset, 
+      queryToPost.sort_by, 
+      queryToPost.sort_dir].filter(Boolean).join('::')
+   : null;

const { data, error, isLoading } = useSWR<any>(
-  api.isReady() && workflowsQuery
-    ? requestUrl + JSON.stringify(queryToPost)
-    : null,
+  cacheKey,
  () => api.post(requestUrl, queryToPost),
  swrConfig
);
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
export function useWorkflowsV2(
  workflowsQuery: WorkflowsQuery | null,
  swrConfig?: SWRConfiguration
) {
  const api = useApi();
  const queryToPost = workflowsQuery 
    ? {
        ...(workflowsQuery.cel !== undefined && { cel: workflowsQuery.cel }),
        ...(workflowsQuery.limit !== undefined && { limit: workflowsQuery.limit }),
        ...(workflowsQuery.offset !== undefined && { offset: workflowsQuery.offset }),
        ...(workflowsQuery.sortBy !== undefined && { sort_by: workflowsQuery.sortBy }),
        ...(workflowsQuery.sortDir !== undefined && { sort_dir: workflowsQuery.sortDir }),
      }
    : {};

  let requestUrl = "/workflows/query?is_v2=true";

  // Create a stable cache key
  const cacheKey = api.isReady() && workflowsQuery 
    ? [
        requestUrl, 
        queryToPost.cel, 
        queryToPost.limit, 
        queryToPost.offset, 
        queryToPost.sort_by, 
        queryToPost.sort_dir
      ].filter(Boolean).join("::")
    : null;

  const { data, error, isLoading } = useSWR<any>(
    cacheKey,
    () => api.post(requestUrl, queryToPost),
    swrConfig
  );
}
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->