Repository: kestra-io/kestra
Issue: Saved search filters
Labels: backend

### Feature description

The UI already features rich ways of filtering the displayed content - mainly the Executions and Task Runs UI views. Additionally, there are more filtering capabilities, like the #1308, to be added in the future.

Setting the desired filters manually can be tiresome for repeated tasks.

Operations teams using those UI views would likely need to use several _pre-set filters_ to perform their tasks effectively. Common use cases include:

- List Executions being in the Failed/Warning state in the past 1 hour.
- List Executions being in any state filtered by a Execution Label (eg. only Executions labeled as "pre-production").
- List Executions of a selected Flow being in the Paused state in the past 24 hours.

This could be achieved by treating the filters as a first class object.

1. The **initial version** of this functionality might treat the filters as _user-specific_ and save them to a _local storage_.
1. A follow-up **advanced version** would allow _sharing the filters_ via a _shared storage_ - effectively allowing the whole operations team to share common filters and still allow defining the user-specific ones.

Issue comments:
We've been thinking a bit about the initial implementation.

The most straightforward approach would be directly using the query string part from the `ui/executions` URL. At the Executions view the user could:

1. set desired filters
2. save the _current search query_ under a name ("my_past_day_errors", etc.)
3. search the saved search query by the name
4. _exec the saved search query_ again

Sadly, this approach could not handle relative time ranges ("past 1 hour", "past day") since the current query/API uses absolute time range (`startDate`-`endDate`). So this would require introducing a new search parameter such as `startDateRelativeMinutes`.

Any ideas?
I think we should first rework the way our date filter works.
As you said, relative times ranges wont work because we put absolute time ranges, and this is an issue.

So I suggest doing this one before : https://github.com/kestra-io/kestra/issues/2211
WDYT @anna-geller ?
Good catch, you're right 👍

Pull request review comments:

Patch that resolved the issue:
diff --git a/ui/src/components/executions/Executions.vue b/ui/src/components/executions/Executions.vue
index 2a49884d94..f94ebfe8fe 100644
--- a/ui/src/components/executions/Executions.vue
+++ b/ui/src/components/executions/Executions.vue
@@ -93,6 +93,9 @@
                         />
                     </el-select>
                 </el-form-item>
+                <el-form-item>
+                    <filters :storage-key="filterStorageKey" />
+                </el-form-item>
                 <el-form-item>
                     <refresh-button
                         :can-auto-refresh="canAutoRefresh"
@@ -365,6 +368,7 @@
     import LabelFilter from "../labels/LabelFilter.vue";
     import DateFilter from "./date-select/DateFilter.vue";
     import RefreshButton from "../layout/RefreshButton.vue"
+    import Filters from "../saved-filters/Filters.vue";
     import StatusFilterButtons from "../layout/StatusFilterButtons.vue"
     import StateGlobalChart from "../../components/stats/StateGlobalChart.vue";
     import TriggerAvatar from "../../components/flows/TriggerAvatar.vue";
@@ -392,6 +396,7 @@
             LabelFilter,
             DateFilter,
             RefreshButton,
+            Filters,
             StatusFilterButtons,
             StateGlobalChart,
             TriggerAvatar,
