Merge pull request #3312 from chakflying/feat/monitor-list-improved-filtering

Feat: Improved comprehensive monitor list filtering
This commit is contained in:
Louis Lam 2023-07-16 21:27:42 +08:00 committed by GitHub
commit 4891ec4527
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 496 additions and 20 deletions

View file

@ -167,7 +167,7 @@ class Monitor extends BeanModel {
async isActive() {
const parentActive = await Monitor.isParentActive(this.id);
return this.active && parentActive;
return (this.active === 1) && parentActive;
}
/**

View file

@ -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>

View file

@ -0,0 +1,284 @@
<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"
:disabled="numFiltersActive === 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("filterActivePaused") }}</span>
</span>
<span v-else>
{{ $t("filterActive") }}
</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("filterActivePaused") }}</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>

View file

@ -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 0 !important;
border-radius: 16px;
overflow: hidden;
position: absolute;
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;
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>

View file

@ -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",

View file

@ -139,6 +139,8 @@
"Disable 2FA": "關閉 2FA",
"2FA Settings": "2FA 設定",
"Two Factor Authentication": "雙重認證",
"filterActive": "執行狀態",
"filterActivePaused": "已暫停",
"Active": "生效",
"Inactive": "未生效",
"Token": "Token",

View file

@ -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 {