This commit is contained in:
Marshu 2025-06-15 18:44:38 +00:00 committed by GitHub
commit 46f295c062
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 554 additions and 10 deletions

View file

@ -0,0 +1,488 @@
<template>
<div v-if="group && group.monitorList && group.monitorList.length > 1" class="sort-dropdown">
<div class="dropdown">
<button
:id="'sortDropdown' + groupIndex"
type="button"
class="btn btn-sm btn-outline-secondary dropdown-toggle sort-button"
data-bs-toggle="dropdown"
aria-expanded="false"
:aria-label="$t('Sort options')"
:title="$t('Sort options')"
>
<div class="sort-arrows">
<font-awesome-icon
icon="arrow-down"
:class="{
'arrow-inactive': !group.sortKey || group.sortDirection !== 'desc',
'arrow-active': group.sortKey && group.sortDirection === 'desc'
}"
/>
<font-awesome-icon
icon="arrow-up"
:class="{
'arrow-inactive': !group.sortKey || group.sortDirection !== 'asc',
'arrow-active': group.sortKey && group.sortDirection === 'asc'
}"
/>
</div>
</button>
<ul class="dropdown-menu dropdown-menu-end sort-menu" :aria-labelledby="'sortDropdown' + groupIndex">
<li>
<button
class="dropdown-item sort-item"
type="button"
:aria-label="$t('Sort by status')"
:title="$t('Sort by status')"
@click="setSort('status')"
>
<div class="sort-item-content">
<span>{{ $t("Status") }}</span>
<span v-if="getSortKey() === 'status'" class="sort-indicators">
<font-awesome-icon
:icon="group.sortDirection === 'asc' ? 'arrow-up' : 'arrow-down'"
class="arrow-active me-1"
/>
</span>
</div>
</button>
</li>
<li>
<button
class="dropdown-item sort-item"
type="button"
:aria-label="$t('Sort by name')"
:title="$t('Sort by name')"
@click="setSort('name')"
>
<div class="sort-item-content">
<span>{{ $t("Name") }}</span>
<span v-if="getSortKey() === 'name'" class="sort-indicators">
<font-awesome-icon
:icon="group.sortDirection === 'asc' ? 'arrow-up' : 'arrow-down'"
class="arrow-active me-1"
/>
</span>
</div>
</button>
</li>
<li>
<button
class="dropdown-item sort-item"
type="button"
:aria-label="$t('Sort by uptime')"
:title="$t('Sort by uptime')"
@click="setSort('uptime')"
>
<div class="sort-item-content">
<span>{{ $t("Uptime") }}</span>
<span v-if="getSortKey() === 'uptime'" class="sort-indicators">
<font-awesome-icon
:icon="group.sortDirection === 'asc' ? 'arrow-up' : 'arrow-down'"
class="arrow-active me-1"
/>
</span>
</div>
</button>
</li>
<li v-if="showCertificateExpiry">
<button
class="dropdown-item sort-item"
type="button"
:aria-label="$t('Sort by certificate expiry')"
:title="$t('Sort by certificate expiry')"
@click="setSort('cert')"
>
<div class="sort-item-content">
<span>{{ $t("Cert Exp.") }}</span>
<span v-if="getSortKey() === 'cert'" class="sort-indicators">
<font-awesome-icon
:icon="group.sortDirection === 'asc' ? 'arrow-up' : 'arrow-down'"
class="arrow-active me-1"
/>
</span>
</div>
</button>
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
name: "GroupSortDropdown",
props: {
/** Group object containing monitorList and sort settings */
group: {
type: Object,
required: true,
},
/** Index of the group for unique IDs */
groupIndex: {
type: Number,
required: true,
},
/** Should certificate expiry options be shown? */
showCertificateExpiry: {
type: Boolean,
default: false,
}
},
emits: [ "update-group" ],
computed: {
/**
* Parse sort settings from URL query parameters
* @returns {object} Parsed sort settings for all groups
*/
sortSettingsFromURL() {
const sortSettings = {};
if (this.$route && this.$route.query) {
for (const [ key, value ] of Object.entries(this.$route.query)) {
if (key.startsWith("sort_") && typeof value === "string") {
const groupId = key.replace("sort_", "");
const [ sortKey, direction ] = value.split("_");
if (sortKey && [ "status", "name", "uptime", "cert" ].includes(sortKey) &&
direction && [ "asc", "desc" ].includes(direction)) {
sortSettings[ groupId ] = {
sortKey,
direction
};
}
}
}
}
return sortSettings;
}
},
watch: {
// Watch for changes in heartbeat list, reapply sorting
"$root.heartbeatList": {
handler() {
this.applySort();
},
deep: true,
},
// Watch for changes in uptime list, reapply sorting
"$root.uptimeList": {
handler() {
this.applySort();
},
deep: true,
},
// Watch for URL changes and apply sort settings
sortSettingsFromURL: {
handler(newSortSettings) {
if (this.group) {
const groupId = this.getGroupIdentifier();
const urlSetting = newSortSettings[ groupId ];
if (urlSetting) {
this.updateGroup({
sortKey: urlSetting.sortKey,
sortDirection: urlSetting.direction
});
} else {
// Set defaults if not in URL
if (this.group.sortKey === undefined) {
this.updateGroup({ sortKey: "status" });
}
if (this.group.sortDirection === undefined) {
this.updateGroup({ sortDirection: "asc" });
}
}
this.applySort();
}
},
immediate: true,
deep: true
}
},
methods: {
/**
* Get sort key for the group
* @returns {string} sort key
*/
getSortKey() {
return this.group.sortKey || "status";
},
/**
* Update group properties by emitting to parent
* @param {object} updates - object with properties to update
* @returns {void}
*/
updateGroup(updates) {
this.$emit("update-group", this.groupIndex, updates);
},
/**
* Set group sort key and direction, then apply sorting
* @param {string} key - sort key ('status', 'name', 'uptime', 'cert')
* @returns {void}
*/
setSort(key) {
if (this.group.sortKey === key) {
this.updateGroup({
sortDirection: this.group.sortDirection === "asc" ? "desc" : "asc"
});
} else {
this.updateGroup({
sortKey: key,
sortDirection: "asc"
});
}
this.applySort();
this.updateRouterQuery();
},
/**
* Update router query parameters with sort settings
* @returns {void}
*/
updateRouterQuery() {
if (!this.$router) {
return;
}
const query = { ...this.$route.query };
const groupId = this.getGroupIdentifier();
if (this.group.sortKey && this.group.sortDirection) {
query[ `sort_${groupId}` ] = `${this.group.sortKey}_${this.group.sortDirection}`;
} else {
delete query[ `sort_${groupId}` ];
}
this.$router.push({ query }).catch(() => {});
},
/**
* Apply sorting logic directly to the group's monitorList (in-place)
* @returns {void}
*/
applySort() {
if (!this.group || !this.group.monitorList || !Array.isArray(this.group.monitorList)) {
return;
}
const sortKey = this.group.sortKey || "status";
const sortDirection = this.group.sortDirection || "desc";
this.updateGroup({
monitorList: [ ...this.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);
}
})
});
},
/**
* Get unique identifier for the group
* @returns {string} group identifier
*/
getGroupIdentifier() {
// Use the name directly if available
if (this.group.name) {
// Only remove spaces and use encodeURIComponent for URL safety
const cleanName = this.group.name.replace(/\s+/g, "");
return cleanName;
}
// Fallback to ID or index
return this.group.id ? `group${this.group.id}` : `group${this.groupIndex}`;
}
}
};
</script>
<style lang="scss" scoped>
@import "../assets/vars";
.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;
}
.dark & {
background-color: $dark-bg;
color: $dark-font-color;
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.3);
&:hover {
background-color: $dark-bg2;
}
&:focus, &:active {
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.3);
}
}
}
.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;
.dark & {
color: #6c757d;
}
}
.arrow-active {
color: #4caf50;
font-size: 0.8rem;
.dark & {
color: $primary;
}
}
.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;
.dark & {
background-color: $dark-bg;
color: $dark-font-color;
border-color: $dark-border-color;
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.3);
}
}
.sort-item {
padding: 0.4rem 0.8rem;
text-align: left;
width: 100%;
background: none;
border: none;
cursor: pointer;
&:hover {
background-color: #f8f9fa;
}
.dark & {
color: $dark-font-color;
&:hover {
background-color: $dark-bg2;
}
}
}
.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;
}
</style>

