From 7cc97834369627c4889021d174915ca1d8365082 Mon Sep 17 00:00:00 2001 From: Nelson Chan <chakflying@hotmail.com> Date: Mon, 26 Jun 2023 13:21:51 +0800 Subject: [PATCH 1/5] Fix: Active needs to return bool instead of 0 --- server/model/monitor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/model/monitor.js b/server/model/monitor.js index 2dfe2e65..017e6813 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -155,7 +155,7 @@ class Monitor extends BeanModel { async isActive() { const parentActive = await Monitor.isParentActive(this.id); - return this.active && parentActive; + return (this.active === 1) && parentActive; } /** From 79b38e0e7bf7247da499e7f1266d3e674bfafd55 Mon Sep 17 00:00:00 2001 From: Nelson Chan <chakflying@hotmail.com> Date: Mon, 26 Jun 2023 13:23:06 +0800 Subject: [PATCH 2/5] Feat: Improve monitorList filtering --- src/components/MonitorList.vue | 90 ++++-- src/components/MonitorListFilter.vue | 283 +++++++++++++++++++ src/components/MonitorListFilterDropdown.vue | 131 +++++++++ src/mixins/socket.js | 5 +- 4 files changed, 490 insertions(+), 19 deletions(-) create mode 100644 src/components/MonitorListFilter.vue create mode 100644 src/components/MonitorListFilterDropdown.vue diff --git a/src/components/MonitorList.vue b/src/components/MonitorList.vue index c69169cc..9ee46e24 100644 --- a/src/components/MonitorList.vue +++ b/src/components/MonitorList.vue @@ -1,17 +1,25 @@ <template> <div class="shadow-box mb-3" :style="boxStyle"> <div class="list-header"> - <div class="placeholder"></div> - <div class="search-wrapper"> - <a v-if="searchText == ''" class="search-icon"> - <font-awesome-icon icon="search" /> - </a> - <a v-if="searchText != ''" class="search-icon" @click="clearSearchText"> - <font-awesome-icon icon="times" /> - </a> - <form> - <input v-model="searchText" class="form-control search-input" :placeholder="$t('Search...')" autocomplete="off" /> - </form> + <div class="header-top"> + <div class="placeholder"></div> + <div class="search-wrapper"> + <a v-if="searchText == ''" class="search-icon"> + <font-awesome-icon icon="search" /> + </a> + <a v-if="searchText != ''" class="search-icon" @click="clearSearchText"> + <font-awesome-icon icon="times" /> + </a> + <form> + <input + v-model="searchText" class="form-control search-input" :placeholder="$t('Search...')" + autocomplete="off" + /> + </form> + </div> + </div> + <div class="header-filter"> + <MonitorListFilter :filterState="filterState" @update-filter="updateFilter" /> </div> </div> <div class="monitor-list" :class="{ scrollbar: scrollbar }"> @@ -19,18 +27,23 @@ {{ $t("No Monitors, please") }} <router-link to="/add">{{ $t("add one") }}</router-link> </div> - <MonitorListItem v-for="(item, index) in sortedMonitorList" :key="index" :monitor="item" :isSearch="searchText !== ''" /> + <MonitorListItem + v-for="(item, index) in sortedMonitorList" :key="index" :monitor="item" + :isSearch="searchText !== ''" + /> </div> </div> </template> <script> import MonitorListItem from "../components/MonitorListItem.vue"; +import MonitorListFilter from "./MonitorListFilter.vue"; import { getMonitorRelativeURL } from "../util.ts"; export default { components: { MonitorListItem, + MonitorListFilter, }, props: { /** Should the scrollbar be shown */ @@ -42,6 +55,11 @@ export default { return { searchText: "", windowTop: 0, + filterState: { + status: null, + active: null, + tags: null, + } }; }, computed: { @@ -72,8 +90,8 @@ export default { const loweredSearchText = this.searchText.toLowerCase(); result = result.filter(monitor => { return monitor.name.toLowerCase().includes(loweredSearchText) - || monitor.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText) - || tag.value?.toLowerCase().includes(loweredSearchText)); + || monitor.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText) + || tag.value?.toLowerCase().includes(loweredSearchText)); }); } else { result = result.filter(monitor => monitor.parent === null); @@ -105,6 +123,27 @@ export default { return m1.name.localeCompare(m2.name); }); + if (this.filterState.status != null && this.filterState.status.length > 0) { + result.map(monitor => { + if (monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[monitor.id]) { + monitor.status = this.$root.lastHeartbeatList[monitor.id].status; + } + }); + result = result.filter(monitor => this.filterState.status.includes(monitor.status)); + } + + if (this.filterState.active != null && this.filterState.active.length > 0) { + result = result.filter(monitor => this.filterState.active.includes(monitor.active)); + } + + if (this.filterState.tags != null && this.filterState.tags.length > 0) { + result = result.filter(monitor => { + return monitor.tags.map(tag => tag.tag_id) // convert to array of tag IDs + .filter(monitorTagId => this.filterState.tags.includes(monitorTagId)) // perform Array Intersaction between filter and monitor's tags + .length > 0; + }); + } + return result; }, }, @@ -134,7 +173,14 @@ export default { /** Clear the search bar */ clearSearchText() { this.searchText = ""; - } + }, + /** + * Update the MonitorList Filter + * @param {object} newFilter Object with new filter + */ + updateFilter(newFilter) { + this.filterState = newFilter; + }, }, }; </script> @@ -159,8 +205,6 @@ export default { margin: -10px; margin-bottom: 10px; padding: 10px; - display: flex; - justify-content: space-between; .dark & { background-color: $dark-header-bg; @@ -168,6 +212,17 @@ export default { } } +.header-top { + display: flex; + justify-content: space-between; + align-items: center; +} + +.header-filter { + display: flex; + align-items: center; +} + @media (max-width: 770px) { .list-header { margin: -20px; @@ -216,5 +271,4 @@ export default { padding-left: 67px; margin-top: 5px; } - </style> diff --git a/src/components/MonitorListFilter.vue b/src/components/MonitorListFilter.vue new file mode 100644 index 00000000..022f7d91 --- /dev/null +++ b/src/components/MonitorListFilter.vue @@ -0,0 +1,283 @@ +<template> + <div class="px-2 pt-2 d-flex"> + <button + type="button" + :title="$t('Clear current filters')" + class="clear-filters-btn btn" + :class="{ 'active': numFiltersActive > 0}" + tabindex="0" + @click="clearFilters" + > + <font-awesome-icon icon="stream" /> + <span v-if="numFiltersActive > 0" class="px-1 fw-bold">{{ numFiltersActive }}</span> + <font-awesome-icon v-if="numFiltersActive > 0" icon="times" /> + </button> + <MonitorListFilterDropdown + :filterActive="filterState.status?.length > 0" + > + <template #status> + <Status v-if="filterState.status?.length === 1" :status="filterState.status[0]" /> + <span v-else> + {{ $t('Status') }} + </span> + </template> + <template #dropdown> + <li> + <div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(1)"> + <div class="d-flex align-items-center justify-content-between"> + <Status :status="1" /> + <span class="ps-3"> + {{ $root.stats.up }} + <span v-if="filterState.status?.includes(1)" class="px-1 filter-active"> + <font-awesome-icon icon="check" /> + </span> + </span> + </div> + </div> + </li> + <li> + <div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(0)"> + <div class="d-flex align-items-center justify-content-between"> + <Status :status="0" /> + <span class="ps-3"> + {{ $root.stats.down }} + <span v-if="filterState.status?.includes(0)" class="px-1 filter-active"> + <font-awesome-icon icon="check" /> + </span> + </span> + </div> + </div> + </li> + <li> + <div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(2)"> + <div class="d-flex align-items-center justify-content-between"> + <Status :status="2" /> + <span class="ps-3"> + {{ $root.stats.pending }} + <span v-if="filterState.status?.includes(2)" class="px-1 filter-active"> + <font-awesome-icon icon="check" /> + </span> + </span> + </div> + </div> + </li> + <li> + <div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(3)"> + <div class="d-flex align-items-center justify-content-between"> + <Status :status="3" /> + <span class="ps-3"> + {{ $root.stats.maintenance }} + <span v-if="filterState.status?.includes(3)" class="px-1 filter-active"> + <font-awesome-icon icon="check" /> + </span> + </span> + </div> + </div> + </li> + </template> + </MonitorListFilterDropdown> + <MonitorListFilterDropdown :filterActive="filterState.active?.length > 0"> + <template #status> + <span v-if="filterState.active?.length === 1"> + <span v-if="filterState.active[0]">{{ $t("Running") }}</span> + <span v-else>{{ $t("Paused") }}</span> + </span> + <span v-else> + {{ $t('Active') }} + </span> + </template> + <template #dropdown> + <li> + <div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(true)"> + <div class="d-flex align-items-center justify-content-between"> + <span>{{ $t("Running") }}</span> + <span class="ps-3"> + {{ $root.stats.active }} + <span v-if="filterState.active?.includes(true)" class="px-1 filter-active"> + <font-awesome-icon icon="check" /> + </span> + </span> + </div> + </div> + </li> + <li> + <div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(false)"> + <div class="d-flex align-items-center justify-content-between"> + <span>{{ $t("Paused") }}</span> + <span class="ps-3"> + {{ $root.stats.pause }} + <span v-if="filterState.active?.includes(false)" class="px-1 filter-active"> + <font-awesome-icon icon="check" /> + </span> + </span> + </div> + </div> + </li> + </template> + </MonitorListFilterDropdown> + <MonitorListFilterDropdown :filterActive="filterState.tags?.length > 0"> + <template #status> + <Tag + v-if="filterState.tags?.length === 1" + :item="tagsList.find(tag => tag.id === filterState.tags[0])" + :size="'sm'" + /> + <span v-else> + {{ $t('Tags') }} + </span> + </template> + <template #dropdown> + <li v-for="tag in tagsList" :key="tag.id"> + <div class="dropdown-item" tabindex="0" @click.stop="toggleTagFilter(tag)"> + <div class="d-flex align-items-center justify-content-between"> + <span><Tag :item="tag" :size="'sm'" /></span> + <span class="ps-3"> + {{ getTaggedMonitorCount(tag) }} + <span v-if="filterState.tags?.includes(tag.id)" class="px-1 filter-active"> + <font-awesome-icon icon="check" /> + </span> + </span> + </div> + </div> + </li> + </template> + </MonitorListFilterDropdown> + </div> +</template> + +<script> +import MonitorListFilterDropdown from "./MonitorListFilterDropdown.vue"; +import Status from "./Status.vue"; +import Tag from "./Tag.vue"; + +export default { + components: { + MonitorListFilterDropdown, + Status, + Tag, + }, + props: { + filterState: { + type: Object, + required: true, + } + }, + emits: [ "updateFilter" ], + data() { + return { + tagsList: [], + }; + }, + computed: { + numFiltersActive() { + let num = 0; + + Object.values(this.filterState).forEach(item => { + if (item != null && item.length > 0) { + num += 1; + } + }); + + return num; + } + }, + mounted() { + this.getExistingTags(); + }, + methods: { + toggleStatusFilter(status) { + let newFilter = { + ...this.filterState + }; + + if (newFilter.status == null) { + newFilter.status = [ status ]; + } else { + if (newFilter.status.includes(status)) { + newFilter.status = newFilter.status.filter(item => item !== status); + } else { + newFilter.status.push(status); + } + } + this.$emit("updateFilter", newFilter); + }, + toggleActiveFilter(active) { + let newFilter = { + ...this.filterState + }; + + if (newFilter.active == null) { + newFilter.active = [ active ]; + } else { + if (newFilter.active.includes(active)) { + newFilter.active = newFilter.active.filter(item => item !== active); + } else { + newFilter.active.push(active); + } + } + this.$emit("updateFilter", newFilter); + }, + toggleTagFilter(tag) { + let newFilter = { + ...this.filterState + }; + + if (newFilter.tags == null) { + newFilter.tags = [ tag.id ]; + } else { + if (newFilter.tags.includes(tag.id)) { + newFilter.tags = newFilter.tags.filter(item => item !== tag.id); + } else { + newFilter.tags.push(tag.id); + } + } + this.$emit("updateFilter", newFilter); + }, + clearFilters() { + this.$emit("updateFilter", { + status: null, + }); + }, + getExistingTags() { + this.$root.getSocket().emit("getTags", (res) => { + if (res.ok) { + this.tagsList = res.tags; + } + }); + }, + getTaggedMonitorCount(tag) { + return Object.values(this.$root.monitorList).filter(monitor => { + return monitor.tags.find(monitorTag => monitorTag.tag_id === tag.id); + }).length; + } + } +}; +</script> + +<style lang="scss" scoped> +@import "../assets/vars.scss"; + +.clear-filters-btn { + font-size: 0.8em; + margin-right: 5px; + display: flex; + align-items: center; + padding: 2px 10px; + border-radius: 16px; + background-color: transparent; + + .dark & { + color: $dark-font-color; + border: 1px solid $dark-font-color2; + } + + &.active { + border: 1px solid $highlight; + background-color: $highlight-white; + + .dark & { + background-color: $dark-font-color2; + } + } +} +</style> diff --git a/src/components/MonitorListFilterDropdown.vue b/src/components/MonitorListFilterDropdown.vue new file mode 100644 index 00000000..adf3d6b3 --- /dev/null +++ b/src/components/MonitorListFilterDropdown.vue @@ -0,0 +1,131 @@ +<template> + <div class="dropdown" @focusin="open = true" @focusout="handleFocusOut"> + <button type="button" class="filter-dropdown-status" :class="{ 'active': filterActive }" tabindex="0"> + <div class="px-1 d-flex align-items-center"> + <slot name="status"></slot> + </div> + <span class="px-1"> + <font-awesome-icon icon="angle-down" /> + </span> + </button> + <ul class="filter-dropdown-menu" :class="{ 'open': open }"> + <slot name="dropdown"></slot> + </ul> + </div> +</template> + +<script> + +export default { + components: { + + }, + props: { + filterActive: { + type: Boolean, + required: true, + } + }, + data() { + return { + open: false + }; + }, + methods: { + handleFocusOut(e) { + if (e.relatedTarget != null && this.$el.contains(e.relatedTarget)) { + return; + } + this.open = false; + } + } +}; +</script> + +<style lang="scss"> +@import "../assets/vars.scss"; + +.filter-dropdown-menu { + z-index: 100; + transition: all 0.2s; + padding: 5px 0px !important; + border-radius: 16px; + overflow: hidden; + + position: absolute; + inset: 0px auto auto 0px; + margin: 0px; + transform: translate(0px, 36px); + box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1); + visibility: hidden; + list-style: none; + height: 0; + opacity: 0; + background: white; + + &.open { + height: unset; + visibility: inherit; + opacity: 1; + } + + .dropdown-item { + padding: 5px 15px; + } + + .dropdown-item:focus { + background: $highlight-white; + + .dark & { + background: $dark-bg2; + } + } + + .dark & { + background-color: $dark-bg; + color: $dark-font-color; + border-color: $dark-border-color; + + .dropdown-item { + color: $dark-font-color; + + &.active { + color: $dark-font-color2; + background-color: $highlight !important; + } + + &:hover { + background-color: $dark-bg2; + } + } + } +} + +.filter-dropdown-status { + display: flex; + align-items: center; + padding: 4px 10px; + margin-left: 5px; + border: 1px solid #ced4da; + border-radius: 25px; + background-color: transparent; + + .dark & { + color: $dark-font-color; + border: 1px solid $dark-font-color2; + } + + &.active { + border: 1px solid $highlight; + background-color: $highlight-white; + + .dark & { + background-color: $dark-font-color2; + } + } +} + +.filter-active { + color: $highlight; +} +</style> diff --git a/src/mixins/socket.js b/src/mixins/socket.js index e2834251..8e078b10 100644 --- a/src/mixins/socket.js +++ b/src/mixins/socket.js @@ -693,9 +693,11 @@ export default { stats() { let result = { + active: 0, up: 0, down: 0, maintenance: 0, + pending: 0, unknown: 0, pause: 0, }; @@ -707,12 +709,13 @@ export default { if (monitor && ! monitor.active) { result.pause++; } else if (beat) { + result.active++; if (beat.status === UP) { result.up++; } else if (beat.status === DOWN) { result.down++; } else if (beat.status === PENDING) { - result.up++; + result.pending++; } else if (beat.status === MAINTENANCE) { result.maintenance++; } else { From cea894cc6d7dcba0ab599f0b85bf5dccb85d899c Mon Sep 17 00:00:00 2001 From: Nelson Chan <chakflying@hotmail.com> Date: Mon, 26 Jun 2023 13:39:19 +0800 Subject: [PATCH 3/5] Chore: Fix lint --- src/components/MonitorListFilterDropdown.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/MonitorListFilterDropdown.vue b/src/components/MonitorListFilterDropdown.vue index adf3d6b3..01b9678f 100644 --- a/src/components/MonitorListFilterDropdown.vue +++ b/src/components/MonitorListFilterDropdown.vue @@ -48,14 +48,14 @@ export default { .filter-dropdown-menu { z-index: 100; transition: all 0.2s; - padding: 5px 0px !important; + padding: 5px 0 !important; border-radius: 16px; overflow: hidden; position: absolute; - inset: 0px auto auto 0px; - margin: 0px; - transform: translate(0px, 36px); + inset: 0 auto auto 0; + margin: 0; + transform: translate(0, 36px); box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1); visibility: hidden; list-style: none; From f8c9a20afdaae76b3d2307c296f5631beddde27b Mon Sep 17 00:00:00 2001 From: Nelson Chan <chakflying@hotmail.com> Date: Mon, 26 Jun 2023 13:42:46 +0800 Subject: [PATCH 4/5] Chore: Disable clear filters button --- src/components/MonitorListFilter.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/MonitorListFilter.vue b/src/components/MonitorListFilter.vue index 022f7d91..2dcd062f 100644 --- a/src/components/MonitorListFilter.vue +++ b/src/components/MonitorListFilter.vue @@ -6,6 +6,7 @@ class="clear-filters-btn btn" :class="{ 'active': numFiltersActive > 0}" tabindex="0" + :disabled="numFiltersActive === 0" @click="clearFilters" > <font-awesome-icon icon="stream" /> From e2a87eb4300d67c6911775ca24962475fbe1580a Mon Sep 17 00:00:00 2001 From: Louis Lam <louislam@users.noreply.github.com> Date: Sun, 16 Jul 2023 21:15:25 +0800 Subject: [PATCH 5/5] Improve the filter translate keys --- src/components/MonitorListFilter.vue | 6 +++--- src/lang/en.json | 2 ++ src/lang/zh-HK.json | 2 ++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/MonitorListFilter.vue b/src/components/MonitorListFilter.vue index 2dcd062f..dbb1eb94 100644 --- a/src/components/MonitorListFilter.vue +++ b/src/components/MonitorListFilter.vue @@ -81,10 +81,10 @@ <template #status> <span v-if="filterState.active?.length === 1"> <span v-if="filterState.active[0]">{{ $t("Running") }}</span> - <span v-else>{{ $t("Paused") }}</span> + <span v-else>{{ $t("filterActivePaused") }}</span> </span> <span v-else> - {{ $t('Active') }} + {{ $t("filterActive") }} </span> </template> <template #dropdown> @@ -104,7 +104,7 @@ <li> <div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(false)"> <div class="d-flex align-items-center justify-content-between"> - <span>{{ $t("Paused") }}</span> + <span>{{ $t("filterActivePaused") }}</span> <span class="ps-3"> {{ $root.stats.pause }} <span v-if="filterState.active?.includes(false)" class="px-1 filter-active"> diff --git a/src/lang/en.json b/src/lang/en.json index bc4f9ce3..ceafe06c 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -155,6 +155,8 @@ "Disable 2FA": "Disable 2FA", "2FA Settings": "2FA Settings", "Two Factor Authentication": "Two Factor Authentication", + "filterActive": "Active", + "filterActivePaused": "Paused", "Active": "Active", "Inactive": "Inactive", "Token": "Token", diff --git a/src/lang/zh-HK.json b/src/lang/zh-HK.json index fd5d35e3..aa43caa5 100644 --- a/src/lang/zh-HK.json +++ b/src/lang/zh-HK.json @@ -139,6 +139,8 @@ "Disable 2FA": "關閉 2FA", "2FA Settings": "2FA 設定", "Two Factor Authentication": "雙重認證", + "filterActive": "執行狀態", + "filterActivePaused": "已暫停", "Active": "生效", "Inactive": "未生效", "Token": "Token",