diff --git a/src/components/PublicGroupList.vue b/src/components/PublicGroupList.vue index bacddbf13..7c3d6d6b0 100644 --- a/src/components/PublicGroupList.vue +++ b/src/components/PublicGroupList.vue @@ -10,9 +10,70 @@

- - - +
+ + + +
+ +

@@ -117,7 +178,6 @@ export default { }, data() { return { - }; }, computed: { @@ -126,9 +186,239 @@ export default { } }, created() { + // Initialize sort settings + this.initializeSortSettings(); + }, + mounted() { + // Load sort settings from URL + this.loadSortSettingsFromURL(); + // Listen for URL changes + window.addEventListener('popstate', this.handlePopState); + }, + beforeUnmount() { + // Remove URL change listener + window.removeEventListener('popstate', this.handlePopState); + }, + watch: { + // Watch for changes in heartbeat list, reapply sorting + '$root.heartbeatList': { + handler() { + if (this.$root && this.$root.publicGroupList) { + this.$root.publicGroupList.forEach(group => { + 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 + */ + 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} sort key ('status', 'name', 'uptime', 'cert') + */ + 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 + */ + 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, 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 @@ -193,6 +483,89 @@ export default { } return "#DC2626"; }, + + /** + * Handle browser back/forward button events + */ + handlePopState() { + this.loadSortSettingsFromURL(); + }, + + /** + * Load sort settings from URL + */ + 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 + */ + 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,16 +627,78 @@ 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 { + min-width: 100px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.3rem 0.7rem; +} + +.sort-menu { + min-width: auto; + width: auto; + padding: 0.2rem 0; +} + +.sort-item { + padding: 0.4rem 0.8rem; +} + +.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 {