Merge 5ace5c00246d55e01b99f54eb8a788a2e548a2ad into 8d8e3e5a8e78030b8ffbe258dc872b8bea234390

This commit is contained in:
Marshu 2025-04-19 17:59:37 +00:00 committed by GitHub
commit 88b769df21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 533 additions and 9 deletions

View File

@ -10,9 +10,88 @@
<div class="mb-5" data-testid="group">
<!-- Group Title -->
<h2 class="group-title">
<font-awesome-icon v-if="editMode && showGroupDrag" icon="arrows-alt-v" class="action drag me-3" />
<font-awesome-icon v-if="editMode" icon="times" class="action remove me-3" @click="removeGroup(group.index)" />
<Editable v-model="group.element.name" :contenteditable="editMode" tag="span" data-testid="group-name" />
<div class="title-section">
<font-awesome-icon v-if="editMode && showGroupDrag" icon="arrows-alt-v" class="action drag me-3" />
<font-awesome-icon v-if="editMode" icon="times" class="action remove me-3" @click="removeGroup(group.index)" />
<Editable v-model="group.element.name" :contenteditable="editMode" tag="span" data-testid="group-name" />
</div>
<div v-if="group.element && group.element.monitorList && group.element.monitorList.length > 1" class="sort-dropdown">
<div class="dropdown">
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle sort-button" :id="'sortDropdown' + group.index" data-bs-toggle="dropdown" aria-expanded="false">
<div class="sort-arrows">
<font-awesome-icon
icon="arrow-down"
:class="{
'arrow-inactive': !group.element.sortKey || group.element.sortDirection !== 'desc',
'arrow-active': group.element.sortKey && group.element.sortDirection === 'desc'
}"
/>
<font-awesome-icon
icon="arrow-up"
:class="{
'arrow-inactive': !group.element.sortKey || group.element.sortDirection !== 'asc',
'arrow-active': group.element.sortKey && group.element.sortDirection === 'asc'
}"
/>
</div>
</button>
<ul class="dropdown-menu dropdown-menu-end sort-menu" :aria-labelledby="'sortDropdown' + group.index">
<li>
<a class="dropdown-item sort-item" href="#" @click.prevent="setSort(group.element, 'status')">
<div class="sort-item-content">
<span>{{ $t("Status") }}</span>
<span v-if="getSortKey(group.element) === 'status'" class="sort-indicators">
<font-awesome-icon
:icon="group.element.sortDirection === 'asc' ? 'arrow-up' : 'arrow-down'"
class="arrow-active me-1"
/>
</span>
</div>
</a>
</li>
<li>
<a class="dropdown-item sort-item" href="#" @click.prevent="setSort(group.element, 'name')">
<div class="sort-item-content">
<span>{{ $t("Name") }}</span>
<span v-if="getSortKey(group.element) === 'name'" class="sort-indicators">
<font-awesome-icon
:icon="group.element.sortDirection === 'asc' ? 'arrow-up' : 'arrow-down'"
class="arrow-active me-1"
/>
</span>
</div>
</a>
</li>
<li>
<a class="dropdown-item sort-item" href="#" @click.prevent="setSort(group.element, 'uptime')">
<div class="sort-item-content">
<span>{{ $t("Uptime") }}</span>
<span v-if="getSortKey(group.element) === 'uptime'" class="sort-indicators">
<font-awesome-icon
:icon="group.element.sortDirection === 'asc' ? 'arrow-up' : 'arrow-down'"
class="arrow-active me-1"
/>
</span>
</div>
</a>
</li>
<li v-if="showCertificateExpiry">
<a class="dropdown-item sort-item" href="#" @click.prevent="setSort(group.element, 'cert')">
<div class="sort-item-content">
<span>{{ $t("Cert Exp.") }}</span>
<span v-if="getSortKey(group.element) === 'cert'" class="sort-indicators">
<font-awesome-icon
:icon="group.element.sortDirection === 'asc' ? 'arrow-up' : 'arrow-down'"
class="arrow-active me-1"
/>
</span>
</div>
</a>
</li>
</ul>
</div>
</div>
</h2>
<div class="shadow-box monitor-list mt-4 position-relative">
@ -21,7 +100,6 @@
</div>
<!-- Monitor List -->
<!-- animation is not working, no idea why -->
<Draggable
v-model="group.element.monitorList"
class="monitor-list"
@ -117,7 +195,6 @@ export default {
},
data() {
return {
};
},
computed: {
@ -126,9 +203,253 @@ 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
* @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)}`;
}
}
};
</script>
@ -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;
}
</style>

View File

@ -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,