feat: add ability to group monitors in dashboard

This commit is contained in:
Peace 2023-01-28 02:58:03 +01:00
parent d99d37898e
commit 645fd94bba
No known key found for this signature in database
GPG key ID: 0EF6B46E172B739F
11 changed files with 411 additions and 44 deletions

View file

@ -0,0 +1,6 @@
BEGIN TRANSACTION;
ALTER TABLE monitor
ADD parent INTEGER REFERENCES [monitor] ([id]) ON DELETE SET NULL ON UPDATE CASCADE;
COMMIT

View file

@ -68,6 +68,7 @@ class Database {
"patch-ping-packet-size.sql": true, "patch-ping-packet-size.sql": true,
"patch-maintenance-table2.sql": true, "patch-maintenance-table2.sql": true,
"patch-add-gamedig-monitor.sql": true, "patch-add-gamedig-monitor.sql": true,
"patch-add-parent-monitor.sql": true,
}; };
/** /**

View file

@ -72,6 +72,9 @@ class Monitor extends BeanModel {
let data = { let data = {
id: this.id, id: this.id,
name: this.name, name: this.name,
pathName: await this.getPathName(),
parent: this.parent,
childrenIDs: await Monitor.getAllChildrenIDs(this.id),
url: this.url, url: this.url,
method: this.method, method: this.method,
hostname: this.hostname, hostname: this.hostname,
@ -253,6 +256,25 @@ class Monitor extends BeanModel {
if (await Monitor.isUnderMaintenance(this.id)) { if (await Monitor.isUnderMaintenance(this.id)) {
bean.msg = "Monitor under maintenance"; bean.msg = "Monitor under maintenance";
bean.status = MAINTENANCE; bean.status = MAINTENANCE;
} else if (this.type === "group") {
const children = await Monitor.getChildren(this.id);
bean.status = UP;
bean.msg = "All childs up and running";
for (const child of children) {
const lastBeat = await Monitor.getPreviousHeartbeat(child.id);
// Only change state if the monitor is in worse conditions then the ones before
if (bean.status === UP && (lastBeat.status === PENDING || lastBeat.status === DOWN)) {
bean.status = lastBeat.status;
} else if (bean.status === PENDING && lastBeat.status === DOWN) {
bean.status = lastBeat.status;
}
}
if (bean.status !== UP) {
bean.msg = "Child inaccessible";
}
} else if (this.type === "http" || this.type === "keyword") { } else if (this.type === "http" || this.type === "keyword") {
// Do not do any queries/high loading things before the "bean.ping" // Do not do any queries/high loading things before the "bean.ping"
let startTime = dayjs().valueOf(); let startTime = dayjs().valueOf();
@ -1283,6 +1305,77 @@ class Monitor extends BeanModel {
throw new Error(`Interval cannot be less than ${MIN_INTERVAL_SECOND} seconds`); throw new Error(`Interval cannot be less than ${MIN_INTERVAL_SECOND} seconds`);
} }
} }
/**
* Gets Parent of the monitor
* @param {number} monitorID ID of monitor to get
* @returns {Promise<LooseObject<any>>}
*/
static async getParent(monitorID) {
return await R.getRow(`
SELECT parent.* FROM monitor parent
LEFT JOIN monitor child
ON child.parent = parent.id
WHERE child.id = ?
`, [
monitorID,
]);
}
/**
* Gets all Children of the monitor
* @param {number} monitorID ID of monitor to get
* @returns {Promise<LooseObject<any>>}
*/
static async getChildren(monitorID) {
return await R.getAll(`
SELECT * FROM monitor
WHERE parent = ?
`, [
monitorID,
]);
}
/**
* Gets Full Path-Name (Groups and Name)
* @returns {Promise<String>}
*/
async getPathName() {
let path = this.name;
if (this.parent === null) {
return path;
}
let parent = await Monitor.getParent(this.id);
while (parent !== null) {
path = `${parent.name} / ${path}`;
parent = await Monitor.getParent(parent.id);
}
return path;
}
/**
* Gets recursive all child ids
* @returns {Promise<Array>}
*/
static async getAllChildrenIDs(monitorID) {
const childs = await Monitor.getChildren(monitorID);
if (childs === null) {
return [];
}
let childrenIDs = [];
for (const child of childs) {
childrenIDs.push(child.id);
childrenIDs = childrenIDs.concat(await Monitor.getAllChildrenIDs(child.id));
}
return childrenIDs;
}
} }
module.exports = Monitor; module.exports = Monitor;

View file

@ -678,6 +678,7 @@ let needSetup = false;
server.monitorList[monitor.id]?.prometheus()?.remove(); server.monitorList[monitor.id]?.prometheus()?.remove();
bean.name = monitor.name; bean.name = monitor.name;
bean.parent = monitor.parent;
bean.type = monitor.type; bean.type = monitor.type;
bean.url = monitor.url; bean.url = monitor.url;
bean.method = monitor.method; bean.method = monitor.method;

View file

@ -19,43 +19,18 @@
{{ $t("No Monitors, please") }} <router-link to="/add">{{ $t("add one") }}</router-link> {{ $t("No Monitors, please") }} <router-link to="/add">{{ $t("add one") }}</router-link>
</div> </div>
<router-link v-for="(item, index) in sortedMonitorList" :key="index" :to="monitorURL(item.id)" class="item" :class="{ 'disabled': ! item.active }"> <MonitorListItem v-for="(item, index) in sortedMonitorList" :key="index" :monitor="item" :isSearch="searchText !== ''" />
<div class="row">
<div class="col-9 col-md-8 small-padding" :class="{ 'monitor-item': $root.userHeartbeatBar == 'bottom' || $root.userHeartbeatBar == 'none' }">
<div class="info">
<Uptime :monitor="item" type="24" :pill="true" />
{{ item.name }}
</div>
<div class="tags">
<Tag v-for="tag in item.tags" :key="tag" :item="tag" :size="'sm'" />
</div>
</div>
<div v-show="$root.userHeartbeatBar == 'normal'" :key="$root.userHeartbeatBar" class="col-3 col-md-4">
<HeartbeatBar size="small" :monitor-id="item.id" />
</div>
</div>
<div v-if="$root.userHeartbeatBar == 'bottom'" class="row">
<div class="col-12 bottom-style">
<HeartbeatBar size="small" :monitor-id="item.id" />
</div>
</div>
</router-link>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import HeartbeatBar from "../components/HeartbeatBar.vue"; import MonitorListItem from "../components/MonitorListItem.vue";
import Tag from "../components/Tag.vue";
import Uptime from "../components/Uptime.vue";
import { getMonitorRelativeURL } from "../util.ts"; import { getMonitorRelativeURL } from "../util.ts";
export default { export default {
components: { components: {
Uptime, MonitorListItem,
HeartbeatBar,
Tag,
}, },
props: { props: {
/** Should the scrollbar be shown */ /** Should the scrollbar be shown */
@ -91,6 +66,19 @@ export default {
sortedMonitorList() { sortedMonitorList() {
let result = Object.values(this.$root.monitorList); let result = Object.values(this.$root.monitorList);
// Simple filter by search text
// finds monitor name, tag name or tag value
if (this.searchText !== "") {
const loweredSearchText = this.searchText.toLowerCase();
result = result.filter(monitor => {
return monitor.name.toLowerCase().includes(loweredSearchText)
|| monitor.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText)
|| tag.value?.toLowerCase().includes(loweredSearchText));
});
} else {
result = result.filter(monitor => monitor.parent === null);
}
result.sort((m1, m2) => { result.sort((m1, m2) => {
if (m1.active !== m2.active) { if (m1.active !== m2.active) {
@ -116,17 +104,6 @@ export default {
return m1.name.localeCompare(m2.name); return m1.name.localeCompare(m2.name);
}); });
// Simple filter by search text
// finds monitor name, tag name or tag value
if (this.searchText !== "") {
const loweredSearchText = this.searchText.toLowerCase();
result = result.filter(monitor => {
return monitor.name.toLowerCase().includes(loweredSearchText)
|| monitor.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText)
|| tag.value?.toLowerCase().includes(loweredSearchText));
});
}
return result; return result;
}, },
}, },

View file

@ -0,0 +1,196 @@
<template>
<div>
<router-link :to="monitorURL(monitor.id)" class="item" :class="{ 'disabled': ! monitor.active }">
<div class="row">
<div class="col-9 col-md-8 small-padding" :class="{ 'monitor-item': $root.userHeartbeatBar == 'bottom' || $root.userHeartbeatBar == 'none' }">
<div class="info" :style="depthMargin">
<Uptime :monitor="monitor" type="24" :pill="true" />
<span v-if="hasChildren" class="collapse-padding" @click.prevent="changeCollapsed">
<font-awesome-icon icon="chevron-down" class="animated" :class="{ collapsed: isCollapsed}" />
</span>
{{ monitorName }}
</div>
<div class="tags">
<Tag v-for="tag in monitor.tags" :key="tag" :item="tag" :size="'sm'" />
</div>
</div>
<div v-show="$root.userHeartbeatBar == 'normal'" :key="$root.userHeartbeatBar" class="col-3 col-md-4">
<HeartbeatBar size="small" :monitor-id="monitor.id" />
</div>
</div>
<div v-if="$root.userHeartbeatBar == 'bottom'" class="row">
<div class="col-12 bottom-style">
<HeartbeatBar size="small" :monitor-id="monitor.id" />
</div>
</div>
</router-link>
<transition name="slide-fade-up">
<div v-if="!isCollapsed" class="childs">
<MonitorListItem v-for="(item, index) in sortedChildMonitorList" :key="index" :monitor="item" :isSearch="isSearch" :depth="depth + 1" />
</div>
</transition>
</div>
</template>
<script>
import HeartbeatBar from "../components/HeartbeatBar.vue";
import Tag from "../components/Tag.vue";
import Uptime from "../components/Uptime.vue";
import { getMonitorRelativeURL } from "../util.ts";
export default {
name: "MonitorListItem",
components: {
Uptime,
HeartbeatBar,
Tag,
},
props: {
/** Monitor this represents */
monitor: {
type: Object,
default: null,
},
isSearch: {
type: Boolean,
default: false,
},
depth: {
type: Number,
default: 0,
},
},
data() {
return {
isCollapsed: true,
};
},
computed: {
sortedChildMonitorList() {
let result = Object.values(this.$root.monitorList);
result = result.filter(childMonitor => childMonitor.parent === this.monitor.id);
result.sort((m1, m2) => {
if (m1.active !== m2.active) {
if (m1.active === 0) {
return 1;
}
if (m2.active === 0) {
return -1;
}
}
if (m1.weight !== m2.weight) {
if (m1.weight > m2.weight) {
return -1;
}
if (m1.weight < m2.weight) {
return 1;
}
}
return m1.name.localeCompare(m2.name);
});
return result;
},
hasChildren() {
return this.sortedChildMonitorList.length > 0;
},
depthMargin() {
return {
marginLeft: `${31 * this.depth}px`,
};
},
monitorName() {
if (this.isSearch) {
return this.monitor.pathName;
} else {
return this.monitor.name;
}
}
},
beforeMount() {
// this.isCollapsed = localStorage.getItem(`monitor_${this.monitor.id}_collapsed`) === "true";
let storage = window.localStorage.getItem("monitorCollapsed");
if (storage === null) {
return;
}
let storageObject = JSON.parse(storage);
if (storageObject[`monitor_${this.monitor.id}`] === null) {
return;
}
this.isCollapsed = storageObject[`monitor_${this.monitor.id}`];
},
methods: {
changeCollapsed() {
this.isCollapsed = !this.isCollapsed;
let storage = window.localStorage.getItem("monitorCollapsed");
let storageObject = {};
if (storage !== null) {
storageObject = JSON.parse(storage);
}
storageObject[`monitor_${this.monitor.id}`] = this.isCollapsed;
window.localStorage.setItem("monitorCollapsed", JSON.stringify(storageObject));
},
/**
* Get URL of monitor
* @param {number} id ID of monitor
* @returns {string} Relative URL of monitor
*/
monitorURL(id) {
return getMonitorRelativeURL(id);
},
// /** Clear the search bar */
// clearSearchText() {
// this.searchText = "";
// }
},
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
.small-padding {
padding-left: 5px !important;
padding-right: 5px !important;
}
.collapse-padding {
padding-left: 8px !important;
padding-right: 2px !important;
}
// .monitor-item {
// width: 100%;
// }
.tags {
margin-top: 4px;
padding-left: 67px;
display: flex;
flex-wrap: wrap;
gap: 0;
}
.collapsed {
transform: rotate(-90deg);
}
.animated {
transition: all 0.2s $easing-in;
}
</style>

View file

@ -643,5 +643,7 @@
"Custom": "Benutzerdefiniert", "Custom": "Benutzerdefiniert",
"Enable DNS Cache": "DNS Cache aktivieren", "Enable DNS Cache": "DNS Cache aktivieren",
"Enable": "Aktivieren", "Enable": "Aktivieren",
"Disable": "Deaktivieren" "Disable": "Deaktivieren",
"Group": "Gruppe",
"Monitor Group": "Monitor Gruppe"
} }

View file

@ -682,5 +682,7 @@
"onebotUserOrGroupId": "Group/User ID", "onebotUserOrGroupId": "Group/User ID",
"onebotSafetyTips": "For safety, must set access token", "onebotSafetyTips": "For safety, must set access token",
"PushDeer Key": "PushDeer Key", "PushDeer Key": "PushDeer Key",
"wayToGetClickSendSMSToken": "You can get API Username and API Key from {0} ." "wayToGetClickSendSMSToken": "You can get API Username and API Key from {0} .",
"Group": "Group",
"Monitor Group": "Monitor Group"
} }

