Repository: jenkinsci/pipeline-graph-view-plugin
Pull request #648: Add a filter bar allowing users to filter by name and status
Author: login: janfaracik
Labels: name: enhancement
description: New feature or request
Created: 2025-04-24T17:07:05Z | Merged: 2025-04-25T13:41:07Z

https://github.com/user-attachments/assets/21f9db11-9713-44c4-9758-3039102f4f6a

Adds a _filter bar_ to the console view, allowing users to filter stages by name and their status.

Partially closes https://github.com/jenkinsci/pipeline-graph-view-plugin/issues/20

https://github.com/jenkinsci/pipeline-graph-view-plugin/issues/303#issuecomment-1776019857

### Testing done

* Filters stages and substages

### Submitter checklist
- [ ] Make sure you are opening from a **topic/feature/bugfix branch** (right side) and not your main branch!
- [ ] Ensure that the pull request title represents the desired changelog entry
- [ ] Please describe what you did
- [ ] Link to relevant issues in GitHub or Jira
- [ ] Link to relevant pull requests, esp. upstream and downstream changes
- [ ] Ensure you have provided tests - that demonstrates feature works or fixes the issue

<!--
Put an `x` into the [ ] to show you have filled the information.
The template comes from https://github.com/jenkinsci/.github/blob/master/.github/pull_request_template.md 
You can override it by creating .github/pull_request_template.md in your own repository 
-->



Diff:
diff --git a/src/main/frontend/common/components/dropdown.tsx b/src/main/frontend/common/components/dropdown.tsx
index a9aee823c..2d424ee57 100644
--- a/src/main/frontend/common/components/dropdown.tsx
+++ b/src/main/frontend/common/components/dropdown.tsx
@@ -1,4 +1,4 @@
-import Tippy from "@tippyjs/react";
+import Tippy, { TippyProps } from "@tippyjs/react";
 import React, { useState } from "react";
 import Tooltip from "./tooltip";
 
