diff --git a/src/components/PublicGroupList.vue b/src/components/PublicGroupList.vue index bacddbf13..da085dabf 100644 --- a/src/components/PublicGroupList.vue +++ b/src/components/PublicGroupList.vue @@ -10,9 +10,88 @@

- - - +
+ + + +
+ +

@@ -21,7 +100,6 @@
- { + if (group) { + this.applySort(group); + } + }); + } + }, + deep: true, + }, + // Watch for changes in uptime list, reapply sorting + "$root.uptimeList": { + handler() { + if (this.$root && this.$root.publicGroupList) { + this.$root.publicGroupList.forEach(group => { + if (group) { + this.applySort(group); + } + }); + } + }, + deep: true, + }, }, methods: { + /** + * Initialize group sort settings + * @returns {void} + */ + initializeSortSettings() { + if (this.$root.publicGroupList) { + this.$root.publicGroupList.forEach(group => { + if (group) { + // Try to read saved sort settings from localStorage + const savedSettings = this.getSavedSortSettings(group); + + if (savedSettings) { + // Apply saved settings + group.sortKey = savedSettings.key; + group.sortDirection = savedSettings.direction; + } else { + // Use default settings + if (group.sortKey === undefined) { + group.sortKey = "status"; + } + if (group.sortDirection === undefined) { + group.sortDirection = "desc"; + } + } + // Apply initial sorting + this.applySort(group); + } + }); + } + // Watch for new groups being added and initialize their sort state + if (this.$root) { + this.$root.$watch("publicGroupList", (newGroups) => { + if (newGroups) { + newGroups.forEach(group => { + if (group && group.sortKey === undefined) { + const savedSettings = this.getSavedSortSettings(group); + + if (savedSettings) { + group.sortKey = savedSettings.key; + group.sortDirection = savedSettings.direction; + } else { + group.sortKey = "status"; + group.sortDirection = "desc"; + } + + this.applySort(group); + } + }); + } + }, { deep: true }); + } + }, + + /** + * Get saved sort settings from localStorage + * @param {object} group object + * @returns {object|null} saved sorting settings + */ + getSavedSortSettings(group) { + try { + const groupId = this.getGroupIdentifier(group); + const slug = this.$root.statusPage ? this.$root.statusPage.slug : "default"; + const storageKey = `uptime-kuma-sort-${slug}-${groupId}`; + + const savedSettings = localStorage.getItem(storageKey); + if (savedSettings) { + return JSON.parse(savedSettings); + } + } catch (error) { + console.error("Cannot read sort settings", error); + } + return null; + }, + + /** + * Get sort key for a group + * @param {object} group object + * @returns {string} sort key + */ + getSortKey(group) { + return group.sortKey || "status"; + }, + + /** + * Get sort direction symbol + * @param {object} group object + * @returns {string} sort direction symbol + */ + getSortDirectionSymbol(group) { + return (group.sortDirection === "asc") ? "↑" : "↓"; + }, + + /** + * Set group sort key and direction, then apply sorting + * @param {object} group object + * @param {string} key - sort key ('status', 'name', 'uptime', 'cert') + * @returns {void} + */ + setSort(group, key) { + if (group.sortKey === key) { + group.sortDirection = group.sortDirection === "asc" ? "desc" : "asc"; + } else { + group.sortKey = key; + group.sortDirection = (key === "status") ? "desc" : "asc"; + } + try { + const groupId = this.getGroupIdentifier(group); + const slug = this.$root.statusPage ? this.$root.statusPage.slug : "default"; + const storageKey = `uptime-kuma-sort-${slug}-${groupId}`; + + const sortSettings = { + key: group.sortKey, + direction: group.sortDirection + }; + localStorage.setItem(storageKey, JSON.stringify(sortSettings)); + } catch (error) { + console.error("Cannot save sort settings", error); + } + + this.applySort(group); + + this.updateURLSortParams(); + }, + + /** + * Apply sorting logic directly to the group's monitorList (in-place) + * @param {object} group object containing monitorList + * @returns {void} + */ + applySort(group) { + if (!group || !group.monitorList || !Array.isArray(group.monitorList)) { + return; + } + + const sortKey = group.sortKey || "status"; + const sortDirection = group.sortDirection || "desc"; + + group.monitorList.sort((a, b) => { + if (!a || !b) return 0; + + let comparison = 0; + let valueA; + let valueB; + + if (sortKey === "status") { + // Sort by status + const getStatusPriority = (monitor) => { + if (!monitor || !monitor.id) { + return 4; + } + + const hbList = this.$root.heartbeatList || {}; + const hbArr = hbList[monitor.id]; + if (hbArr && hbArr.length > 0) { + const lastStatus = hbArr.at(-1).status; + if (lastStatus === 0) { + return 0; + } // Down + if (lastStatus === 1) { + return 1; + } // Up + if (lastStatus === 2) { + return 2; + } // Pending + if (lastStatus === 3) { + return 3; + } // Maintenance + } + return 4; // Unknown/No data + }; + valueA = getStatusPriority(a); + valueB = getStatusPriority(b); + } else if (sortKey === "name") { + // Sort alphabetically by name + valueA = a.name ? a.name.toLowerCase() : ""; + valueB = b.name ? b.name.toLowerCase() : ""; + } else if (sortKey === "uptime") { + // Sort by uptime + const uptimeList = this.$root.uptimeList || {}; + const uptimeA = a.id ? parseFloat(uptimeList[`${a.id}_24`]) || 0 : 0; + const uptimeB = b.id ? parseFloat(uptimeList[`${b.id}_24`]) || 0 : 0; + valueA = uptimeA; + valueB = uptimeB; + } else if (sortKey === "cert") { + // Sort by certificate expiry time + valueA = a.validCert && a.certExpiryDaysRemaining ? a.certExpiryDaysRemaining : -1; + valueB = b.validCert && b.certExpiryDaysRemaining ? b.certExpiryDaysRemaining : -1; + } + + if (valueA < valueB) { + comparison = -1; + } else if (valueA > valueB) { + comparison = 1; + } + + // Special handling for status sorting + if (sortKey === "status") { + return sortDirection === "desc" ? (comparison * -1) : comparison; + } else { + return sortDirection === "asc" ? comparison : (comparison * -1); + } + }); + }, + /** * Remove the specified group * @param {number} index Index of group to remove @@ -140,8 +461,7 @@ export default { /** * Remove a monitor from a group - * @param {number} groupIndex Index of group to remove monitor - * from + * @param {number} groupIndex Index of group to remove monitor from * @param {number} index Index of monitor to remove * @returns {void} */ @@ -162,7 +482,9 @@ export default { // We must check if there are any elements in monitorList to // prevent undefined errors if it hasn't been loaded yet if (this.$parent.editMode && ignoreSendUrl && Object.keys(this.$root.monitorList).length) { - return this.$root.monitorList[monitor.element.id].type === "http" || this.$root.monitorList[monitor.element.id].type === "keyword" || this.$root.monitorList[monitor.element.id].type === "json-query"; + return this.$root.monitorList[monitor.element.id].type === "http" || + this.$root.monitorList[monitor.element.id].type === "keyword" || + this.$root.monitorList[monitor.element.id].type === "json-query"; } return monitor.element.sendUrl && monitor.element.url && monitor.element.url !== "https://"; }, @@ -193,6 +515,100 @@ export default { } return "#DC2626"; }, + + /** + * Handle browser back/forward button events + * @returns {void} + */ + handlePopState() { + this.loadSortSettingsFromURL(); + }, + + /** + * Load sort settings from URL + * @returns {void} + */ + loadSortSettingsFromURL() { + if (!this.$root.publicGroupList) { + return; + } + + const urlParams = new URLSearchParams(window.location.search); + + // Iterate through all groups, look for sort parameters in URL + this.$root.publicGroupList.forEach(group => { + if (!group) { + return; + } + + const groupId = this.getGroupIdentifier(group); + const sortParam = urlParams.get(`sort_${groupId}`); + + if (sortParam) { + const [key, direction] = sortParam.split("_"); + if (key && ["status", "name", "uptime", "cert"].includes(key) && + direction && ["asc", "desc"].includes(direction)) { + group.sortKey = key; + group.sortDirection = direction; + this.applySort(group); + } + } + }); + }, + + /** + * Update sort parameters in URL + * @returns {void} + */ + updateURLSortParams() { + if (!this.$root.publicGroupList) { + return; + } + + const urlParams = new URLSearchParams(window.location.search); + + // First clear all sort_ parameters + const paramsToRemove = []; + for (const [key] of urlParams.entries()) { + if (key.startsWith("sort_")) { + paramsToRemove.push(key); + } + } + + paramsToRemove.forEach(key => { + urlParams.delete(key); + }); + + // Add current sort parameters + this.$root.publicGroupList.forEach(group => { + if (!group || !group.sortKey) { + return; + } + + const groupId = this.getGroupIdentifier(group); + urlParams.set(`sort_${groupId}`, `${group.sortKey}_${group.sortDirection}`); + }); + + // Update URL without refreshing the page + const newUrl = `${window.location.pathname}${urlParams.toString() ? "?" + urlParams.toString() : ""}`; + window.history.pushState({ path: newUrl }, "", newUrl); + }, + + /** + * Get unique identifier for a group + * @param {object} group object + * @returns {string} group identifier + */ + getGroupIdentifier(group) { + // Use the name directly if available + if (group.name) { + // Only remove spaces and use encodeURIComponent for URL safety + const cleanName = group.name.replace(/\s+/g, ""); + return cleanName; + } + // Fallback to ID or index + return group.id ? `group${group.id}` : `group${this.$root.publicGroupList.indexOf(group)}`; + } } }; @@ -254,20 +670,124 @@ export default { } .group-title { + display: flex; + justify-content: space-between; + align-items: center; + + .title-section { + display: flex; + align-items: center; + } + span { display: inline-block; min-width: 15px; } } +.sort-dropdown { + margin-left: auto; +} + +.sort-button { + display: flex; + align-items: center; + justify-content: center; + padding: 0.3rem 0.6rem; + min-width: 40px; + border-radius: 10px; + background-color: white; + border: none; + box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1); + transition: all ease-in-out 0.15s; + + &:hover { + background-color: #f8f9fa; + } + + &:focus, &:active { + box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1); + border: none; + outline: none; + } +} + +.sort-arrows { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 2px; +} + +.arrow-inactive { + color: #aaa; + font-size: 0.7rem; + opacity: 0.5; +} + +.arrow-active { + color: #4caf50; + font-size: 0.8rem; +} + +.sort-menu { + min-width: auto; + width: auto; + padding: 0.2rem 0; + border-radius: 10px; + border: none; + box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.sort-item { + padding: 0.4rem 0.8rem; + + &:hover { + background-color: #f8f9fa; + } +} + +.sort-item-content { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + min-width: 120px; +} + +.sort-indicators { + display: flex; + align-items: center; + margin-left: 10px; +} + +.sort-direction-indicator { + font-weight: bold; + display: inline-block; + margin-left: 2px; +} + .mobile { .item { padding: 13px 0 10px; } + + .group-title { + flex-direction: column; + align-items: flex-start; + + .sort-dropdown { + margin-left: 0; + margin-top: 0.5rem; + width: 100%; + } + } } .bg-maintenance { background-color: $maintenance; } - diff --git a/src/icon.js b/src/icon.js index 7bdfe1ca0..d81023df1 100644 --- a/src/icon.js +++ b/src/icon.js @@ -8,6 +8,8 @@ import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; // 2) add the icon name to the library.add() statement below. import { faArrowAltCircleUp, + faArrowDown, + faArrowUp, faCog, faEdit, faEye, @@ -54,6 +56,8 @@ import { library.add( faArrowAltCircleUp, + faArrowDown, + faArrowUp, faCog, faEdit, faEye,