View file

@ -1,6 +1,7 @@
<template> <template>
<transition name="slide-fade" appear> <transition name="slide-fade" appear>
<div v-if="monitor"> <div v-if="monitor">
<span> {{ group }}</span>
<h1> {{ monitor.name }}</h1> <h1> {{ monitor.name }}</h1>
<div class="tags"> <div class="tags">
<Tag v-for="tag in monitor.tags" :key="tag.id" :item="tag" :size="'sm'" /> <Tag v-for="tag in monitor.tags" :key="tag.id" :item="tag" :size="'sm'" />
@ -286,6 +287,13 @@ export default {
const endIndex = startIndex + this.perPage; const endIndex = startIndex + this.perPage;
return this.heartBeatList.slice(startIndex, endIndex); return this.heartBeatList.slice(startIndex, endIndex);
}, },
group() {
if (!this.monitor.pathName.includes("/")) {
return "";
}
return this.monitor.pathName.substr(0, this.monitor.pathName.lastIndexOf("/"));
}
}, },
mounted() { mounted() {

View file

@ -12,6 +12,9 @@
<label for="type" class="form-label">{{ $t("Monitor Type") }}</label> <label for="type" class="form-label">{{ $t("Monitor Type") }}</label>
<select id="type" v-model="monitor.type" class="form-select"> <select id="type" v-model="monitor.type" class="form-select">
<optgroup :label="$t('General Monitor Type')"> <optgroup :label="$t('General Monitor Type')">
<option value="group">
{{ $t("Group") }}
</option>
<option value="http"> <option value="http">
HTTP(s) HTTP(s)
</option> </option>
@ -79,6 +82,15 @@
<input id="name" v-model="monitor.name" type="text" class="form-control" required> <input id="name" v-model="monitor.name" type="text" class="form-control" required>
</div> </div>
<!-- Parent Monitor -->
<div class="my-3">
<label for="parent" class="form-label">{{ $t("Monitor Group") }}</label>
<select v-model="monitor.parent" class="form-select" :disabled="sortedMonitorList.length === 0">
<option :value="null" selected>{{ $t("None") }}</option>
<option v-for="parentMonitor in sortedMonitorList" :key="parentMonitor.id" :value="parentMonitor.id">{{ parentMonitor.pathName }}</option>
</select>
</div>
<!-- URL --> <!-- URL -->
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3"> <div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3">
<label for="url" class="form-label">{{ $t("URL") }}</label> <label for="url" class="form-label">{{ $t("URL") }}</label>
@ -737,6 +749,49 @@ message HealthCheckResponse {
return null; return null;
}, },
sortedMonitorList() {
// return Object.values(this.$root.monitorList).filter(monitor => {
// // Only return monitors which aren't related to the current selected
// if (monitor.id === this.monitor.id || monitor.parent === this.monitor.id) {
// return false;
// }
// return true;
// });
let result = Object.values(this.$root.monitorList);
console.log(this.monitor.childrenIDs);
result = result.filter(monitor => monitor.type === "group");
result = result.filter(monitor => monitor.id !== this.monitor.id);
result = result.filter(monitor => !this.monitor.childrenIDs?.includes(monitor.id));
result.sort((m1, m2) => {
if (m1.active !== m2.active) {
if (m1.active === 0) {
return 1;
}
if (m2.active === 0) {
return -1;
}
}
if (m1.weight !== m2.weight) {
if (m1.weight > m2.weight) {
return -1;
}
if (m1.weight < m2.weight) {
return 1;
}
}
return m1.pathName.localeCompare(m2.pathName);
});
return result;
},
}, },
watch: { watch: {
"$root.proxyList"() { "$root.proxyList"() {
@ -839,6 +894,7 @@ message HealthCheckResponse {
this.monitor = { this.monitor = {
type: "http", type: "http",
name: "", name: "",
parent: null,
url: "https://", url: "https://",
method: "GET", method: "GET",
interval: 60, interval: 60,

View file

@ -254,10 +254,10 @@
</div> </div>
<div class="mt-3"> <div class="mt-3">
<div v-if="allMonitorList.length > 0 && loadedData"> <div v-if="sortedMonitorList.length > 0 && loadedData">
<label>{{ $t("Add a monitor") }}:</label> <label>{{ $t("Add a monitor") }}:</label>
<select v-model="selectedMonitor" class="form-control"> <select v-model="selectedMonitor" class="form-control">
<option v-for="monitor in allMonitorList" :key="monitor.id" :value="monitor">{{ monitor.name }}</option> <option v-for="monitor in sortedMonitorList" :key="monitor.id" :value="monitor">{{ monitor.pathName }}</option>
</select> </select>
</div> </div>
<div v-else class="text-center"> <div v-else class="text-center">
@ -391,7 +391,7 @@ export default {
/** /**
* If the monitor is added to public list, which will not be in this list. * If the monitor is added to public list, which will not be in this list.
*/ */
allMonitorList() { sortedMonitorList() {
let result = []; let result = [];
for (let id in this.$root.monitorList) { for (let id in this.$root.monitorList) {
@ -401,6 +401,31 @@ export default {
} }
} }
result.sort((m1, m2) => {
if (m1.active !== m2.active) {
if (m1.active === 0) {
return 1;
}
if (m2.active === 0) {
return -1;
}
}
if (m1.weight !== m2.weight) {
if (m1.weight > m2.weight) {
return -1;
}
if (m1.weight < m2.weight) {
return 1;
}
}
return m1.pathName.localeCompare(m2.pathName);
});
return result; return result;
}, },