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,