View file

@ -10,9 +10,18 @@
<div class="mb-5" data-testid="group"> <div class="mb-5" data-testid="group">
<!-- Group Title --> <!-- Group Title -->
<h2 class="group-title"> <h2 class="group-title">
<font-awesome-icon v-if="editMode && showGroupDrag" icon="arrows-alt-v" class="action drag me-3" /> <div class="title-section">
<font-awesome-icon v-if="editMode" icon="times" class="action remove me-3" @click="removeGroup(group.index)" /> <font-awesome-icon v-if="editMode && showGroupDrag" icon="arrows-alt-v" class="action drag me-3" />
<Editable v-model="group.element.name" :contenteditable="editMode" tag="span" data-testid="group-name" /> <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>
<GroupSortDropdown
:group="group.element"
:group-index="group.index"
:show-certificate-expiry="showCertificateExpiry"
@update-group="updateGroup"
/>
</h2> </h2>
<div class="shadow-box monitor-list mt-4 position-relative"> <div class="shadow-box monitor-list mt-4 position-relative">
@ -21,7 +30,6 @@
</div> </div>
<!-- Monitor List --> <!-- Monitor List -->
<!-- animation is not working, no idea why -->
<Draggable <Draggable
v-model="group.element.monitorList" v-model="group.element.monitorList"
class="monitor-list" class="monitor-list"
@ -92,6 +100,7 @@ import Draggable from "vuedraggable";
import HeartbeatBar from "./HeartbeatBar.vue"; import HeartbeatBar from "./HeartbeatBar.vue";
import Uptime from "./Uptime.vue"; import Uptime from "./Uptime.vue";
import Tag from "./Tag.vue"; import Tag from "./Tag.vue";
import GroupSortDropdown from "./GroupSortDropdown.vue";
export default { export default {
components: { components: {
@ -100,6 +109,7 @@ export default {
HeartbeatBar, HeartbeatBar,
Uptime, Uptime,
Tag, Tag,
GroupSortDropdown,
}, },
props: { props: {
/** Are we in edit mode? */ /** Are we in edit mode? */
@ -118,7 +128,6 @@ export default {
}, },
data() { data() {
return { return {
}; };
}, },
computed: { computed: {
@ -126,8 +135,11 @@ export default {
return (this.$root.publicGroupList.length >= 2); return (this.$root.publicGroupList.length >= 2);
} }
}, },
watch: {
// No watchers needed - sorting is handled by GroupSortDropdown component
},
created() { created() {
// Sorting is now handled by GroupSortDropdown component
}, },
methods: { methods: {
/** /**
@ -141,8 +153,7 @@ export default {
/** /**
* Remove a monitor from a group * Remove a monitor from a group
* @param {number} groupIndex Index of group to remove monitor * @param {number} groupIndex Index of group to remove monitor from
* from
* @param {number} index Index of monitor to remove * @param {number} index Index of monitor to remove
* @returns {void} * @returns {void}
*/ */
@ -163,7 +174,9 @@ export default {
// We must check if there are any elements in monitorList to // We must check if there are any elements in monitorList to
// prevent undefined errors if it hasn't been loaded yet // prevent undefined errors if it hasn't been loaded yet
if (this.$parent.editMode && ignoreSendUrl && Object.keys(this.$root.monitorList).length) { 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://"; return monitor.element.sendUrl && monitor.element.url && monitor.element.url !== "https://";
}, },
@ -194,6 +207,32 @@ export default {
} }
return "#DC2626"; return "#DC2626";
}, },
/**
* Update group properties
* @param {number} groupIndex Index of group to update
* @param {object} updates Object with properties to update
* @returns {void}
*/
updateGroup(groupIndex, updates) {
Object.assign(this.$root.publicGroupList[groupIndex], updates);
},
/**
* 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> </script>
@ -255,6 +294,15 @@ export default {
} }
.group-title { .group-title {
display: flex;
justify-content: space-between;
align-items: center;
.title-section {
display: flex;
align-items: center;
}
span { span {
display: inline-block; display: inline-block;
min-width: 15px; min-width: 15px;
@ -265,10 +313,14 @@ export default {
.item { .item {
padding: 13px 0 10px; padding: 13px 0 10px;
} }
.group-title {
flex-direction: column;
align-items: flex-start;
}
} }
.bg-maintenance { .bg-maintenance {
background-color: $maintenance; background-color: $maintenance;
} }
</style> </style>

View file

@ -8,6 +8,8 @@ import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
// 2) add the icon name to the library.add() statement below. // 2) add the icon name to the library.add() statement below.
import { import {
faArrowAltCircleUp, faArrowAltCircleUp,
faArrowDown,
faArrowUp,
faCog, faCog,
faEdit, faEdit,
faEye, faEye,
@ -54,6 +56,8 @@ import {
library.add( library.add(
faArrowAltCircleUp, faArrowAltCircleUp,
faArrowDown,
faArrowUp,
faCog, faCog,
faEdit, faEdit,
faEye, faEye,