Repository: keephq/keep
Pull request #3740: feat: Filtering/search workflows
Author: login: skynetigor
Labels: name: Feature
description: A new feature
name: size:XXL
description: This PR changes 1000+ lines, ignoring generated files.
Created: 2025-02-27T14:02:50Z | Merged: 2025-03-09T09:48:08Z

<!-- 
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. -->


Linked issue #3624: [➕ 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.

Diff:
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..c80a4f85ad 100644
--- a/keep-ui/app/(keep)/workflows/workflows.client.tsx
+++ b/keep-ui/app/(keep)/workflows/workflows.client.tsx
@@ -1,12 +1,19 @@
 "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,
   PlusCircleIcon,
 } from "@heroicons/react/24/outline";
-import { KeepLoader, PageTitle } from "@/shared/ui";
+import { EmptyStateCard, KeepLoader, PageTitle } from "@/shared/ui";
 import WorkflowsEmptyState from "./noworkflows";
 import WorkflowTile from "./workflow-tile";
 import { ArrowRightIcon } from "@radix-ui/react-icons";
@@ -16,10 +23,25 @@ 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 { 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,151 @@ 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"
+              icon={FunnelIcon}
+            >
+              <Button
+                color="orange"
+                variant="secondary"
+                onClick={() => setClearFiltersToken(uuidV4())}
+              >
+                Reset filter
+              </Button>
+            </EmptyStateCard>
+          </div>
+        </div>
+      </>
+    );
+  }
+
+  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"
+              icon={MagnifyingGlassIcon}
+            >
+              <Button
+                color="orange"
+                variant="secondary"
+                onClick={() => setSearchedValue(null)}
+              >
+                Clear search
+              </Button>
+            </EmptyStateCard>
+          </div>
+        </div>
+      </>
+    );
   }
 