@@ -565,6 +570,9 @@
             },
             isDisplayedTop() {
                 return this.embed === false || this.filter
+            },
+            filterStorageKey(){
+                return storageKeys.EXECUTIONS_FILTERS
             }
         },
         methods: {
diff --git a/ui/src/components/flows/Flows.vue b/ui/src/components/flows/Flows.vue
index 0d2cc4d0a3..ab53b3aaf9 100644
--- a/ui/src/components/flows/Flows.vue
+++ b/ui/src/components/flows/Flows.vue
@@ -60,6 +60,9 @@
                             @update:model-value="onDataTableValue('labels', $event)"
                         />
                     </el-form-item>
+                    <el-form-item>
+                        <filters :storage-key="storageKeys.FLOWS_FILTERS" />
+                    </el-form-item>
                 </template>
 
                 <template #top>
@@ -213,6 +216,7 @@
     import TrashCan from "vue-material-design-icons/TrashCan.vue";
     import FileDocumentRemoveOutline from "vue-material-design-icons/FileDocumentRemoveOutline.vue";
     import FileDocumentCheckOutline from "vue-material-design-icons/FileDocumentCheckOutline.vue";
+    import Filters from "../saved-filters/Filters.vue";
 </script>
 
 <script>
@@ -239,6 +243,7 @@
     import Labels from "../layout/Labels.vue"
     import Upload from "vue-material-design-icons/Upload.vue";
     import LabelFilter from "../labels/LabelFilter.vue";
+    import {storageKeys} from "../../utils/constants";
 
     export default {
         mixins: [RouteContext, RestoreUrl, DataTableActions, SelectTableActions],
diff --git a/ui/src/components/logs/LogsWrapper.vue b/ui/src/components/logs/LogsWrapper.vue
index 315845736a..9dd8b83448 100644
--- a/ui/src/components/logs/LogsWrapper.vue
+++ b/ui/src/components/logs/LogsWrapper.vue
@@ -28,6 +28,11 @@
                             @update:model-value="onDataTableValue($event)"
                         />
                     </el-form-item>
+                    <el-form-item>
+                        <el-form-item>
+                            <filters :storage-key="storageKeys.LOGS_FILTERS" />
+                        </el-form-item>
+                    </el-form-item>
                     <el-form-item>
                         <refresh-button class="float-right" @refresh="refresh" />
                     </el-form-item>
@@ -92,10 +97,14 @@
     import RefreshButton from "../../components/layout/RefreshButton.vue";
     import _merge from "lodash/merge";
     import LogChart from "../stats/LogChart.vue";
+    import Filters from "../saved-filters/Filters.vue";
+    import {storageKeys} from "../../utils/constants";
 
     export default {
         mixins: [RouteContext, RestoreUrl, DataTableActions],
-        components: {DataTable, LogLine, NamespaceSelect, DateRange, SearchField, LogLevelSelector, RefreshButton, TopNavBar, LogChart},
+        components: {
+            Filters,
+            DataTable, LogLine, NamespaceSelect, DateRange, SearchField, LogLevelSelector, RefreshButton, TopNavBar, LogChart},
         props: {
             logLevel: {
                 type: String,
@@ -113,6 +122,9 @@
             };
         },
         computed: {
+            storageKeys() {
+                return storageKeys
+            },
             ...mapState("log", ["logs", "total", "level"]),
             ...mapState("stat", ["logDaily"]),
             routeInfo() {
diff --git a/ui/src/components/saved-filters/Filters.vue b/ui/src/components/saved-filters/Filters.vue
new file mode 100644
index 0000000000..124e2e7c4c
--- /dev/null
+++ b/ui/src/components/saved-filters/Filters.vue
@@ -0,0 +1,172 @@
+<template>
+    <!-- No filter yet -->
+    <el-button v-if="!hasSavedFilters" :icon="ContentSave" @click="toggleDrawer()">
+        Filters
+    </el-button>
+    <!-- Existing filters -->
+    <el-dropdown v-else button type="default">
+        <el-button class="dropdown-button" :icon="ContentSave" @click="toggleDrawer()">
+            Filters
+        </el-button>
+        <template #dropdown>
+            <el-dropdown-menu>
+                <template
+                    v-for="(query, label) in relevantFilters"
+                    :key="label"
+                >
+                    <el-dropdown-item @click="setFilter(query)" :disabled="isSelected(query)">
+                        {{ label }}
+                    </el-dropdown-item>
+                </template>
+            </el-dropdown-menu>
+        </template>
+    </el-dropdown>
+    <drawer
+        v-model="isDrawerOpen"
+        :title="$t('search filters.manage')"
+    >
+        <el-card
+            v-if="hasSavedFilters"
+            :header="$t('search filters.saved')"
+            class="w-100"
+        >
+            <saved-filter
+                v-for="(query, label) in relevantFilters"
+                :key="label"
+                :query="query"
+                :label="label"
+                @clicked="() => isDrawerOpen = false"
+                @deleted="removeSavedFilter($event)"
+            />
+
+            <template #footer>
+                <el-input
+                    v-model="labelFilter"
+                    :prefix-icon="Magnify"
+                    :maxlength="15"
+                    :placeholder="$t('search')"
+                    clearable
+                />
+            </template>
+        </el-card>
+
+
+        <el-input
+            class="py-3"
+            v-model="newFilterLabel"
+            :autofocus="true"
+            :maxlength="15"
+            :placeholder="$t('search filters.filter name')"
+            @keyup.enter="storeSavedFilters()"
+        >
+            <template #append>
+                <el-button
+                    :icon="Plus"
+                    :disabled="newFilterLabel === EMPTY_LABEL"
+                    @click="storeSavedFilters()"
+                >
+                    {{ $t("search filters.save filter") }}
+                </el-button>
+            </template>
+        </el-input>
+    </drawer>
+</template>
+
+<script setup>
+    import ContentSave from "vue-material-design-icons/ContentSave.vue";
+    import Plus from "vue-material-design-icons/Plus.vue";
+    import Magnify from "vue-material-design-icons/Magnify.vue";
+</script>
+
+<script>
+    import Drawer from "../Drawer.vue";
+    import SavedFilter from "./SavedFilter.vue";
+    import {mapGetters} from "vuex";
+    import _isEqual from "lodash/isEqual";
+
+    export default {
+        components: {
+            Drawer,
+            SavedFilter
+        },
+        props: {
+            storageKey: {
+                type: String,
+                required: true
+            }
+        },
+        data() {
+            return {
+                isDrawerOpen: false,
+                newFilterLabel: undefined,
+                labelFilter: undefined,
+                filters: {}
+            };
+        },
+        computed: {
+            ...mapGetters("filters", ["savedFilters"]),
+            relevantFilters() {
+                return Object.entries(this.filters)
+                    .filter(([key, _]) => this.labelFilter ? key.includes(this.labelFilter) : true)
+                    .sort(([a, _], [b, __]) => {
+                        const keyA = a.toLowerCase();
+                        const keyB = b.toLowerCase();
+
+                        return (keyA < keyB ? -1 : (keyA > keyB ? 1 : 0));
+                    })
+                    .reduce((acc, [key, value]) => {
+                        acc[key] = value;
+                        return acc;
+                    }, {});
+            },
+            hasSavedFilters() {
+                return Object.keys(this.filters).length > 0;
+            }
+        },
+        created() {
+            this.resetNewFilterLabel();
+            this.EMPTY_LABEL = "";
+        },
+        mounted() {
+            this.filters = this.savedFilters(this.storageKey);
+        },
+        methods: {
+            resetNewFilterLabel() {
+                this.newFilterLabel = this.EMPTY_LABEL;
+            },
+            removeSavedFilter(label) {
+                delete this.filters[label];
+                this.$store.commit("filters/setSavedFilters",
+                                   {
+                                       storageKey: this.storageKey,
+                                       filters: this.filters
+                                   });
+            },
+            storeSavedFilters() {
+                if (!this.newFilterLabel) {
+                    return;
+                }
+                this.filters[this.newFilterLabel] = this.$route.query;
+                this.$store.commit("filters/setSavedFilters",
+                                   {
+                                       storageKey: this.storageKey,
+                                       filters: this.filters
+                                   });
+                this.resetNewFilterLabel();
+            },
+            toggleDrawer() {
+                this.isDrawerOpen = !this.isDrawerOpen;
+            },
+            setFilter(query) {
+                this.$router.push({query: query})
+            },
+            isSelected(query) {
+                return _isEqual(query, this.$route.query);
+            }
+        }
+    }
+</script>
+<style>
+    .dropdown-button {
+        width: 100%}
+</style>
\ No newline at end of file
diff --git a/ui/src/components/saved-filters/SavedFilter.vue b/ui/src/components/saved-filters/SavedFilter.vue
new file mode 100644
index 0000000000..b3697c699d
--- /dev/null
+++ b/ui/src/components/saved-filters/SavedFilter.vue
@@ -0,0 +1,84 @@
+<template>
+    <el-tooltip
+        placement="bottom"
+        trigger="hover"
+        :persistent="false"
+        :show-after="750"
+        :hide-after="0"
+    >
+        <template #content>
+            <div v-for="(queryPart) in getReadableQuery()" :key="queryPart">
+                {{ queryPart }}
+            </div>
+        </template>
+        <el-tag
+            :type="isSelected() ? 'primary' : 'info'"
+            size="large"
+            @click="onClick"
+            @close="showConfirmDialog()"
+            closable
+            class="me-1"
+            disable-transitions
+        >
+            <span>{{ label }}</span>
+        </el-tag>
+    </el-tooltip>
+</template>
+
+<script>
+    import _isEqual from "lodash/isEqual";
+
+    export default {
+        emits: [
+            "clicked",
+            "deleted"
+        ],
+        props: {
+            query: {
+                type: Object,
+                required: true
+            },
+            label: {
+                type: String,
+                required: true
+            }
+        },
+        methods: {
+            showConfirmDialog() {
+                this.$toast().confirm(
+                    this.$t("delete confirm", {name: this.label}),
+                    this.onDelete,
+                    () => {
+                    }
+                );
+            },
+            onClick() {
+                this.$router.push({query: this.query})
+                this.$emit("clicked");
+            },
+            onDelete() {
+                this.$emit("deleted", this.label);
+            },
+            isSelected() {
+                return _isEqual(this.query, this.$route.query);
+            },
+            getReadableQuery() {
+                return Object.entries(this.query)
+                    .map(([key, value]) => `${key}: ${value}`);
+            }
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+    .el-tag {
+        & a, span, :deep(.el-icon) {
+            color: var(--bs-white);
+            cursor: pointer;
+        }
+
+        &.el-tag--info {
+            background: var(--bs-gray-600);
+        }
+    }
+</style>
\ No newline at end of file
diff --git a/ui/src/stores/filters.js b/ui/src/stores/filters.js
new file mode 100644
index 0000000000..3f622f8282
--- /dev/null
+++ b/ui/src/stores/filters.js
@@ -0,0 +1,18 @@
+export default {
+    namespaced: true,
+    state: {
+        lastFilters: undefined
+    },
+    mutations: {
+        setSavedFilters(state, value) {
+            localStorage.setItem(value.storageKey, JSON.stringify(value.filters));
+        }
+    },
+    getters: {
+        savedFilters() {
+            return (storageKey) => {
+                return JSON.parse(localStorage.getItem(storageKey)) ?? {}
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/ui/src/stores/store.js b/ui/src/stores/store.js
index 25cefdaae2..ad0802d082 100644
--- a/ui/src/stores/store.js
+++ b/ui/src/stores/store.js
@@ -2,6 +2,7 @@ import api from "./api"
 import auth from "./auth"
 import core from "./core"
 import execution from "./executions"
+import filters from "./filters";
 import flow from "./flow"
 import graph from "./graph"
 import layout from "./layout"
@@ -19,6 +20,7 @@ export default {
     modules: {
         api,
         core,
+        filters,
         flow,
         template,
         execution,
diff --git a/ui/src/translations.json b/ui/src/translations.json
index 28c417dabf..db21244782 100644
--- a/ui/src/translations.json
+++ b/ui/src/translations.json
@@ -100,6 +100,13 @@
     "add flow": "Add flow",
     "from": "From",
     "to": "To",
+    "search filters": {
+      "saved": "Saved filters",
+      "manage": "Manage search filters",
+      "manage desc": "Manage saved search filters",
+      "save filter": "Save filter",
+      "filter name": "Filter name"
+    },
     "steps": "Steps",
     "state": "State",
     "search term in message": "Search term in message",
diff --git a/ui/src/utils/constants.js b/ui/src/utils/constants.js
index 873cf95479..648d991b9f 100644
--- a/ui/src/utils/constants.js
+++ b/ui/src/utils/constants.js
@@ -32,9 +32,13 @@ export const storageKeys = {
     DEFAULT_NAMESPACE: "defaultNamespace",
     LATEST_NAMESPACE: "latestNamespace",
     PAGINATION_SIZE: "paginationSize",
+    EXECUTIONS_FILTERS: "executionsSavedFilters",
+    FLOWS_FILTERS: "flowsSavedFilters",
+    LOGS_FILTERS: "logsSavedFilters",
 }
 
 export const executeFlowBehaviours = {
     SAME_TAB: "same tab",
     NEW_TAB: "new tab"
-}
\ No newline at end of file
+}
+


Test patch: