<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"> <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); const url = new URL(location.href); const params = url.searchParams; const filterParam = params.get("filter"); const statusParams = params.getAll("status"); if (filterParam !== "true") { return; } const states = { up: 1, down: 0, pending: 2, maintenance: 3, }; this.updateFilter({ ...this.filterState, status: statusParams.map( status => states[status] ), }); }, 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>