1
0
mirror of https://github.com/louislam/uptime-kuma.git synced 2024-11-24 08:32:23 +02:00

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-maintenance-table2.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 = {
id: this.id,
name: this.name,
pathName: await this.getPathName(),
parent: this.parent,
childrenIDs: await Monitor.getAllChildrenIDs(this.id),
url: this.url,
method: this.method,
hostname: this.hostname,
@ -253,6 +256,25 @@ class Monitor extends BeanModel {
if (await Monitor.isUnderMaintenance(this.id)) {
bean.msg = "Monitor under 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") {
// Do not do any queries/high loading things before the "bean.ping"
let startTime = dayjs().valueOf();
@ -1283,6 +1305,77 @@ class Monitor extends BeanModel {
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;

View File

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

View File

@ -19,43 +19,18 @@
{{ $t("No Monitors, please") }} <router-link to="/add">{{ $t("add one") }}</router-link>
</div>
<router-link v-for="(item, index) in sortedMonitorList" :key="index" :to="monitorURL(item.id)" class="item" :class="{ 'disabled': ! item.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">
<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>
<MonitorListItem v-for="(item, index) in sortedMonitorList" :key="index" :monitor="item" :isSearch="searchText !== ''" />
</div>
</div>
</template>
<script>
import HeartbeatBar from "../components/HeartbeatBar.vue";
import Tag from "../components/Tag.vue";
import Uptime from "../components/Uptime.vue";
import MonitorListItem from "../components/MonitorListItem.vue";
import { getMonitorRelativeURL } from "../util.ts";
export default {
components: {
Uptime,
HeartbeatBar,
Tag,
MonitorListItem,
},
props: {
/** Should the scrollbar be shown */
@ -91,6 +66,19 @@ export default {
sortedMonitorList() {
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) => {
if (m1.active !== m2.active) {
@ -116,17 +104,6 @@ export default {
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;
},
},

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",
"Enable DNS Cache": "DNS Cache aktivieren",
"Enable": "Aktivieren",
"Disable": "Deaktivieren"
"Disable": "Deaktivieren",
"Group": "Gruppe",
"Monitor Group": "Monitor Gruppe"
}

View File

@ -682,5 +682,7 @@
"onebotUserOrGroupId": "Group/User ID",
"onebotSafetyTips": "For safety, must set access token",
"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>
<transition name="slide-fade" appear>
<div v-if="monitor">
<span> {{ group }}</span>
<h1> {{ monitor.name }}</h1>
<div class="tags">
<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;
return this.heartBeatList.slice(startIndex, endIndex);
},
group() {
if (!this.monitor.pathName.includes("/")) {
return "";
}
return this.monitor.pathName.substr(0, this.monitor.pathName.lastIndexOf("/"));
}
},
mounted() {

View File

@ -12,6 +12,9 @@
<label for="type" class="form-label">{{ $t("Monitor Type") }}</label>
<select id="type" v-model="monitor.type" class="form-select">
<optgroup :label="$t('General Monitor Type')">
<option value="group">
{{ $t("Group") }}
</option>
<option value="http">
HTTP(s)
</option>
@ -79,6 +82,15 @@
<input id="name" v-model="monitor.name" type="text" class="form-control" required>
</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 -->
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3">
<label for="url" class="form-label">{{ $t("URL") }}</label>
@ -737,6 +749,49 @@ message HealthCheckResponse {
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: {
"$root.proxyList"() {
@ -839,6 +894,7 @@ message HealthCheckResponse {
this.monitor = {
type: "http",
name: "",
parent: null,
url: "https://",
method: "GET",
interval: 60,

View File

@ -254,10 +254,10 @@
</div>
<div class="mt-3">
<div v-if="allMonitorList.length > 0 && loadedData">
<div v-if="sortedMonitorList.length > 0 && loadedData">
<label>{{ $t("Add a monitor") }}:</label>
<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>
</div>
<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.
*/
allMonitorList() {
sortedMonitorList() {
let result = [];
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;
},