@@ -13,16 +13,9 @@ export default function Dropdown({ items, disabled }: DropdownProps) {
   return (
     <Tooltip content={"More actions"}>
       <Tippy
-        theme="dropdown"
-        duration={250}
-        touch={true}
         visible={visible}
-        animation="dropdown"
         onClickOutside={hide}
-        interactive={true}
-        offset={[0, 0]}
-        placement="bottom-start"
-        arrow={false}
+        {...DefaultDropdownProps}
         content={
           <div className="jenkins-dropdown">
             {items.map((item, index) => (
@@ -57,6 +50,17 @@ export default function Dropdown({ items, disabled }: DropdownProps) {
   );
 }
 
+export const DefaultDropdownProps: TippyProps = {
+  theme: "dropdown",
+  duration: 250,
+  touch: true,
+  animation: "dropdown",
+  interactive: true,
+  offset: [0, 0],
+  placement: "bottom-start",
+  arrow: false,
+};
+
 interface DropdownProps {
   items: DropdownItem[];
   disabled?: boolean;
diff --git a/src/main/frontend/common/components/filter.scss b/src/main/frontend/common/components/filter.scss
new file mode 100644
index 000000000..84c5a86f9
--- /dev/null
+++ b/src/main/frontend/common/components/filter.scss
@@ -0,0 +1,28 @@
+.pgv-filter-button {
+  transition: var(--standard-transition);
+
+  svg {
+    width: 1.375rem;
+    height: 1.375rem;
+  }
+
+  &--unchecked {
+    opacity: 0.75;
+    color: var(--text-color-secondary) !important;
+  }
+}
+
+.jenkins-dropdown__heading {
+  display: grid;
+  grid-template-columns: 1fr auto;
+}
+
+.pgv-reset-button {
+  padding: 0;
+  min-height: 0;
+
+  &::before,
+  &::after {
+    display: none;
+  }
+}
diff --git a/src/main/frontend/common/components/filter.tsx b/src/main/frontend/common/components/filter.tsx
new file mode 100644
index 000000000..386ffd68a
--- /dev/null
+++ b/src/main/frontend/common/components/filter.tsx
@@ -0,0 +1,140 @@
+import Tippy from "@tippyjs/react";
+import React, { useState } from "react";
+import Tooltip from "./tooltip";
+import StatusIcon from "./status-icon";
+import { Result } from "../../pipeline-graph-view/pipeline-graph/main";
+import { classNames } from "../utils/classnames";
+import "./filter.scss";
+import { useFilter } from "../../pipeline-console-view/pipeline-console/main/providers/filter-provider";
+import { DefaultDropdownProps } from "./dropdown";
+
+export default function Filter({ disabled }: FilterProps) {
+  const [visible, setVisible] = useState(false);
+  const { visibleStatuses, toggleStatus, resetStatuses, allVisible } =
+    useFilter();
+
+  const statuses = [
+    {
+      key: "running",
+      text: "Running",
+      status: Result.running,
+    },
+    {
+      key: "success",
+      text: "Successful",
+      status: Result.success,
+    },
+    {
+      key: "failure",
+      text: "Failed",
+      status: Result.failure,
+    },
+    {
+      key: "unstable",
+      text: "Unstable",
+      status: Result.unstable,
+    },
+    {
+      key: "aborted",
+      text: "Aborted",
+      status: Result.aborted,
+    },
+    {
+      key: "skipped",
+      text: "Skipped",
+      status: Result.skipped,
+    },
+    {
+      key: "not_built",
+      text: "Not built",
+      status: Result.not_built,
+    },
+  ];
+
+  const show = () => setVisible(true);
+  const hide = () => setVisible(false);
+
+  return (
+    <Tooltip content={"Filter"}>
+      <Tippy
+        {...DefaultDropdownProps}
+        visible={visible}
+        onClickOutside={hide}
+        placement="bottom"
+        appendTo={document.body}
+        offset={[0, -1]}
+        content={
+          <div className="jenkins-dropdown">
+            <div className="jenkins-dropdown__heading">
+              Filter
+              {!allVisible && (
+                <button
+                  className={
+                    "jenkins-button jenkins-button--tertiary jenkins-!-accent-color pgv-reset-button"
+                  }
+                  onClick={resetStatuses}
+                >
+                  Reset
+                </button>
+              )}
+            </div>
+            {statuses.map((item, index) => (
+              <button
+                key={index}
+                className={classNames(
+                  "jenkins-dropdown__item",
+                  "pgv-filter-button",
+                  {
+                    "pgv-filter-button--unchecked": !visibleStatuses.includes(
+                      item.status,
+                    ),
+                  },
+                )}
+                onClick={() => toggleStatus(item.status)}
+              >
+                <div className="jenkins-dropdown__item__icon">
+                  <StatusIcon
+                    status={item.status}
+                    skeleton={!visibleStatuses.includes(item.status)}
+                    percentage={0}
+                  />
+                </div>
+                {item.text}
+              </button>
+            ))}
+          </div>
+        }
+      >
+        <button
+          className={classNames(
+            "jenkins-button",
+            {
+              "jenkins-button--tertiary": allVisible,
+            },
+            {
+              "jenkins-!-accent-color": !allVisible,
+            },
+          )}
+          type="button"
+          disabled={disabled}
+          onClick={visible ? hide : show}
+        >
+          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+            <path
+              fill="none"
+              stroke="currentColor"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={!allVisible ? 42 : 36}
+              d="M32 144h448M112 256h288M208 368h96"
+            />
+          </svg>
+        </button>
+      </Tippy>
+    </Tooltip>
+  );
+}
+
+interface FilterProps {
+  disabled?: boolean;
+}
diff --git a/src/main/frontend/pipeline-console-view/app.tsx b/src/main/frontend/pipeline-console-view/app.tsx
index 93b304ecd..f8073be19 100644
--- a/src/main/frontend/pipeline-console-view/app.tsx
+++ b/src/main/frontend/pipeline-console-view/app.tsx
@@ -1,6 +1,7 @@
 import React, { lazy } from "react";
 
 import { I18NProvider } from "../common/i18n/i18n-provider";
+import { FilterProvider } from "./pipeline-console/main/providers/filter-provider";
 import { ResourceBundleName } from "../common/i18n/translations";
 
 const PipelineConsole = lazy(
@@ -16,7 +17,9 @@ export default function App() {
       bundles={[ResourceBundleName.messages, ResourceBundleName.run]}
       locale={locale}
     >
-      <PipelineConsole />
+      <FilterProvider>
+        <PipelineConsole />
+      </FilterProvider>
     </I18NProvider>
   );
 }
diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/ConsoleLogCard.tsx b/src/main/frontend/pipeline-console-view/pipeline-console/main/ConsoleLogCard.tsx
index 42203d50f..2d95360ee 100644
--- a/src/main/frontend/pipeline-console-view/pipeline-console/main/ConsoleLogCard.tsx
+++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/ConsoleLogCard.tsx
@@ -111,6 +111,7 @@ export default function ConsoleLogCard(props: ConsoleLogCardProps) {
             <span
               style={{
                 color: "var(--text-color-secondary)",
+                fontWeight: "normal",
               }}
             >
               <Total ms={props.step.totalDurationMillis} />
diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/DataTreeView.tsx b/src/main/frontend/pipeline-console-view/pipeline-console/main/DataTreeView.tsx
index 666ea16ba..22b9634c9 100644
--- a/src/main/frontend/pipeline-console-view/pipeline-console/main/DataTreeView.tsx
+++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/DataTreeView.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useState } from "react";
+import React, { useCallback, useEffect, useState } from "react";
 import {
   Result,
   StageInfo,
@@ -7,12 +7,17 @@ import "./data-tree-view.scss";
 import { Total } from "../../../common/utils/timings";
 import StatusIcon from "../../../common/components/status-icon";
 import { classNames } from "../../../common/utils/classnames";
+import Filter from "../../../common/components/filter";
+import { useFilter } from "./providers/filter-provider";
 
 export default function DataTreeView({
   stages,
   selected,
   onNodeSelect,
 }: DataTreeViewProps) {
+  const { search, setSearch, visibleStatuses } = useFilter();
+  const filteredStages = filterStageTree(search, visibleStatuses, stages);
+
   const handleSelect = useCallback(
     (event: React.MouseEvent, nodeId: string) => {
       onNodeSelect(event, nodeId);
@@ -21,20 +26,75 @@ export default function DataTreeView({
   );
 
   return (
-    <div id="tasks">
-      {stages.map((stage) => (
-        <TreeNode
-          key={stage.id}
-          stage={stage}
-          selected={String(selected)}
-          onSelect={handleSelect}
-        />
-      ))}
+    <div>
+      <div className={"pgw-filter-bar"}>
+        <div className="jenkins-search">
+          <div className="jenkins-search__icon">
+            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+              <path
+                d="M221.09 64a157.09 157.09 0 10157.09 157.09A157.1 157.1 0 00221.09 64z"
+                fill="none"
+                stroke="currentColor"
+                stroke-miterlimit="10"
+                stroke-width="32"
+              />
+              <path
+                fill="none"
+                stroke="currentColor"
+                stroke-linecap="round"
+                stroke-miterlimit="10"
+                stroke-width="32"
+                d="M338.29 338.29L448 448"
+              />
+            </svg>
+          </div>
+          <input
+            className="jenkins-input jenkins-search__input"
+            placeholder="Search"
+            type="search"
+            autoComplete="off"
+            autoCorrect="off"
+            autoCapitalize="off"
+            spellCheck="false"
+            value={search}
+            onChange={(e) => setSearch(e.target.value)}
+          />
+        </div>
+        <Filter />
+      </div>
+
+      {filteredStages.length === 0 && (
+        <div className={"jenkins-notice"}>
+          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+            <path
+              fill="none"
+              stroke="currentColor"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={36}
+              d="M32 144h448M112 256h288M208 368h96"
+            />
+          </svg>
+          <div>No stages</div>
+        </div>
+      )}
+
+      <div id="tasks" style={{ marginLeft: "0.7rem" }}>
+        {filteredStages.map((stage) => (
+          <TreeNode
+            key={stage.id}
+            stage={stage}
+            selected={String(selected)}
+            onSelect={handleSelect}
+          />
+        ))}
+      </div>
     </div>
   );
 }
 
 const TreeNode = React.memo(({ stage, selected, onSelect }: TreeNodeProps) => {
+  const { search, visibleStatuses, allVisible } = useFilter();
   const hasChildren = stage.children && stage.children.length > 0;
   const isSelected = String(stage.id) === selected;
   const [isExpanded, setIsExpanded] = useState<boolean>(
@@ -52,6 +112,14 @@ const TreeNode = React.memo(({ stage, selected, onSelect }: TreeNodeProps) => {
     setIsExpanded(!isExpanded);
   };
 
+  useEffect(() => {
+    if (search.length || !allVisible) {
+      if (filterStageTree(search, visibleStatuses, [stage]).length !== 0) {
+        setIsExpanded(true);
+      }
+    }
+  }, [search, visibleStatuses, allVisible]);
+
   return (
     <div className="task">
       <div className="pgv-tree-node-header">
@@ -131,6 +199,34 @@ const TreeNode = React.memo(({ stage, selected, onSelect }: TreeNodeProps) => {
   );
 });
 
+const filterStageTree = (
+  search: string,
+  visibleStatuses: Result[],
+  stages: StageInfo[],
+): StageInfo[] => {
+  return stages
+    .map((stage) => {
+      const filteredChildren = stage.children
+        ? filterStageTree(search, visibleStatuses, stage.children)
+        : [];
+
+      const matchesSelf =
+        stage.name.toLowerCase().includes(search.toLowerCase()) &&
+        visibleStatuses.includes(stage.state);
+
+      // Include this stage if it matches or has matching children
+      if (matchesSelf || filteredChildren.length > 0) {
+        return {
+          ...stage,
+          children: filteredChildren,
+        };
+      }
+
+      return null;
+    })
+    .filter((stage) => stage !== null);
+};
+
 interface DataTreeViewProps {
   stages: StageInfo[];
   selected?: number;
diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/console-log-card.scss b/src/main/frontend/pipeline-console-view/pipeline-console/main/console-log-card.scss
index b078af29f..eb4b175ab 100644
--- a/src/main/frontend/pipeline-console-view/pipeline-console/main/console-log-card.scss
+++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/console-log-card.scss
@@ -28,7 +28,7 @@
     gap: 1rem;
     text-decoration: none;
     color: var(--text-color);
-    min-height: 38px;
+    min-height: 2.5rem;
   }
 
   .jenkins-button {
diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/data-tree-view.scss b/src/main/frontend/pipeline-console-view/pipeline-console/main/data-tree-view.scss
index 5f31240da..3808103ce 100644
--- a/src/main/frontend/pipeline-console-view/pipeline-console/main/data-tree-view.scss
+++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/data-tree-view.scss
@@ -3,10 +3,66 @@
   padding-right: 0.7rem;
 }
 
+.pgw-filter-bar {
+  position: relative;
+  display: grid;
+  grid-template-columns: 1fr auto;
+  margin-bottom: 0.75rem;
+
+  &::before {
+    content: "";
+    position: absolute;
+    inset: 0;
+    border-radius: var(--form-input-border-radius);
+    // TODO - var fallback can removed after baseline is moved >= 2.496
+    border: var(
+      --jenkins-border,
+      2px solid color-mix(in srgb, var(--text-color-secondary) 10%, transparent)
+    );
+    box-shadow: var(--form-input-glow);
+    transition: var(--standard-transition);
+  }
+
+  button {
+    border-radius: 0.4rem;
+    padding-inline: 0;
+    width: 42px;
+
+    &::before,
+    &::after {
+      inset: 5px;
+    }
+  }
+
+  .jenkins-search {
+    --search-bar-height: 2.625rem;
+
+    input {
+      border: none !important;
+      box-shadow: none !important;
+      background: none !important;
+      padding: 0 0 0 var(--search-bar-height);
+    }
+  }
+
+  &:hover {
+    &::before {
+      border-color: var(--input-border-hover);
+    }
+  }
+
+  &:has(input:focus) {
+    &::before {
+      border-color: var(--focus-input-border);
+      box-shadow: var(--form-input-glow--focus);
+    }
+  }
+}
+
 .pgv-tree-item {
   display: block !important;
   font-family: var(--font-family-sans) !important;
-  padding-block: 0.375rem !important;
+  padding-block: 0.5rem !important;
   padding-right: 1rem;
   cursor: pointer !important;
 
@@ -45,7 +101,7 @@
 
 .pgv-toggle-icon {
   position: absolute;
-  top: 0.55rem;
+  top: 0.6875rem;
   right: 0.55rem;
   width: 1rem;
   height: 1rem;
@@ -106,6 +162,6 @@
     );
     // TODO - var fallback can removed after baseline is moved >= 2.496
     width: var(--jenkins-border-width, 2px);
-    border-radius: 10px;
+    border-radius: var(--form-input-border-radius);
   }
 }
diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/providers/filter-provider.tsx b/src/main/frontend/pipeline-console-view/pipeline-console/main/providers/filter-provider.tsx
new file mode 100644
index 000000000..bfb6cb25d
--- /dev/null
+++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/providers/filter-provider.tsx
@@ -0,0 +1,66 @@
+import React, { createContext, ReactNode, useContext, useState } from "react";
+import { Result } from "../../../../pipeline-graph-view/pipeline-graph/main";
+
+interface FilterContextType {
+  visibleStatuses: Result[];
+  toggleStatus: (key: Result) => void;
+  resetStatuses: () => void;
+  allVisible: boolean;
+  search: string;
+  setSearch: (value: string) => void;
+}
+
+const FilterContext = createContext<FilterContextType | undefined>(undefined);
+
+const defaultStatuses: Result[] = [
+  Result.running,
+  Result.success,
+  Result.failure,
+  Result.unstable,
+  Result.aborted,
+  Result.skipped,
+  Result.not_built,
+];
+
+export const FilterProvider = ({ children }: { children: ReactNode }) => {
+  const [visibleStatuses, setVisibleStatuses] = useState<Result[]>([]);
+  const [search, setSearch] = useState("");
+
+  const toggleStatus = (key: Result) => {
+    if (visibleStatuses.includes(key as Result)) {
+      setVisibleStatuses(visibleStatuses.filter((s) => s !== (key as Result)));
+    } else {
+      setVisibleStatuses([...visibleStatuses, key as Result]);
+    }
+  };
+
+  const resetStatuses = () => {
+    setVisibleStatuses([]);
+  };
+
+  return (
+    <FilterContext.Provider
+      value={{
+        visibleStatuses:
+          visibleStatuses.length > 0 ? visibleStatuses : defaultStatuses,
+        toggleStatus,
+        resetStatuses,
+        allVisible:
+          (visibleStatuses.length > 0 ? visibleStatuses : defaultStatuses)
+            .length === defaultStatuses.length,
+        search,
+        setSearch,
+      }}
+    >
+      {children}
+    </FilterContext.Provider>
+  );
+};
+
+export const useFilter = (): FilterContextType => {
+  const context = useContext(FilterContext);
+  if (!context) {
+    throw new Error("useFilter must be used within a FilterProvider");
+  }
+  return context;
+};
diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/stage-details.scss b/src/main/frontend/pipeline-console-view/pipeline-console/main/stage-details.scss
index 73cc60260..635d909d8 100644
--- a/src/main/frontend/pipeline-console-view/pipeline-console/main/stage-details.scss
+++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/stage-details.scss
@@ -6,10 +6,10 @@
   grid-template-columns: 1fr auto;
   gap: 0.5rem;
   // TODO - var fallback can removed after baseline is moved >= 2.496
-  padding: 0.375rem calc(1rem + var(--jenkins-border-width, 2px));
-  padding-right: 0.85rem;
+  padding: 0.125rem calc(1rem + var(--jenkins-border-width, 2px));
+  padding-right: 0.7rem;
   border-radius: var(--form-input-border-radius);
-  margin-bottom: 0.5rem;
+  margin-bottom: 0.75rem;
   z-index: 101;
 
   &::before {
@@ -45,8 +45,8 @@
   }
 
   h2 {
-    font-size: var(--font-size-base);
-    font-weight: 450;
+    font-size: var(--font-size-sm);
+    font-weight: var(--font-bold-weight);
     margin: 0;
     text-box: trim-both;
   }
@@ -57,12 +57,13 @@
     --button-box-shadow--focus: oklch(from currentColor l c h / 0.1);
 
     color: var(--color) !important;
-    border-radius: 0.5rem;
+    border-radius: 0.4rem;
     margin-inline: -0.85rem;
+    padding-inline: 1.125rem;
 
     &::before,
     &::after {
-      inset: 2px 7px;
+      inset: 3px 7px;
     }
   }
 


Reviews:
user: login: lewisbirks
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T10:09:13Z
user: login: lewisbirks
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T11:04:53Z
user: login: janfaracik
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T13:17:52Z
user: login: timja
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-25T13:37:43Z

Review comments:
diff_hunk: @@ -0,0 +1,144 @@
+import Tippy from "@tippyjs/react";
+import React, { useState } from "react";
+import Tooltip from "./tooltip";
+import StatusIcon from "./status-icon";
+import { Result } from "../../pipeline-graph-view/pipeline-graph/main";
+import { classNames } from "../utils/classnames";
+import "./filter.scss";
+import { useFilter } from "../../pipeline-console-view/pipeline-console/main/providers/filter-provider";
+
+export default function Filter({ disabled }: FilterProps) {
+  const [visible, setVisible] = useState(false);
+  const { visibleStatuses, toggleStatus, resetStatuses, allVisible } =
+    useFilter();
+
+  const statuses = [
+    {
+      key: "running",
+      text: "Running",
+      status: Result.running,
+    },
+    {
+      key: "success",
+      text: "Successful",
+      status: Result.success,
+    },
+    {
+      key: "failure",
+      text: "Failed",
+      status: Result.failure,
+    },
+    {
+      key: "unstable",
+      text: "Unstable",
+      status: Result.unstable,
+    },
+    {
+      key: "aborted",
+      text: "Aborted",
+      status: Result.aborted,
+    },
+    {
+      key: "skipped",
+      text: "Skipped",
+      status: Result.skipped,
+    },
+    {
+      key: "not_built",
+      text: "Not built",
+      status: Result.not_built,
+    },
+  ];
+
+  const show = () => setVisible(true);
+  const hide = () => setVisible(false);
+
+  return (
+    <Tooltip content={"Filter"}>
+      <Tippy
+        theme="dropdown"
path: src/main/frontend/common/components/filter.tsx
user: login: lewisbirks
body: should this make use of the dropdown component?
created_at: 2025-04-25T09:57:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7,12 +7,41 @@ import "./data-tree-view.scss";
 import { Total } from "../../../common/utils/timings";
 import StatusIcon from "../../../common/components/status-icon";
 import { classNames } from "../../../common/utils/classnames";
+import Filter from "../../../common/components/filter";
+import { useFilter } from "./providers/filter-provider";
 
 export default function DataTreeView({
   stages,
   selected,
   onNodeSelect,
 }: DataTreeViewProps) {
+  const { search, setSearch, visibleStatuses } = useFilter();
+  const filterStageTree = (stages: StageInfo[]): StageInfo[] => {
+    return stages
+      .map((stage) => {
+        const filteredChildren = stage.children
+          ? filterStageTree(stage.children)
+          : [];
+
+        const matchesSelf =
+          stage.name.toLowerCase().includes(search.toLowerCase()) &&
+          visibleStatuses.includes(stage.state);
+
+        // Include this stage if it matches or has matching children
+        if (matchesSelf || filteredChildren.length > 0) {
+          return {
+            ...stage,
+            children: filteredChildren,
+          };
+        }
+
+        return null;
+      })
+      .filter((stage): stage is StageInfo => stage !== null);
path: src/main/frontend/pipeline-console-view/pipeline-console/main/DataTreeView.tsx
user: login: lewisbirks
body: Thoughts on
```suggestion
        if (matchesSelf || filteredChildren.length > 0) {
          return {
            ...stage,
            children: filteredChildren,
          } as StageInfo;
        }

        return null;
      })
      .filter((stage) => stage !== null);
```
created_at: 2025-04-25T10:06:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,144 @@
+import Tippy from "@tippyjs/react";
+import React, { useState } from "react";
+import Tooltip from "./tooltip";
+import StatusIcon from "./status-icon";
+import { Result } from "../../pipeline-graph-view/pipeline-graph/main";
+import { classNames } from "../utils/classnames";
+import "./filter.scss";
+import { useFilter } from "../../pipeline-console-view/pipeline-console/main/providers/filter-provider";
+
+export default function Filter({ disabled }: FilterProps) {
+  const [visible, setVisible] = useState(false);
+  const { visibleStatuses, toggleStatus, resetStatuses, allVisible } =
+    useFilter();
+
+  const statuses = [
+    {
+      key: "running",
+      text: "Running",
+      status: Result.running,
+    },
+    {
+      key: "success",
+      text: "Successful",
+      status: Result.success,
+    },
+    {
+      key: "failure",
+      text: "Failed",
+      status: Result.failure,
+    },
+    {
+      key: "unstable",
+      text: "Unstable",
+      status: Result.unstable,
+    },
+    {
+      key: "aborted",
+      text: "Aborted",
+      status: Result.aborted,
+    },
+    {
+      key: "skipped",
+      text: "Skipped",
+      status: Result.skipped,
+    },
+    {
+      key: "not_built",
+      text: "Not built",
+      status: Result.not_built,
+    },
+  ];
+
+  const show = () => setVisible(true);
+  const hide = () => setVisible(false);
+
+  return (
+    <Tooltip content={"Filter"}>
+      <Tippy
+        theme="dropdown"
path: src/main/frontend/common/components/filter.tsx
user: login: lewisbirks
body: or maybe make some standard props in the dropdown file which can at least be reused
created_at: 2025-04-25T11:04:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,144 @@
+import Tippy from "@tippyjs/react";
+import React, { useState } from "react";
+import Tooltip from "./tooltip";
+import StatusIcon from "./status-icon";
+import { Result } from "../../pipeline-graph-view/pipeline-graph/main";
+import { classNames } from "../utils/classnames";
+import "./filter.scss";
+import { useFilter } from "../../pipeline-console-view/pipeline-console/main/providers/filter-provider";
+
+export default function Filter({ disabled }: FilterProps) {
+  const [visible, setVisible] = useState(false);
+  const { visibleStatuses, toggleStatus, resetStatuses, allVisible } =
+    useFilter();
+
+  const statuses = [
+    {
+      key: "running",
+      text: "Running",
+      status: Result.running,
+    },
+    {
+      key: "success",
+      text: "Successful",
+      status: Result.success,
+    },
+    {
+      key: "failure",
+      text: "Failed",
+      status: Result.failure,
+    },
+    {
+      key: "unstable",
+      text: "Unstable",
+      status: Result.unstable,
+    },
+    {
+      key: "aborted",
+      text: "Aborted",
+      status: Result.aborted,
+    },
+    {
+      key: "skipped",
+      text: "Skipped",
+      status: Result.skipped,
+    },
+    {
+      key: "not_built",
+      text: "Not built",
+      status: Result.not_built,
+    },
+  ];
+
+  const show = () => setVisible(true);
+  const hide = () => setVisible(false);
+
+  return (
+    <Tooltip content={"Filter"}>
+      <Tippy
+        theme="dropdown"
path: src/main/frontend/common/components/filter.tsx
user: login: janfaracik
body: Now reusing the same props from `Dropdown` - I think the components should be separate however.
created_at: 2025-04-25T13:17:52Z
author_association: MEMBER

PR comments:
user: login: timja
created_at: 2025-04-25T09:41:05Z
body: You’ve got a conflict fyi
author_association: MEMBER
user: login: timja
created_at: 2025-04-25T12:30:28Z
body: Would be nice if matches were expanded:

<img width="353" alt="image" src="https://github.com/user-attachments/assets/b407f3bc-ad76-45b9-b979-d5875fbd5e82" />

underneath each of those is the sub stage.
author_association: MEMBER
user: login: janfaracik
created_at: 2025-04-25T13:14:19Z
body: > Would be nice if matches were expanded:
> 
> <img alt="image" width="353" src="https://private-user-images.githubusercontent.com/21194782/437440336-b407f3bc-ad76-45b9-b979-d5875fbd5e82.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDU1ODcxNDksIm5iZiI6MTc0NTU4Njg0OSwicGF0aCI6Ii8yMTE5NDc4Mi80Mzc0NDAzMzYtYjQwN2YzYmMtYWQ3Ni00NWI5LWI5NzktZDU4NzVmYmQ1ZTgyLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTA0MjUlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwNDI1VDEzMTQwOVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTM0MWQ3YjEwM2MxMGY4NTI5NDk0YzY3ZWI5YmIyNmJmZmZiOGI3OTk5Mjc2MGRmM2Y0YWI1Y2I4MWVkY2VhNmEmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.riUkbdOueRrGOOutqBAY3U90hYVwg0exlw2N-RcBwjI">
> underneath each of those is the sub stage.

Good idea, done.
author_association: MEMBER