-  if (isLoading || !workflows) {
-    return <KeepLoader />;
+  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 +352,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 +389,44 @@ export default function WorkflowsPage() {
               </Button>
             </div>
           </div>
-          {workflows.length === 0 ? (
+          {isEmptyState ? (
             <WorkflowsEmptyState />
           ) : (
-            <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..dfd6265d0e 100644
--- a/keep-ui/utils/hooks/useWorkflowsV2.ts
+++ b/keep-ui/utils/hooks/useWorkflowsV2.ts
@@ -1,24 +1,70 @@
-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();
+  let requestUrl = "/workflows/query?is_v2=true";
 
-  const { data, error, isLoading } = useSWR<Workflow[]>(
-    api.isReady() ? WORKFLOWS_KEY : null,
-    (url: string) => api.get(url)
-  );
+  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,
+        }),
+      }
+    : {};
 
-  const mutateWorkflows = () => {
-    return mutate(WORKFLOWS_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 ? cacheKey : null,
+      () => api.post(requestUrl, queryToPost),
+      swrConfig
+    );
+
+    const mutateWorkflows = () => {
+      return mutate(
+        (key) => typeof key === "string" && key.split("::")[0] === 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 1fd48903de..acb927c9b8 100644
--- a/keep/api/core/db.py
+++ b/keep/api/core/db.py
@@ -3092,60 +3092,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..58e7875f0c 100644
--- a/keep/api/routes/workflows.py
+++ b/keep/api/routes/workflows.py
@@ -18,6 +18,7 @@
 from opentelemetry import trace
 from sqlmodel import Session
 
+from keep.api.core.cel_to_sql.sql_providers.base import CelToSqlException
 from keep.api.core.config import config
 from keep.api.core.db import (
     get_alert_by_event_id,
@@ -28,7 +29,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 +60,107 @@
 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,
+        },
+    )
+
+    try:
+        facet_options = get_workflow_facets_data(
+            tenant_id=tenant_id, facet_options_query=facet_options_query
+        )
+    except CelToSqlException as e:
+        logger.exception(
+            f'Error parsing CEL expression "{facet_options_query.cel}". {str(e)}'
+        )
+        raise HTTPException(
+            status_code=400,
+            detail=f"Error parsing CEL expression: {facet_options_query.cel}",
+        ) from e
+
+    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 +175,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 = []
@@ -86,10 +211,24 @@ def get_workflows(
             installed_providers_by_type[installed_provider.type][
                 installed_provider.name
             ] = installed_provider
-    # get all workflows
-    workflows = workflowstore.get_all_workflows_with_last_execution(
-        tenant_id=tenant_id, is_v2=is_v2
-    )
+
+    try:
+        # get all workflows
+        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,
+        )
+    except CelToSqlException as e:
+        logger.exception(f'Error parsing CEL expression "{query.cel}". {str(e)}')
+        raise HTTPException(
+            status_code=400,
+            detail=f"Error parsing CEL expression: {query.cel}",
+        ) from e
 
     # Group last workflow executions by workflow
     if is_v2:
@@ -150,7 +289,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 9340440360..24ffd774f4 100644
--- a/tests/cel_to_sql/cel-to-sql-test-cases.json
+++ b/tests/cel_to_sql/cel-to-sql-test-cases.json
@@ -39,27 +39,27 @@
     "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%')"
     }
   },
   {
     "input_cel": "alert.some_json_prop.contains(100500)",
     "description": "Contains operator with arg not string",
     "expected_sql_dialect_based": {
-      "sqlite": "(json_extract(alert_enrichments, '$.some_json_prop') IS NOT NULL AND json_extract(alert_enrichments, '$.some_json_prop') GLOB '*100500*' OR json_extract(alert_event, '$.some_json_prop') IS NOT NULL AND json_extract(alert_event, '$.some_json_prop') GLOB '*100500*')",
-      "mysql": "(JSON_UNQUOTE(JSON_EXTRACT(alert_enrichments, '$.some_json_prop')) IS NOT NULL AND JSON_UNQUOTE(JSON_EXTRACT(alert_enrichments, '$.some_json_prop')) LIKE '%100500%' OR JSON_UNQUOTE(JSON_EXTRACT(alert_event, '$.some_json_prop')) IS NOT NULL AND JSON_UNQUOTE(JSON_EXTRACT(alert_event, '$.some_json_prop')) LIKE '%100500%')",
-      "postgresql": "((alert_enrichments) ->> 'some_json_prop' IS NOT NULL AND (alert_enrichments) ->> 'some_json_prop' LIKE '%100500%' OR (alert_event) ->> 'some_json_prop' IS NOT NULL AND (alert_event) ->> 'some_json_prop' LIKE '%100500%')"
+      "sqlite": "(json_extract(alert_enrichments, '$.some_json_prop') IS NOT NULL AND json_extract(alert_enrichments, '$.some_json_prop') LIKE '%100500%' OR json_extract(alert_event, '$.some_json_prop') IS NOT NULL AND json_extract(alert_event, '$.some_json_prop') LIKE '%100500%')",
+      "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 '%100500%' 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 '%100500%')",
+      "postgresql": "((alert_enrichments) ->> 'some_json_prop' IS NOT NULL AND (alert_enrichments) ->> 'some_json_prop' ILIKE '%100500%' OR (alert_event) ->> 'some_json_prop' IS NOT NULL AND (alert_event) ->> 'some_json_prop' ILIKE '%100500%')"
     }
   },
   {
@@ -210,18 +210,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'''"
     }
   }
 ]


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 7**

<details>
<summary>🧹 Nitpick comments (13)</summary><blockquote>

<details>
<summary>keep-ui/app/(keep)/workflows/page.tsx (1)</summary><blockquote>

`7-25`: **Good implementation of server-side data fetching for initial facets**

The conversion to an async function with server-side data fetching is a good approach for improving initial load performance and SEO.

However, the error handling could be improved:

```diff
- try {
-   const api = await createServerApiClient();
-   initialFacets = await getInitialFacets(api, "workflows");
- } catch (error) {
-   console.log(error);
- }
+ try {
+   const api = await createServerApiClient();
+   initialFacets = await getInitialFacets(api, "workflows");
+ } catch (error) {
+   console.error("Failed to fetch initial facets:", error);
+   // Consider adding error reporting service here
+ }
```

</blockquote></details>
<details>
<summary>keep-ui/features/filter/search-input.tsx (1)</summary><blockquote>

`1-34`: **Well-implemented debounced search component with proper state management**

The component correctly implements debounced search functionality and handles state synchronization between props and internal state.

Two minor suggestions to enhance this component:


1. Extract the debounce delay to a constant or prop for better configurability:

```diff
+ interface SearchInputProps {
    className?: string;
    value?: string;
    placeholder: string;
    onValueChange: (value: string) => void;
+   debounceMs?: number;
  }

  export const SearchInput = ({
    className,
    placeholder,
    onValueChange,
    value,
+   debounceMs = 500,
  }: SearchInputProps) => {
    const [inputValue, setInputValue] = useState("");

    useEffect(() => {
-     const timeoutId = setTimeout(() => onValueChange(inputValue), 500);
+     const timeoutId = setTimeout(() => onValueChange(inputValue), debounceMs);
      return () => clearTimeout(timeoutId);
-   }, [inputValue, onValueChange]);
+   }, [inputValue, onValueChange, debounceMs]);
```

2. Consider adding an immediate search option (like pressing Enter) to bypass the debounce:

```diff
  return (
    <TextInput
      className={className}
      placeholder={placeholder}
      value={inputValue}
      onValueChange={setInputValue}
+     onKeyDown={(e) => {
+       if (e.key === 'Enter') {
+         onValueChange(inputValue);
+       }
+     }}
    />
  );
```

</blockquote></details>
<details>
<summary>keep-ui/utils/hooks/useWorkflowsV2.ts (1)</summary><blockquote>

`56-63`: **Improve typing and null handling**

The current implementation has some type safety issues with the returned data.


```diff
return {
-  workflows: data?.results as Workflow[],
-  totalCount: data?.count,
+  workflows: data?.results as Workflow[] || [],
+  totalCount: data?.count || 0,
  isLoading: isLoading || !data,
  error,
  mutateWorkflows,
};
```

</blockquote></details>
<details>
<summary>keep/api/core/cel_to_sql/sql_providers/sqlite.py (3)</summary><blockquote>

`62-64`: **Improved string matching with LIKE operator**

The change from GLOB pattern matching to SQL LIKE pattern matching aligns the SQLite implementation with other database providers, which is a good standardization. This approach enhances cross-database compatibility for the CEL to SQL conversion.

However, SQLite's LIKE operator is case-sensitive by default, unlike the now case-insensitive implementations in MySQL and PostgreSQL.



Consider adding a COLLATE NOCASE clause to make SQLite LIKE operations case-insensitive, similar to the approach used for MySQL:

```diff
-return f"{property_path} IS NOT NULL AND {property_path} LIKE '%{unquoted_literal}%'"
+return f"{property_path} IS NOT NULL AND {property_path} LIKE '%{unquoted_literal}%' COLLATE NOCASE"
```

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

<details>
<summary>🪛 GitHub Check: codecov/patch</summary>

[warning] 62-64: keep/api/core/cel_to_sql/sql_providers/sqlite.py#L62-L64
Added lines #L62 - L64 were not covered by tests

</details>

</details>

---

`71-73`: **Missing case-insensitive collation for startsWith**

Similar to the contains method, the startsWith implementation would benefit from case-insensitive matching for consistency with other database providers.


```diff
-return f"{property_path} IS NOT NULL AND {property_path} LIKE '{unquoted_literal}%'"
+return f"{property_path} IS NOT NULL AND {property_path} LIKE '{unquoted_literal}%' COLLATE NOCASE"
```

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

<details>
<summary>🪛 GitHub Check: codecov/patch</summary>

[warning] 71-73: keep/api/core/cel_to_sql/sql_providers/sqlite.py#L71-L73
Added lines #L71 - L73 were not covered by tests

</details>

</details>

---

`81-83`: **Missing case-insensitive collation for endsWith**

The endsWith method should also use case-insensitive matching for consistency.


```diff
-return f"{property_path} IS NOT NULL AND {property_path} LIKE '%{unquoted_literal}'"
+return f"{property_path} IS NOT NULL AND {property_path} LIKE '%{unquoted_literal}' COLLATE NOCASE"
```

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

<details>
<summary>🪛 GitHub Check: codecov/patch</summary>

[warning] 81-83: keep/api/core/cel_to_sql/sql_providers/sqlite.py#L81-L83
Added lines #L81 - L83 were not covered by tests

</details>

</details>

</blockquote></details>
<details>
<summary>keep/api/routes/workflows.py (5)</summary><blockquote>

`66-71`: **Avoid function calls as default arguments (Ruff B008).**

Calling `IdentityManagerFactory.get_auth_verifier(...)` within the function signature triggers B008. Consider moving this call inside the function body or referencing a module-level variable, for example:

```diff
-def fetch_facet_options(
-    facet_options_query: FacetOptionsQueryDto,
-    authenticated_entity: AuthenticatedEntity = Depends(
-        IdentityManagerFactory.get_auth_verifier(["read:workflows"])
-    ),
-) -> dict:
+def fetch_facet_options(
+    facet_options_query: FacetOptionsQueryDto = Body(...),
+    authenticated_entity: AuthenticatedEntity = Depends(get_auth_verifier_workflows),
+) -> dict:

# define elsewhere at module level or inside the function:
# get_auth_verifier_workflows = IdentityManagerFactory.get_auth_verifier(["read:workflows"])
```

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

<details>
<summary>🪛 Ruff (0.8.2)</summary>

68-70: Do not perform function call `Depends` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

---

69-69: Do not perform function call `IdentityManagerFactory.get_auth_verifier` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

</details>

</details>

---

`100-102`: **Same note about function calls in default arguments (Ruff B008).**

Apply a similar refactoring as suggested for lines 66-71 to avoid this anti-pattern.

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

<details>
<summary>🪛 Ruff (0.8.2)</summary>

100-102: Do not perform function call `Depends` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

---

101-101: Do not perform function call `IdentityManagerFactory.get_auth_verifier` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

</details>

</details>

---

`130-132`: **Again, function calls in default arguments (Ruff B008).**

Consider the same pattern to define the verifier call outside the function signature.

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

<details>
<summary>🪛 Ruff (0.8.2)</summary>

130-132: Do not perform function call `Depends` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

---

131-131: Do not perform function call `IdentityManagerFactory.get_auth_verifier` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

</details>

</details>

---

`168-177`: **Deprecation note (TODO).**

The comment indicates this endpoint should be deprecated. Would you like me to open a tracking issue or propose a PR removing it?

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

<details>
<summary>🪛 Ruff (0.8.2)</summary>

170-172: Do not perform function call `Depends` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

---

171-171: Do not perform function call `IdentityManagerFactory.get_auth_verifier` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

</details>

</details>

---

`179-187`: **Same Ruff B008 note on lines 185-187.**

Consider referencing `IdentityManagerFactory.get_auth_verifier(["read:workflows"])` outside the parameter defaults.

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

<details>
<summary>🪛 Ruff (0.8.2)</summary>

185-187: Do not perform function call `Depends` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

---

186-186: Do not perform function call `IdentityManagerFactory.get_auth_verifier` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

</details>

</details>

</blockquote></details>
<details>
<summary>keep/api/core/workflows.py (2)</summary><blockquote>

`135-135`: **Use pythonic 'not' instead of comparing to False (Ruff E712).**

Defer from:
```python
.where(Workflow.is_deleted == False)
```
to:
```python
.where(not Workflow.is_deleted)
```

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

<details>
<summary>🪛 Ruff (0.8.2)</summary>

135-135: Avoid equality comparisons to `False`; use `if not Workflow.is_deleted:` for false checks

Replace with `not Workflow.is_deleted`

(E712)

</details>

</details>

---

`86-275`: **Coverage shortfall.**

Some new lines are missing test coverage per codecov. Let me know if you’d like help creating additional tests to ensure these queries and endpoints are safely covered.

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

<details>
<summary>🪛 Ruff (0.8.2)</summary>

135-135: Avoid equality comparisons to `False`; use `if not Workflow.is_deleted:` for false checks

Replace with `not Workflow.is_deleted`

(E712)

</details>
<details>
<summary>🪛 GitHub Check: codecov/patch</summary>

[warning] 86-86: keep/api/core/workflows.py#L86
Added line #L86 was not covered by tests

---

[warning] 88-92: keep/api/core/workflows.py#L88-L92
Added lines #L88 - L92 were not covered by tests

---

[warning] 113-113: keep/api/core/workflows.py#L113
Added line #L113 was not covered by tests

---

[warning] 138-138: keep/api/core/workflows.py#L138
Added line #L138 was not covered by tests

---

[warning] 146-146: keep/api/core/workflows.py#L146
Added line #L146 was not covered by tests

---

[warning] 150-150: keep/api/core/workflows.py#L150
Added line #L150 was not covered by tests

---

[warning] 154-157: keep/api/core/workflows.py#L154-L157
Added lines #L154 - L157 were not covered by tests

---

[warning] 159-159: keep/api/core/workflows.py#L159
Added line #L159 was not covered by tests

---

[warning] 161-161: keep/api/core/workflows.py#L161
Added line #L161 was not covered by tests

---

[warning] 173-175: keep/api/core/workflows.py#L173-L175
Added lines #L173 - L175 were not covered by tests

---

[warning] 177-177: keep/api/core/workflows.py#L177
Added line #L177 was not covered by tests

---

[warning] 179-181: keep/api/core/workflows.py#L179-L181
Added lines #L179 - L181 were not covered by tests

---

[warning] 183-184: keep/api/core/workflows.py#L183-L184
Added lines #L183 - L184 were not covered by tests

---

[warning] 186-188: keep/api/core/workflows.py#L186-L188
Added lines #L186 - L188 were not covered by tests

---

[warning] 193-194: keep/api/core/workflows.py#L193-L194
Added lines #L193 - L194 were not covered by tests

---

[warning] 196-196: keep/api/core/workflows.py#L196
Added line #L196 was not covered by tests

---

[warning] 212-213: keep/api/core/workflows.py#L212-L213
Added lines #L212 - L213 were not covered by tests

---

[warning] 217-217: keep/api/core/workflows.py#L217
Added line #L217 was not covered by tests

---

[warning] 219-220: keep/api/core/workflows.py#L219-L220
Added lines #L219 - L220 were not covered by tests

---

[warning] 222-222: keep/api/core/workflows.py#L222
Added line #L222 was not covered by tests

---

[warning] 224-224: keep/api/core/workflows.py#L224
Added line #L224 was not covered by tests

</details>

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 98e6e8d9beaad3f717de657dbf96d7b0f06a2c2e and 39b6faec42dccab9733dc93911be9b674b8fb12b.

</details>

<details>
<summary>📒 Files selected for processing (16)</summary>

* `keep-ui/app/(keep)/workflows/page.tsx` (1 hunks)
* `keep-ui/app/(keep)/workflows/workflows.client.tsx` (6 hunks)
* `keep-ui/features/filter/index.ts` (1 hunks)
* `keep-ui/features/filter/pagination.tsx` (1 hunks)
* `keep-ui/features/filter/search-input.tsx` (1 hunks)
* `keep-ui/utils/hooks/useWorkflows.ts` (1 hunks)
* `keep-ui/utils/hooks/useWorkflowsV2.ts` (1 hunks)
* `keep/api/core/cel_to_sql/sql_providers/mysql.py` (3 hunks)
* `keep/api/core/cel_to_sql/sql_providers/postgresql.py` (3 hunks)
* `keep/api/core/cel_to_sql/sql_providers/sqlite.py` (1 hunks)
* `keep/api/core/db.py` (0 hunks)
* `keep/api/core/workflows.py` (1 hunks)
* `keep/api/routes/facets.py` (1 hunks)
* `keep/api/routes/workflows.py` (5 hunks)
* `keep/workflowmanager/workflowstore.py` (3 hunks)
* `tests/cel_to_sql/cel-to-sql-test-cases.json` (2 hunks)

</details>

<details>
<summary>💤 Files with no reviewable changes (1)</summary>

* keep/api/core/db.py

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 GitHub Check: codecov/patch</summary>

<details>
<summary>keep/api/core/cel_to_sql/sql_providers/postgresql.py</summary>

[warning] 55-55: keep/api/core/cel_to_sql/sql_providers/postgresql.py#L55
Added line #L55 was not covered by tests

---

[warning] 64-64: keep/api/core/cel_to_sql/sql_providers/postgresql.py#L64
Added line #L64 was not covered by tests

---

[warning] 73-73: keep/api/core/cel_to_sql/sql_providers/postgresql.py#L73
Added line #L73 was not covered by tests

</details>
<details>
<summary>keep/api/core/cel_to_sql/sql_providers/sqlite.py</summary>

[warning] 62-64: keep/api/core/cel_to_sql/sql_providers/sqlite.py#L62-L64
Added lines #L62 - L64 were not covered by tests

---

[warning] 71-73: keep/api/core/cel_to_sql/sql_providers/sqlite.py#L71-L73
Added lines #L71 - L73 were not covered by tests

---

[warning] 81-83: keep/api/core/cel_to_sql/sql_providers/sqlite.py#L81-L83
Added lines #L81 - L83 were not covered by tests

</details>
<details>
<summary>keep/api/core/cel_to_sql/sql_providers/mysql.py</summary>

[warning] 49-49: keep/api/core/cel_to_sql/sql_providers/mysql.py#L49
Added line #L49 was not covered by tests

---

[warning] 58-58: keep/api/core/cel_to_sql/sql_providers/mysql.py#L58
Added line #L58 was not covered by tests

---

[warning] 67-67: keep/api/core/cel_to_sql/sql_providers/mysql.py#L67
Added line #L67 was not covered by tests

</details>
<details>
<summary>keep/api/core/workflows.py</summary>

[warning] 86-86: keep/api/core/workflows.py#L86
Added line #L86 was not covered by tests

---

[warning] 88-92: keep/api/core/workflows.py#L88-L92
Added lines #L88 - L92 were not covered by tests

---

[warning] 113-113: keep/api/core/workflows.py#L113
Added line #L113 was not covered by tests

---

[warning] 138-138: keep/api/core/workflows.py#L138
Added line #L138 was not covered by tests

---

[warning] 146-146: keep/api/core/workflows.py#L146
Added line #L146 was not covered by tests

---

[warning] 150-150: keep/api/core/workflows.py#L150
Added line #L150 was not covered by tests

---

[warning] 154-157: keep/api/core/workflows.py#L154-L157
Added lines #L154 - L157 were not covered by tests

---

[warning] 159-159: keep/api/core/workflows.py#L159
Added line #L159 was not covered by tests

---

[warning] 161-161: keep/api/core/workflows.py#L161
Added line #L161 was not covered by tests

---

[warning] 173-175: keep/api/core/workflows.py#L173-L175
Added lines #L173 - L175 were not covered by tests

---

[warning] 177-177: keep/api/core/workflows.py#L177
Added line #L177 was not covered by tests

---

[warning] 179-181: keep/api/core/workflows.py#L179-L181
Added lines #L179 - L181 were not covered by tests

---

[warning] 183-184: keep/api/core/workflows.py#L183-L184
Added lines #L183 - L184 were not covered by tests

---

[warning] 186-188: keep/api/core/workflows.py#L186-L188
Added lines #L186 - L188 were not covered by tests

---

[warning] 193-194: keep/api/core/workflows.py#L193-L194
Added lines #L193 - L194 were not covered by tests

---

[warning] 196-196: keep/api/core/workflows.py#L196
Added line #L196 was not covered by tests

---

[warning] 212-213: keep/api/core/workflows.py#L212-L213
Added lines #L212 - L213 were not covered by tests

---

[warning] 217-217: keep/api/core/workflows.py#L217
Added line #L217 was not covered by tests

---

[warning] 219-220: keep/api/core/workflows.py#L219-L220
Added lines #L219 - L220 were not covered by tests

---

[warning] 222-222: keep/api/core/workflows.py#L222
Added line #L222 was not covered by tests

---

[warning] 224-224: keep/api/core/workflows.py#L224
Added line #L224 was not covered by tests

</details>

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

<details>
<summary>keep-ui/app/(keep)/workflows/workflows.client.tsx</summary>

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

</details>

</details>
<details>
<summary>🪛 Ruff (0.8.2)</summary>

<details>
<summary>keep/api/routes/workflows.py</summary>

68-70: Do not perform function call `Depends` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

---

69-69: Do not perform function call `IdentityManagerFactory.get_auth_verifier` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

---

100-102: Do not perform function call `Depends` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

---

101-101: Do not perform function call `IdentityManagerFactory.get_auth_verifier` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

---

130-132: Do not perform function call `Depends` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

---

131-131: Do not perform function call `IdentityManagerFactory.get_auth_verifier` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

---

170-172: Do not perform function call `Depends` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

---

171-171: Do not perform function call `IdentityManagerFactory.get_auth_verifier` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

---

185-187: Do not perform function call `Depends` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

---

186-186: Do not perform function call `IdentityManagerFactory.get_auth_verifier` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

</details>
<details>
<summary>keep/api/core/workflows.py</summary>

135-135: Avoid equality comparisons to `False`; use `if not Workflow.is_deleted:` for false checks

Replace with `not Workflow.is_deleted`

(E712)

---

312-312: Use `key in dict` instead of `key in dict.keys()`

Remove `.keys()`

(SIM118)

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (28)</summary><blockquote>

<details>
<summary>keep/api/core/cel_to_sql/sql_providers/postgresql.py (1)</summary>

`55-55`: <details>
<summary>✅ Verification successful</summary>

**Updated SQL queries to use case-insensitive matching - this enhancement is great!**

Changing from `LIKE` to `ILIKE` for all string matching operations implements case-insensitive search, which significantly improves the user experience. This change aligns perfectly with the PR objective of implementing a case-insensitive version of the `.contains()` CEL operator.



However, the static analysis shows these changes are not covered by tests. Let's verify if there are any test cases for these methods:




Also applies to: 64-64, 73-73

---

🏁 Script executed:

```shell
#!/bin/bash
# Description: Check for test coverage of the case-insensitive search operators

# Look for test files related to SQL providers or CEL to SQL conversion
echo "Searching for test files that might cover SQL provider functionality:"
fd "test_.*sql.*" "keep/api" "tests"

# Check for test cases involving ILIKE operator in PostgreSQL
echo "Checking for test cases using ILIKE:"
rg -i "ilike" --type py --type json "tests" "keep/api/tests"
```

Length of output: 1268

---



</details>

**SQL ILIKE operator update verified with adequate test coverage**

The update—changing from `LIKE` to `ILIKE`—is validated in the test cases, for example in the JSON file at `tests/cel_to_sql/cel-to-sql-test-cases.json`, which confirms that the case-insensitive `.contains()` CEL operator is properly tested. This applies not just to line 55 but also to similar changes at lines 64 and 73. No further changes are required here.

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

<details>
<summary>🪛 GitHub Check: codecov/patch</summary>

[warning] 55-55: keep/api/core/cel_to_sql/sql_providers/postgresql.py#L55
Added line #L55 was not covered by tests

</details>

</details>

</details>
<details>
<summary>keep-ui/features/filter/index.ts (1)</summary>

`2-2`: **Good addition of pagination and search components**

Adding exports for `Pagination` and `SearchInput` components is a clean way to implement the requested filtering and search functionality. These components will provide a consistent user experience across the application.



Also applies to: 9-9

</details>
<details>
<summary>keep-ui/utils/hooks/useWorkflows.ts (1)</summary>

`9-18`: <details>
<summary>✅ Verification successful</summary>

**Improved data structure in the useWorkflows hook**

Good refactoring of the hook to provide a more structured response. By spreading the SWR properties and extracting the results into a dedicated `data` property, you're maintaining the SWR functionality while providing a cleaner interface for consumers of this hook.



Let's verify the structure of the API response to ensure that `.results` is the correct property:

---

🏁 Script executed:

```shell
#!/bin/bash
# Description: Verify the structure of the workflows API response

# Check for API routes that handle workflows to understand the response structure
echo "Looking for workflows API implementation:"
rg -A 10 "def get_workflows" "keep/api/routes"

# Check if there are any references to the "results" property in workflow-related components
echo "Checking references to 'results' property in workflow components:"
rg "data\?.?results" --type ts "keep-ui/app/(keep)/workflows"
```

Length of output: 1053

---



</details>

**Verified: useWorkflows hook returns the expected API structure**  
Our investigation confirms that the API endpoint in `keep/api/routes/workflows.py` returns data via the `"results"` property (as seen in the line `return query_result["results"]`). This verifies that spreading SWR's properties and extracting `data: swr.data?.results as Workflow[]` in the hook is correct. The absence of additional matches when searching in workflow components isn’t a concern here since our primary validation is focused on the API response. Overall, the refactoring improves clarity without breaking functionality.

</details>
<details>
<summary>keep-ui/features/filter/pagination.tsx (1)</summary>

`38-68`: **Clean pagination component with effective state management**

The component implementation is good with proper memoization of options and clear state management.

</details>
<details>
<summary>keep-ui/utils/hooks/useWorkflowsV2.ts (1)</summary>

`5-11`: **Good interface design for workflow query parameters**

The `WorkflowsQuery` interface is well-structured with appropriate optional properties.

</details>
<details>
<summary>keep/workflowmanager/workflowstore.py (4)</summary>

`5-5`: **LGTM!**

Good addition of the Tuple import to support the new return type.

---

`24-24`: **LGTM!**

Proper import of the new function from the workflows module.

---

`148-157`: **Enhanced method signature with search and pagination capabilities**

Good improvement to the method signature, adding CEL filtering, limit, offset, and sorting parameters. These are essential for implementing the filtering/search functionality described in the PR objectives.

---

`159-170`: **Well-structured conditional logic for v2 implementation**

The conditional approach maintains backward compatibility while enabling the new functionality. I appreciate how clearly the v2 branch is implemented when the flag is set.


The v2 function correctly passes all the new parameters including the CEL expression for filtering, which directly addresses the PR objective of implementing case-insensitive `.contains()` CEL operator.

</details>
<details>
<summary>keep/api/core/cel_to_sql/sql_providers/mysql.py (2)</summary>

`58-58`: **Case-insensitive string matching consistently applied**

You've consistently applied the same collation approach to all string matching methods (contains, startsWith, endsWith), which ensures uniform behavior.



Also applies to: 67-67

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

<details>
<summary>🪛 GitHub Check: codecov/patch</summary>

[warning] 58-58: keep/api/core/cel_to_sql/sql_providers/mysql.py#L58
Added line #L58 was not covered by tests

</details>

</details>

---

`49-49`: <details>
<summary>❓ Verification inconclusive</summary>

**Properly implemented case-insensitive string matching**

The addition of `COLLATE utf8mb4_general_ci` is the correct approach for implementing case-insensitive matching in MySQL, aligning with the PR objective.



Let's check if the implementation has test coverage:

---

🏁 Script executed:

```shell
#!/bin/bash
# Check if the MySQL provider implementation for contains is being tested

# Search for MySQL-specific test cases that would use this method
rg -A 3 "mysql.*contains" tests/cel_to_sql/cel-to-sql-test-cases.json
```

Length of output: 71

---



</details>

**Action: Verify Test Coverage for MySQL Contains Implementation**

The implementation using `COLLATE utf8mb4_general_ci` for case-insensitive string matching is correct and aligns with the PR objective. However, our search for MySQL-specific "contains" tests (using the pattern in `tests/cel_to_sql/cel-to-sql-test-cases.json`) did not return any output. Please manually verify that there is adequate test coverage for this method or consider adding tests if none exist.

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

<details>
<summary>🪛 GitHub Check: codecov/patch</summary>

[warning] 49-49: keep/api/core/cel_to_sql/sql_providers/mysql.py#L49
Added line #L49 was not covered by tests

</details>

</details>

</details>
<details>
<summary>tests/cel_to_sql/cel-to-sql-test-cases.json (3)</summary>

`42-44`: **LGTM! Test cases properly updated for case-insensitive matching**

The test cases have been correctly updated to reflect the implementation changes:
- SQLite: Using LIKE instead of GLOB
- MySQL: Added COLLATE utf8mb4_general_ci
- PostgreSQL: Using ILIKE (case-insensitive LIKE)

This provides good test coverage for the new case-insensitive .contains() CEL operator implementation.

---

`51-53`: **Test case correctly updated for nested JSON case-insensitive contains**

The test case has been properly updated for complex JSON field access combined with case-insensitive string matching across all supported database engines.

---

`204-206`: **Test cases properly updated for startsWith and endsWith with special cases**

The test cases handling strings with single quotes have been correctly updated to use the new case-insensitive matching approaches while maintaining proper escaping of quotes.



Also applies to: 213-215

</details>
<details>
<summary>keep/api/routes/workflows.py (10)</summary>

`31-35`: **No concerns with these imports.**

---

`37-37`: **No issues here.**

---

`38-38`: **No issues here.**

---

`62-65`: **New endpoint definition looks good.**

---

`74-92`: **Implementation logic is straightforward and effective.**

---

`95-98`: **Route declaration for fetching facets looks good.**

---

`104-122`: **Facets fetching logic is clear and well-logged.**

---

`125-128`: **Route for facet fields looks good.**

---

`134-151`: **Facet fields retrieval is handled properly.**

---

`188-279`: **Query logic appears sound and well-structured.**

</details>
<details>
<summary>keep-ui/app/(keep)/workflows/workflows.client.tsx (4)</summary>

`3-10`: **General import statements look fine aside from the 'EmptyStateCard' import issue.**

---

`30-43`: **Additional imports for searching and filtering are a welcome improvement.**

---

`87-90`: **AssigneeLabel component looks properly implemented.**

---

`92-523`: **Overall UI and filtering logic appear robust.**

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-04T17:36:32Z
user: login: Kiryous
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T17:40:41Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-04T17:40:48Z
user: login: Kiryous
body: LGTM!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T09:47:40Z

Review comments:
diff_hunk: @@ -19,6 +19,7 @@
 entity_name_to_entity_type = {
     "incidents": "incident",
     "alerts": "alert",
+    "workflows": "workflow",
path: keep/api/routes/facets.py
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Added support for workflow facets - good enhancement**

Adding "workflows" to the entity mapping enables facet functionality for workflows, which aligns with the filtering requirements in this PR.



However, I notice that the log messages in the functions still reference "incident" specifically, even when handling other entity types. Let's check if this could be improved:

---

🏁 Script executed:

```shell
#!/bin/bash
# Description: Check for potential inconsistency in log messages

# Look for other log messages in the same file
echo "Checking logging messages in facets.py:"
rg "Creating facet for incident|Deleting facet for incident" "keep/api/routes/facets.py" -A 2 -B 2
```

Length of output: 500

---



</details>

**Improve log message dynamicity in facets.py**

The addition of workflow facets is a valuable enhancement. However, our verification shows that the log messages remain hardcoded as "Creating facet for incident" and "Deleting facet for incident." To maintain consistency and clarity, please update these log messages to reflect the actual facet type dynamically (for example, by interpolating the facet name or using a parameter). This adjustment will ensure that debugging and log analysis remain accurate when handling different entity types.

- **File:** keep/api/routes/facets.py
  - **Lines with log messages:** Update the strings in the logging calls to be dynamic instead of hardcoding `'incident'`.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-04T17:36:31Z
author_association: NONE
diff_hunk: @@ -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>
+  );
+}
path: keep-ui/features/filter/pagination.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Fix potential edge case in pagination navigation buttons**

There's a potential issue when there are zero pages (when totalCount is 0).


```diff
  <Button
    className="pagination-button"
    icon={ChevronRightIcon}
    onClick={() => setPageIndex(pageIndex + 1)}
-   disabled={pageIndex == pagesCount - 1}
+   disabled={pagesCount === 0 || pageIndex >= pagesCount - 1}
    size="xs"
    color="gray"
    variant="secondary"
  />
  <Button
    className="pagination-button"
    icon={ChevronDoubleRightIcon}
    onClick={() => setPageIndex(pagesCount - 1)}
-   disabled={pageIndex == pagesCount - 1}
+   disabled={pagesCount === 0 || pageIndex >= pagesCount - 1}
    size="xs"
    color="gray"
    variant="secondary"
  />
```

Also, for consistency and type safety, use the strict equality operator (`===`) instead of loose equality (`==`):

```diff
  <Button
    className="pagination-button"
    icon={ChevronDoubleLeftIcon}
    onClick={() => setPageIndex(0)}
-   disabled={pageIndex == 0}
+   disabled={pageIndex === 0}
    size="xs"
    color="gray"
    variant="secondary"
  />
  <Button
    className="pagination-button"
    icon={ChevronLeftIcon}
    onClick={() => setPageIndex(pageIndex - 1)}
-   disabled={pageIndex == 0}
+   disabled={pageIndex === 0}
    size="xs"
    color="gray"
    variant="secondary"
  />
```


<!-- 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
  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={pagesCount === 0 || pageIndex >= pagesCount - 1}
            size="xs"
            color="gray"
            variant="secondary"
          />
          <Button
            className="pagination-button"
            icon={ChevronDoubleRightIcon}
            onClick={() => setPageIndex(pagesCount - 1)}
            disabled={pagesCount === 0 || 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>
  );
}
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-04T17:36:31Z
author_association: NONE
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
user: login: coderabbitai[bot]
body: _🛠️ 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 -->
created_at: 2025-03-04T17:36:31Z
author_association: NONE
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
user: login: coderabbitai[bot]
body: _🛠️ 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 -->
created_at: 2025-03-04T17:36:31Z
author_association: NONE
diff_hunk: @@ -59,28 +59,25 @@
         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]
     ) -> str:
         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}'"
path: keep/api/core/cel_to_sql/sql_providers/sqlite.py
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Add tests for the new implementations**

The static analysis indicates that the modified lines aren't covered by tests, despite having corresponding test cases in the `cel-to-sql-test-cases.json` file.


Let's verify if the test issue is valid:

---

🏁 Script executed:

```shell
#!/bin/bash
# Check if there are tests that execute these string matching methods

# Look for test cases that would invoke these methods
echo "Searching for test invocations of string matching methods..."
rg -l "contains|startsWith|endsWith" tests/cel_to_sql/

# Check if there's test setup for SQLite provider
rg -l "CelToSqliteProvider" tests/cel_to_sql/

# Check test execution for these specific methods
rg -A 10 "_visit_contains_method_calling|_visit_starts_with_method_calling|_visit_ends_with_method_calling" tests/cel_to_sql/
```

Length of output: 441

---



</details>

**Add tests for SQLite string matching methods**

It appears that although there are tests in `tests/cel_to_sql/cel-to-sql-test-cases.json` and `tests/cel_to_sql/test_cel_to_ast.py`, none of them appears to directly exercise the SQLite provider’s methods (`_visit_contains_method_calling`, `_visit_starts_with_method_calling`, and `_visit_ends_with_method_calling`). Please add dedicated tests to verify that each of these methods produces the correct SQL string, ensuring they handle literal processing and wildcards as expected.

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

<details>
<summary>🪛 GitHub Check: codecov/patch</summary>

[warning] 62-64: keep/api/core/cel_to_sql/sql_providers/sqlite.py#L62-L64
Added lines #L62 - L64 were not covered by tests

---

[warning] 71-73: keep/api/core/cel_to_sql/sql_providers/sqlite.py#L71-L73
Added lines #L71 - L73 were not covered by tests

---

[warning] 81-83: keep/api/core/cel_to_sql/sql_providers/sqlite.py#L81-L83
Added lines #L81 - L83 were not covered by tests

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-04T17:36:31Z
author_association: NONE
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
user: login: coderabbitai[bot]
body: _⚠️ 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 '@components/ui' has no exported member 'EmptyStateCard'.

</details>

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-03-04T17:36:32Z
author_association: NONE
diff_hunk: @@ -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()]))
path: keep/api/core/workflows.py
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Use 'key in dict' instead of 'key in dict.keys()' (Ruff SIM118).**

```diff
-if facet_id not in facet_options_query.facet_queries.keys():
+if facet_id not in facet_options_query.facet_queries:
```


> Committable suggestion skipped: line range outside the PR's diff.

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

<details>
<summary>🪛 Ruff (0.8.2)</summary>

312-312: Use `key in dict` instead of `key in dict.keys()`

Remove `.keys()`

(SIM118)

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-04T17:36:32Z
author_association: NONE
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
user: login: Kiryous
body: @skynetigor 
created_at: 2025-03-04T17:40:40Z
author_association: CONTRIBUTOR
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
user: login: coderabbitai[bot]
body: > Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

<!-- This is an auto-generated mute comment by CodeRabbit -->
created_at: 2025-03-04T17:40:48Z
author_association: NONE

PR comments:
user: login: vercel[bot]
created_at: 2025-02-27T14:02:54Z
body: [vc]: #2sS8NFAHup607+LC0pC/fwDTA2XMjCBU5dhYg9VrjSE=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJrZWVwIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2tlZXBocS9rZWVwLzcyU1NodTFIMzNlaXBHR0hrWE1oemg2TTFGV2QiLCJwcmV2aWV3VXJsIjoia2VlcC1naXQtMzYyNC1maWx0ZXJpbmdzZWFyY2hzb3J0LXdvcmtmbG93cy1rZWVwaHEudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJrZWVwLWdpdC0zNjI0LWZpbHRlcmluZ3NlYXJjaHNvcnQtd29ya2Zsb3dzLWtlZXBocS52ZXJjZWwuYXBwIn0sInJvb3REaXJlY3RvcnkiOiJrZWVwLXVpIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **keep** | ✅ Ready ([Inspect](https://vercel.com/keephq/keep/72SShu1H33eipGGHkXMhzh6M1FWd)) | [Visit Preview](https://keep-git-3624-filteringsearchsort-workflows-keephq.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/keep-git-3624-filteringsearchsort-workflows-keephq.vercel.app?via=pr-comment-feedback-link) | Mar 9, 2025 8:18am |


author_association: NONE
user: login: codecov[bot]
created_at: 2025-03-04T16:56:30Z
body: ## [Codecov](https://app.codecov.io/gh/keephq/keep/pull/3740?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in) Report
Attention: Patch coverage is `28.02548%` with `113 lines` in your changes missing coverage. Please review.
> Project coverage is 47.42%. Comparing base [(`ec0bd45`)](https://app.codecov.io/gh/keephq/keep/commit/ec0bd45a2e3fc77ceddaae0a6abab9fe91ca6b5e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in) to head [(`61e7a30`)](https://app.codecov.io/gh/keephq/keep/commit/61e7a30f41ac74dff2e15162a66b9dc2b20c24f3?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in).
> Report is 58 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/keephq/keep/pull/3740?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in) | Patch % | Lines |
|---|---|---|
| [keep/api/core/workflows.py](https://app.codecov.io/gh/keephq/keep/pull/3740?src=pr&el=tree&filepath=keep%2Fapi%2Fcore%2Fworkflows.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in#diff-a2VlcC9hcGkvY29yZS93b3JrZmxvd3MucHk=) | 24.48% | [74 Missing :warning: ](https://app.codecov.io/gh/keephq/keep/pull/3740?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in) |
| [keep/api/routes/workflows.py](https://app.codecov.io/gh/keephq/keep/pull/3740?src=pr&el=tree&filepath=keep%2Fapi%2Froutes%2Fworkflows.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in#diff-a2VlcC9hcGkvcm91dGVzL3dvcmtmbG93cy5weQ==) | 30.76% | [27 Missing :warning: ](https://app.codecov.io/gh/keephq/keep/pull/3740?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in) |
| [keep/api/core/cel\_to\_sql/sql\_providers/mysql.py](https://app.codecov.io/gh/keephq/keep/pull/3740?src=pr&el=tree&filepath=keep%2Fapi%2Fcore%2Fcel_to_sql%2Fsql_providers%2Fmysql.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in#diff-a2VlcC9hcGkvY29yZS9jZWxfdG9fc3FsL3NxbF9wcm92aWRlcnMvbXlzcWwucHk=) | 0.00% | [3 Missing :warning: ](https://app.codecov.io/gh/keephq/keep/pull/3740?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in) |
| [...ep/api/core/cel\_to\_sql/sql\_providers/postgresql.py](https://app.codecov.io/gh/keephq/keep/pull/3740?src=pr&el=tree&filepath=keep%2Fapi%2Fcore%2Fcel_to_sql%2Fsql_providers%2Fpostgresql.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in#diff-a2VlcC9hcGkvY29yZS9jZWxfdG9fc3FsL3NxbF9wcm92aWRlcnMvcG9zdGdyZXNxbC5weQ==) | 0.00% | [3 Missing :warning: ](https://app.codecov.io/gh/keephq/keep/pull/3740?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in) |
| [keep/api/core/cel\_to\_sql/sql\_providers/sqlite.py](https://app.codecov.io/gh/keephq/keep/pull/3740?src=pr&el=tree&filepath=keep%2Fapi%2Fcore%2Fcel_to_sql%2Fsql_providers%2Fsqlite.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in#diff-a2VlcC9hcGkvY29yZS9jZWxfdG9fc3FsL3NxbF9wcm92aWRlcnMvc3FsaXRlLnB5) | 66.66% | [3 Missing :warning: ](https://app.codecov.io/gh/keephq/keep/pull/3740?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in) |
| [keep/workflowmanager/workflowstore.py](https://app.codecov.io/gh/keephq/keep/pull/3740?src=pr&el=tree&filepath=keep%2Fworkflowmanager%2Fworkflowstore.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in#diff-a2VlcC93b3JrZmxvd21hbmFnZXIvd29ya2Zsb3dzdG9yZS5weQ==) | 40.00% | [3 Missing :warning: ](https://app.codecov.io/gh/keephq/keep/pull/3740?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##             main    #3740       +/-   ##
===========================================
+ Coverage   31.25%   47.42%   +16.17%     
===========================================
  Files          70      157       +87     
  Lines        8307    15525     +7218     
===========================================
+ Hits         2596     7363     +4767     
- Misses       5711     8162     +2451     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/keephq/keep/pull/3740?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=palad-in).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- 📦 [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: Kiryous
created_at: 2025-03-04T17:14:46Z
body: @skynetigor hey! it seems the vercel build is failing because I removed the legacy <EmptyStateCard /> and replace it with new exported from `@/shared/ui`, which has slightly different props.
author_association: CONTRIBUTOR
user: login: Kiryous
created_at: 2025-03-04T17:30:05Z
body: @coderabbitai review
author_association: CONTRIBUTOR
user: login: coderabbitai[bot]
created_at: 2025-03-04T17:36:28Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

<details>
<summary>📝 Walkthrough</summary>

## Walkthrough
This pull request refactors both client-side and API components across the project. The client components now incorporate asynchronous data fetching, enhanced filtering, searching, and pagination features with associated UI components and hook modifications. Server-side changes update SQL providers for case-insensitive matching, remove deprecated database functions, and introduce a new workflows module with improved CRUD and query endpoints. Test cases have been updated to reflect these SQL operator changes.

## Changes

| File(s) | Change Summary |
|---------|----------------|
| `keep-ui/app/(keep)/workflows/page.tsx`<br>`keep-ui/app/(keep)/workflows/workflows.client.tsx`<br>`keep-ui/features/filter/index.ts`<br>`keep-ui/features/filter/pagination.tsx`<br>`keep-ui/features/filter/search-input.tsx`<br>`keep-ui/utils/hooks/useWorkflows*.ts` | Converted the Page component to asynchronous data fetching with initial facets and enhanced the WorkflowsPage with advanced filtering, search, pagination, and added new UI components and hooks for structured workflow data and dynamic querying. |
| `keep/api/core/cel_to_sql/sql_providers/*`<br>`keep/api/core/db.py`<br>`keep/api/core/workflows.py`<br>`keep/api/routes/facets.py`<br>`keep/api/routes/workflows.py`<br>`keep/workflowmanager/workflowstore.py` | Updated SQL providers for case-insensitive matching (using COLLATE, ILIKE, and LIKE). Removed a legacy DB function and introduced a new workflows module with CRUD operations, new API endpoints for facets and queries, and an updated workflow store method. |
| `tests/cel_to_sql/cel-to-sql-test-cases.json` | Modified SQL pattern expectations for SQLite, MySQL, and PostgreSQL to reflect updated operators and collation behavior. |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant Browser
    participant ServerPage
    participant API
    Browser->>ServerPage: Request workflows page
    ServerPage->>API: createServerApiClient() & getInitialFacets("workflows")
    API-->>ServerPage: Return initialFacets data
    ServerPage->>Browser: Render WorkflowsPage with initialFacetsData
```

```mermaid
sequenceDiagram
    participant Client
    participant APIRoute
    participant Database
    Client->>APIRoute: POST /query with QueryDto
    APIRoute->>Database: Call query_workflows(...)
    Database-->>APIRoute: Return workflows & total count
    APIRoute->>Client: Respond with workflows, count, limit, offset
```

## Possibly related PRs
- **keephq/keep#3015**: Addresses similar asynchronous modifications to the Page function and enhances initial data fetching, impacting components reliant on fetched data.

## Suggested labels
`UI`, `lgtm`

## Suggested reviewers
- talboren

## Poem
> I'm Bunny, hopping through the code maze,  
> Async leaps and facets set my heart ablaze.  
> Filters and queries, I nibble with delight,  
> Each commit a nibble, each change a sparkling bite.  
> In this garden of code, my whiskers twitch with glee,  
> Celebrating fresh features—oh, what a spree!  
> 🐰 Happy coding from me to thee!

</details>

<!-- walkthrough_end -->
<!-- internal state start -->


<!-- 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= -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details>
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media?

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE