diff options
Diffstat (limited to 'src/components/MonitorList.vue')
-rw-r--r-- | src/components/MonitorList.vue | 485 |
1 files changed, 485 insertions, 0 deletions
diff --git a/src/components/MonitorList.vue b/src/components/MonitorList.vue new file mode 100644 index 0000000..a579316 --- /dev/null +++ b/src/components/MonitorList.vue @@ -0,0 +1,485 @@ +<template> + <div class="shadow-box mb-3" :style="boxStyle"> + <div class="list-header"> + <div class="header-top"> + <button class="btn btn-outline-normal ms-2" :class="{ 'active': selectMode }" type="button" @click="selectMode = !selectMode"> + {{ $t("Select") }} + </button> + + <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...')" + :aria-label="$t('Search monitored sites')" + autocomplete="off" + /> + </form> + </div> + </div> + <div class="header-filter"> + <MonitorListFilter :filterState="filterState" @update-filter="updateFilter" /> + </div> + + <!-- Selection Controls --> + <div v-if="selectMode" class="selection-controls px-2 pt-2"> + <input + v-model="selectAll" + class="form-check-input select-input" + type="checkbox" + /> + + <button class="btn-outline-normal" @click="pauseDialog"><font-awesome-icon icon="pause" size="sm" /> {{ $t("Pause") }}</button> + <button class="btn-outline-normal" @click="resumeSelected"><font-awesome-icon icon="play" size="sm" /> {{ $t("Resume") }}</button> + + <span v-if="selectedMonitorCount > 0"> + {{ $t("selectedMonitorCount", [ selectedMonitorCount ]) }} + </span> + </div> + </div> + <div ref="monitorList" class="monitor-list" :class="{ scrollbar: scrollbar }" :style="monitorListStyle" data-testid="monitor-list"> + <div v-if="Object.keys($root.monitorList).length === 0" class="text-center mt-3"> + {{ $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" + :isSelectMode="selectMode" + :isSelected="isSelected" + :select="select" + :deselect="deselect" + :filter-func="filterFunc" + :sort-func="sortFunc" + /> + </div> + </div> + + <Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseSelected"> + {{ $t("pauseMonitorMsg") }} + </Confirm> +</template> + +<script> +import Confirm from "../components/Confirm.vue"; +import MonitorListItem from "../components/MonitorListItem.vue"; +import MonitorListFilter from "./MonitorListFilter.vue"; +import { getMonitorRelativeURL } from "../util.ts"; + +export default { + components: { + Confirm, + MonitorListItem, + MonitorListFilter, + }, + props: { + /** Should the scrollbar be shown */ + scrollbar: { + type: Boolean, + }, + }, + data() { + return { + searchText: "", + selectMode: false, + selectAll: false, + disableSelectAllWatcher: false, + selectedMonitors: {}, + windowTop: 0, + filterState: { + status: null, + active: null, + tags: null, + } + }; + }, + computed: { + /** + * Improve the sticky appearance of the list by increasing its + * height as user scrolls down. + * Not used on mobile. + * @returns {object} Style for monitor list + */ + boxStyle() { + if (window.innerWidth > 550) { + return { + height: `calc(100vh - 160px + ${this.windowTop}px)`, + }; + } else { + return { + height: "calc(100vh - 160px)", + }; + } + + }, + + /** + * Returns a sorted list of monitors based on the applied filters and search text. + * @returns {Array} The sorted list of monitors. + */ + sortedMonitorList() { + let result = Object.values(this.$root.monitorList); + + result = result.filter(monitor => { + // The root list does not show children + if (monitor.parent !== null) { + return false; + } + return true; + }); + + result = result.filter(this.filterFunc); + + result.sort(this.sortFunc); + + return result; + }, + + isDarkTheme() { + return document.body.classList.contains("dark"); + }, + + monitorListStyle() { + let listHeaderHeight = 107; + + if (this.selectMode) { + listHeaderHeight += 42; + } + + return { + "height": `calc(100% - ${listHeaderHeight}px)` + }; + }, + + selectedMonitorCount() { + return Object.keys(this.selectedMonitors).length; + }, + + /** + * Determines if any filters are active. + * @returns {boolean} True if any filter is active, false otherwise. + */ + filtersActive() { + return this.filterState.status != null || this.filterState.active != null || this.filterState.tags != null || this.searchText !== ""; + } + }, + watch: { + searchText() { + for (let monitor of this.sortedMonitorList) { + if (!this.selectedMonitors[monitor.id]) { + if (this.selectAll) { + this.disableSelectAllWatcher = true; + this.selectAll = false; + } + break; + } + } + }, + selectAll() { + if (!this.disableSelectAllWatcher) { + this.selectedMonitors = {}; + + if (this.selectAll) { + this.sortedMonitorList.forEach((item) => { + this.selectedMonitors[item.id] = true; + }); + } + } else { + this.disableSelectAllWatcher = false; + } + }, + selectMode() { + if (!this.selectMode) { + this.selectAll = false; + this.selectedMonitors = {}; + } + }, + }, + mounted() { + window.addEventListener("scroll", this.onScroll); + }, + beforeUnmount() { + window.removeEventListener("scroll", this.onScroll); + }, + methods: { + /** + * Handle user scroll + * @returns {void} + */ + onScroll() { + if (window.top.scrollY <= 133) { + this.windowTop = window.top.scrollY; + } else { + this.windowTop = 133; + } + }, + /** + * Get URL of monitor + * @param {number} id ID of monitor + * @returns {string} Relative URL of monitor + */ + monitorURL(id) { + return getMonitorRelativeURL(id); + }, + /** + * Clear the search bar + * @returns {void} + */ + clearSearchText() { + this.searchText = ""; + }, + /** + * Update the MonitorList Filter + * @param {object} newFilter Object with new filter + * @returns {void} + */ + updateFilter(newFilter) { + this.filterState = newFilter; + }, + /** + * Deselect a monitor + * @param {number} id ID of monitor + * @returns {void} + */ + deselect(id) { + delete this.selectedMonitors[id]; + }, + /** + * Select a monitor + * @param {number} id ID of monitor + * @returns {void} + */ + select(id) { + this.selectedMonitors[id] = true; + }, + /** + * Determine if monitor is selected + * @param {number} id ID of monitor + * @returns {bool} Is the monitor selected? + */ + isSelected(id) { + return id in this.selectedMonitors; + }, + /** + * Disable select mode and reset selection + * @returns {void} + */ + cancelSelectMode() { + this.selectMode = false; + this.selectedMonitors = {}; + }, + /** + * Show dialog to confirm pause + * @returns {void} + */ + pauseDialog() { + this.$refs.confirmPause.show(); + }, + /** + * Pause each selected monitor + * @returns {void} + */ + pauseSelected() { + Object.keys(this.selectedMonitors) + .filter(id => this.$root.monitorList[id].active) + .forEach(id => this.$root.getSocket().emit("pauseMonitor", id, () => {})); + + this.cancelSelectMode(); + }, + /** + * Resume each selected monitor + * @returns {void} + */ + resumeSelected() { + Object.keys(this.selectedMonitors) + .filter(id => !this.$root.monitorList[id].active) + .forEach(id => this.$root.getSocket().emit("resumeMonitor", id, () => {})); + + this.cancelSelectMode(); + }, + /** + * Whether a monitor should be displayed based on the filters + * @param {object} monitor Monitor to check + * @returns {boolean} Should the monitor be displayed + */ + filterFunc(monitor) { + // Group monitors bypass filter if at least 1 of children matched + if (monitor.type === "group") { + const children = Object.values(this.$root.monitorList).filter(m => m.parent === monitor.id); + if (children.some((child, index, children) => this.filterFunc(child))) { + return true; + } + } + + // filter by search text + // finds monitor name, tag name or tag value + let searchTextMatch = true; + if (this.searchText !== "") { + const loweredSearchText = this.searchText.toLowerCase(); + searchTextMatch = + monitor.name.toLowerCase().includes(loweredSearchText) + || monitor.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText) + || tag.value?.toLowerCase().includes(loweredSearchText)); + } + + // filter by status + let statusMatch = true; + if (this.filterState.status != null && this.filterState.status.length > 0) { + if (monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[monitor.id]) { + monitor.status = this.$root.lastHeartbeatList[monitor.id].status; + } + statusMatch = this.filterState.status.includes(monitor.status); + } + + // filter by active + let activeMatch = true; + if (this.filterState.active != null && this.filterState.active.length > 0) { + activeMatch = this.filterState.active.includes(monitor.active); + } + + // filter by tags + let tagsMatch = true; + if (this.filterState.tags != null && this.filterState.tags.length > 0) { + tagsMatch = 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 searchTextMatch && statusMatch && activeMatch && tagsMatch; + }, + /** + * Function used in Array.sort to order monitors in a list. + * @param {*} m1 monitor 1 + * @param {*} m2 monitor 2 + * @returns {number} -1, 0 or 1 + */ + sortFunc(m1, m2) { + if (m1.active !== m2.active) { + if (m1.active === false) { + return 1; + } + + if (m2.active === false) { + return -1; + } + } + + if (m1.weight !== m2.weight) { + if (m1.weight > m2.weight) { + return -1; + } + + if (m1.weight < m2.weight) { + return 1; + } + } + + return m1.name.localeCompare(m2.name); + } + }, +}; +</script> + +<style lang="scss" scoped> +@import "../assets/vars.scss"; + +.shadow-box { + height: calc(100vh - 150px); + position: sticky; + top: 10px; +} + +.small-padding { + padding-left: 5px !important; + padding-right: 5px !important; +} + +.list-header { + border-bottom: 1px solid #dee2e6; + border-radius: 10px 10px 0 0; + margin: -10px; + margin-bottom: 10px; + padding: 10px; + + .dark & { + background-color: $dark-header-bg; + border-bottom: 0; + } +} + +.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; + margin-bottom: 10px; + padding: 5px; + } +} + +.search-wrapper { + display: flex; + align-items: center; +} + +.search-icon { + padding: 10px; + color: #c0c0c0; + + // Clear filter button (X) + svg[data-icon="times"] { + cursor: pointer; + transition: all ease-in-out 0.1s; + + &:hover { + opacity: 0.5; + } + } +} + +.search-input { + max-width: 15em; +} + +.monitor-item { + width: 100%; +} + +.tags { + margin-top: 4px; + padding-left: 67px; + display: flex; + flex-wrap: wrap; + gap: 0; +} + +.bottom-style { + padding-left: 67px; + margin-top: 5px; +} + +.selection-controls { + margin-top: 5px; + display: flex; + align-items: center; + gap: 10px; +} +</style> |