A complete maintenance planning system has been created

This commit is contained in:
Karel Krýda
2022-01-23 15:22:00 +01:00
parent c3c4db52ec
commit 0d3414c6d6
32 changed files with 1121 additions and 51 deletions

View File

@@ -273,6 +273,7 @@ textarea.form-control {
&.bg-info,
&.bg-warning,
&.bg-danger,
&.bg-maintenance,
&.bg-light {
color: $dark-font-color2;
}

View File

@@ -1,6 +1,7 @@
$primary: #5cdd8b;
$danger: #dc3545;
$warning: #f8a306;
$maintenance: #1747f5;
$link-color: #111;
$border-radius: 50rem;

View File

@@ -5,7 +5,7 @@
v-for="(beat, index) in shortBeatList"
:key="index"
class="beat"
:class="{ 'empty' : (beat === 0), 'down' : (beat.status === 0), 'pending' : (beat.status === 2) }"
:class="{ 'empty' : (beat === 0), 'down' : (beat.status === 0), 'pending' : (beat.status === 2), 'maintenance' : (beat.status === 3) }"
:style="beatStyle"
:title="getBeatTitle(beat)"
/>
@@ -200,6 +200,10 @@ export default {
background-color: $warning;
}
&.maintenance {
background-color: $maintenance;
}
&:not(.empty):hover {
transition: all ease-in-out 0.15s;
opacity: 0.8;

View File

@@ -1,7 +1,12 @@
<template>
<div class="shadow-box mb-3">
<div class="list-header">
<div class="placeholder"></div>
<div class="search-wrapper float-start">
<select v-model="selectedList" class="form-control">
<option value="monitor" selected>{{$t('Monitor List')}}</option>
<option value="maintenance">{{$t('Maintenance List')}}</option>
</select>
</div>
<div class="search-wrapper">
<a v-if="searchText == ''" class="search-icon">
<font-awesome-icon icon="search" />
@@ -13,11 +18,25 @@
</div>
</div>
<div class="monitor-list" :class="{ scrollbar: scrollbar }">
<div v-if="Object.keys($root.monitorList).length === 0" class="text-center mt-3">
<div v-if="Object.keys($root.monitorList).length === 0 && selectedList === 'monitor'" class="text-center mt-3">
{{ $t("No Monitors, please") }} <router-link to="/add">{{ $t("add one") }}</router-link>
</div>
<div v-if="Object.keys($root.maintenanceList).length === 0 && selectedList === 'maintenance'" class="text-center mt-3">
{{ $t("No Maintenance, please") }} <router-link to="/addMaintenance">{{ $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 }">
<router-link v-if="selectedList === 'maintenance'" v-for="(item, index) in sortedMaintenanceList" :key="index" :to="maintenanceURL(item.id)" class="item" :class="{ 'disabled': (Date.parse(item.end_date) < Date.now()) }">
<div class="row">
<div class="col-9 col-md-8 small-padding">
<div class="info">
<Uptime :monitor="null" type="maintenance" :pill="true" />
{{ item.title }}
</div>
</div>
</div>
</router-link>
<router-link v-if="selectedList === 'monitor'" 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="{ 'monitorItem': $root.userHeartbeatBar == 'bottom' || $root.userHeartbeatBar == 'none' }">
<div class="info">
@@ -47,7 +66,7 @@
import HeartbeatBar from "../components/HeartbeatBar.vue";
import Uptime from "../components/Uptime.vue";
import Tag from "../components/Tag.vue";
import { getMonitorRelativeURL } from "../util.ts";
import {getMaintenanceRelativeURL, getMonitorRelativeURL} from "../util.ts";
export default {
components: {
@@ -63,9 +82,60 @@ export default {
data() {
return {
searchText: "",
selectedList: "monitor"
};
},
computed: {
sortedMaintenanceList() {
let result = Object.values(this.$root.maintenanceList);
result.sort((m1, m2) => {
const now = Date.now();
if (Date.parse(m1.end_date) >= now !== Date.parse(m2.end_date) >= now) {
if (Date.parse(m2.end_date) < now) {
return -1;
}
if (Date.parse(m1.end_date) < now) {
return 1;
}
}
if (Date.parse(m1.end_date) >= now && Date.parse(m2.end_date) >= now) {
if (Date.parse(m1.end_date) < Date.parse(m2.end_date)) {
return -1;
}
if (Date.parse(m2.end_date) < Date.parse(m1.end_date)) {
return 1;
}
}
if (Date.parse(m1.end_date) < now && Date.parse(m2.end_date) < now) {
if (Date.parse(m1.end_date) < Date.parse(m2.end_date)) {
return 1;
}
if (Date.parse(m2.end_date) < Date.parse(m1.end_date)) {
return -1;
}
}
return m1.title.localeCompare(m2.title);
});
// Simple filter by search text
// finds maintenance name
if (this.searchText !== "") {
const loweredSearchText = this.searchText.toLowerCase();
result = result.filter(maintenance => {
return maintenance.title.toLowerCase().includes(loweredSearchText)
|| maintenance.description.toLowerCase().includes(loweredSearchText);
});
}
return result;
},
sortedMonitorList() {
let result = Object.values(this.$root.monitorList);
@@ -96,7 +166,7 @@ export default {
// Simple filter by search text
// finds monitor name, tag name or tag value
if (this.searchText != "") {
if (this.searchText !== "") {
const loweredSearchText = this.searchText.toLowerCase();
result = result.filter(monitor => {
return monitor.name.toLowerCase().includes(loweredSearchText)
@@ -112,6 +182,9 @@ export default {
monitorURL(id) {
return getMonitorRelativeURL(id);
},
maintenanceURL(id) {
return getMaintenanceRelativeURL(id);
},
clearSearchText() {
this.searchText = "";
}
@@ -174,4 +247,12 @@ export default {
flex-wrap: wrap;
gap: 0;
}
.bg-maintenance {
background-color: $maintenance;
}
select {
text-align: center;
}
</style>

View File

@@ -24,7 +24,7 @@ import timezone from "dayjs/plugin/timezone";
import "chartjs-adapter-dayjs";
import { LineChart } from "vue-chart-3";
import { useToast } from "vue-toastification";
import { UP, DOWN, PENDING } from "../util.ts";
import { UP, DOWN, PENDING, MAINTENANCE } from "../util.ts";
dayjs.extend(utc);
dayjs.extend(timezone);
@@ -162,7 +162,8 @@ export default {
},
chartData() {
let pingData = []; // Ping Data for Line Chart, y-axis contains ping time
let downData = []; // Down Data for Bar Chart, y-axis is 1 if target is down, 0 if target is up
let downData = []; // Down Data for Bar Chart, y-axis is 1 if target is down (red color), under maintenance (blue color) or pending (orange color), 0 if target is up
let colorData = []; // Color Data for Bar Chart
let heartbeatList = this.heartbeatList ||
(this.monitorId in this.$root.heartbeatList && this.$root.heartbeatList[this.monitorId]) ||
@@ -184,8 +185,9 @@ export default {
});
downData.push({
x,
y: beat.status === DOWN ? 1 : 0,
y: (beat.status === DOWN || beat.status === MAINTENANCE || beat.status === PENDING) ? 1 : 0,
});
colorData.push((beat.status === MAINTENANCE) ? "rgba(23,71,245,0.41)" : ((beat.status === PENDING) ? "rgba(245,182,23,0.41)" : "#DC354568"))
});
return {
@@ -204,7 +206,7 @@ export default {
type: "bar",
data: downData,
borderColor: "#00000000",
backgroundColor: "#DC354568",
backgroundColor: colorData,
yAxisID: "y1",
barThickness: "flex",
barPercentage: 1,

View File

@@ -146,4 +146,8 @@ export default {
}
}
.bg-maintenance {
background-color: $maintenance;
}
</style>

View File

@@ -22,6 +22,10 @@ export default {
return "warning";
}
if (this.status === 3) {
return "maintenance";
}
return "secondary";
},
@@ -38,6 +42,10 @@ export default {
return this.$t("Pending");
}
if (this.status === 3) {
return this.$t("Maintenance");
}
return this.$t("Unknown");
},
},

View File

@@ -15,6 +15,10 @@ export default {
computed: {
uptime() {
if (this.type === "maintenance") {
return this.$t("Maintenance");
}
let key = this.monitor.id + "_" + this.type;
@@ -26,6 +30,10 @@ export default {
},
color() {
if (this.type === "maintenance" || this.monitor.maintenance) {
return "maintenance"
}
if (this.lastHeartBeat.status === 0) {
return "danger"
}

View File

@@ -34,6 +34,7 @@ import {
faAward,
faLink,
faChevronDown,
faWrench,
} from "@fortawesome/free-solid-svg-icons";
library.add(
@@ -67,6 +68,7 @@ library.add(
faAward,
faLink,
faChevronDown,
faWrench,
);
export { FontAwesomeIcon };

View File

@@ -7,11 +7,13 @@ export default {
upsideDownModeDescription: "Flip the status upside down. If the service is reachable, it is DOWN.",
maxRedirectDescription: "Maximum number of redirects to follow. Set to 0 to disable redirects.",
acceptedStatusCodesDescription: "Select status codes which are considered as a successful response.",
affectedMonitorsDescription: "Select monitors that are affected by current maintenance",
passwordNotMatchMsg: "The repeat password does not match.",
notificationDescription: "Notifications must be assigned to a monitor to function.",
keywordDescription: "Search keyword in plain HTML or JSON response. The search is case-sensitive.",
pauseDashboardHome: "Pause",
deleteMonitorMsg: "Are you sure want to delete this monitor?",
deleteMaintenanceMsg: "Are you sure want to delete this maintenance?",
deleteNotificationMsg: "Are you sure want to delete this notification for all monitors?",
resoverserverDescription: "Cloudflare is the default server. You can change the resolver server anytime.",
rrtypeDescription: "Select the RR type you want to monitor",

View File

@@ -340,7 +340,6 @@ export default {
"No monitors available.": "沒有可用的監測器。",
"Add one": "新增一個",
"No Monitors": "無監測器",
"Add one": "新增一個",
"Untitled Group": "未命名群組",
Services: "服務",
Discard: "捨棄",

View File

@@ -51,7 +51,7 @@
<!-- Mobile Only -->
<div v-if="$root.isMobile" style="width: 100%; height: 60px;" />
<nav v-if="$root.isMobile" class="bottom-nav">
<nav v-if="$root.isMobile" class="bottom-nav scroll">
<router-link to="/dashboard" class="nav-link">
<div><font-awesome-icon icon="tachometer-alt" /></div>
{{ $t("Dashboard") }}
@@ -64,7 +64,12 @@
<router-link to="/add" class="nav-link">
<div><font-awesome-icon icon="plus" /></div>
{{ $t("Add") }}
{{ $t("Add Monitor") }}
</router-link>
<router-link to="/addMaintenance" class="nav-link">
<div><font-awesome-icon icon="wrench" /></div>
{{ $t("Add Maintenance") }}
</router-link>
<router-link to="/settings" class="nav-link">
@@ -201,4 +206,21 @@ main {
}
}
.scroll {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: -ms-autohiding-scrollbar;
}
.scroll::-webkit-scrollbar {
display: none;
}
.scroll a {
flex: 0 0 auto;
min-width: fit-content;
}
</style>

View File

@@ -22,6 +22,16 @@ export default {
return this.datetimeFormat(value, "YYYY-MM-DD HH:mm:ss");
},
datetimeMaintenance(value) {
const inputDate = new Date(value);
const now = new Date(Date.now());
if (inputDate.getFullYear() === now.getFullYear() && inputDate.getMonth() === now.getMonth() && inputDate.getDay() === now.getDay())
return this.datetimeMaintenanceFormat(value, "HH:mm");
else
return this.datetimeMaintenanceFormat(value, "YYYY-MM-DD HH:mm");
},
date(value) {
return this.datetimeFormat(value, "YYYY-MM-DD");
},
@@ -41,6 +51,13 @@ export default {
return dayjs.utc(value).tz(this.timezone).format(format);
}
return "";
},
datetimeMaintenanceFormat(value, format) {
if (value !== undefined && value !== "") {
return dayjs(value).format(format);
}
return "";
}
},

View File

@@ -27,6 +27,7 @@ export default {
allowLoginDialog: false, // Allowed to show login dialog, but "loggedIn" have to be true too. This exists because prevent the login dialog show 0.1s in first before the socket server auth-ed.
loggedIn: false,
monitorList: { },
maintenanceList: { },
heartbeatList: { },
importantHeartbeatList: { },
avgPingList: { },
@@ -99,6 +100,10 @@ export default {
this.monitorList = data;
});
socket.on("maintenanceList", (data) => {
this.maintenanceList = data;
});
socket.on("notificationList", (data) => {
this.notificationList = data;
});
@@ -309,14 +314,37 @@ export default {
socket.emit("getMonitorList", callback);
},
getMaintenanceList(callback) {
if (! callback) {
callback = () => { };
}
socket.emit("getMaintenanceList", callback);
},
add(monitor, callback) {
socket.emit("add", monitor, callback);
},
addMaintenance(maintenance, callback) {
socket.emit("addMaintenance", maintenance, callback);
},
addMonitorMaintenance(maintenanceID, monitors, callback) {
socket.emit("addMonitorMaintenance", maintenanceID, monitors, callback);
},
getMonitorMaintenance(maintenanceID, callback) {
socket.emit("getMonitorMaintenance", maintenanceID, callback);
},
deleteMonitor(monitorID, callback) {
socket.emit("deleteMonitor", monitorID, callback);
},
deleteMaintenance(maintenanceID, callback) {
socket.emit("deleteMaintenance", maintenanceID, callback);
},
clearData() {
console.log("reset heartbeat list");
this.heartbeatList = {};
@@ -368,7 +396,13 @@ export default {
for (let monitorID in this.lastHeartbeatList) {
let lastHeartBeat = this.lastHeartbeatList[monitorID];
if (! lastHeartBeat) {
if (this.monitorList[monitorID].maintenance) {
result[monitorID] = {
text: this.$t("Maintenance"),
color: "maintenance",
};
}
else if (! lastHeartBeat) {
result[monitorID] = unknown;
} else if (lastHeartBeat.status === 1) {
result[monitorID] = {

View File

@@ -4,6 +4,7 @@
<div v-if="! $root.isMobile" class="col-12 col-md-5 col-xl-4">
<div>
<router-link to="/add" class="btn btn-primary mb-3"><font-awesome-icon icon="plus" /> {{ $t("Add New Monitor") }}</router-link>
<router-link to="/addMaintenance" class="btn btn-primary mb-3 float-end"><font-awesome-icon icon="wrench" /> {{ $t("Add New Maintenance") }}</router-link>
</div>
<MonitorList :scrollbar="true" />
</div>

View File

@@ -15,6 +15,10 @@
<h3>{{ $t("Down") }}</h3>
<span class="num text-danger">{{ stats.down }}</span>
</div>
<div class="col">
<h3>{{ $t("Maintenance") }}</h3>
<span class="num text-maintenance">{{ stats.maintenance }}</span>
</div>
<div class="col">
<h3>{{ $t("Unknown") }}</h3>
<span class="num text-secondary">{{ stats.unknown }}</span>
@@ -38,7 +42,7 @@
</thead>
<tbody>
<tr v-for="(beat, index) in displayedRecords" :key="index" :class="{ 'shadow-box': $root.windowWidth <= 550}">
<td><router-link :to="`/dashboard/${beat.monitorID}`">{{ beat.name }}</router-link></td>
<td><router-link :to="`/dashboard/monitor/${beat.monitorID}`">{{ beat.name }}</router-link></td>
<td><Status :status="beat.status" /></td>
<td :class="{ 'border-0':! beat.msg}"><Datetime :value="beat.time" /></td>
<td class="border-0">{{ beat.msg }}</td>
@@ -93,6 +97,7 @@ export default {
let result = {
up: 0,
down: 0,
maintenance: 0,
unknown: 0,
pause: 0,
};
@@ -100,8 +105,11 @@ export default {
for (let monitorID in this.$root.monitorList) {
let beat = this.$root.lastHeartbeatList[monitorID];
let monitor = this.$root.monitorList[monitorID];
if (monitor && ! monitor.active) {
if (monitor && monitor.maintenance) {
result.maintenance++;
}
else if (monitor && !monitor.active) {
result.pause++;
} else if (beat) {
if (beat.status === 1) {
@@ -173,6 +181,14 @@ export default {
display: block;
}
.text-maintenance {
color: $maintenance;
}
.bg-maintenance {
background-color: $maintenance;
}
.shadow-box {
padding: 20px;
}

View File

@@ -499,4 +499,8 @@ table {
margin-left: 0 !important;
}
.bg-maintenance {
background-color: $maintenance;
}
</style>

View File

@@ -0,0 +1,247 @@
<template>
<transition name="slide-fade" appear>
<div>
<h1 class="mb-3">{{ pageName }}</h1>
<form @submit.prevent="submit">
<div class="shadow-box">
<div class="row">
<div class="col-md-6">
<h2 class="mb-2">{{ $t("General") }}</h2>
<!-- Title -->
<div class="my-3">
<label for="name" class="form-label">{{ $t("Title") }}</label>
<input id="name" v-model="maintenance.title" type="text" class="form-control"
:placeholder="titlePlaceholder" required>
</div>
<!-- Description -->
<div class="my-3">
<label for="description" class="form-label">{{ $t("Description") }}</label>
<textarea id="description" v-model="maintenance.description" class="form-control"
:placeholder="descriptionPlaceholder"></textarea>
</div>
<!-- Affected Monitors -->
<div class="my-3">
<label for="affected_monitors" class="form-label">{{ $t("Affected Monitors") }}</label>
<VueMultiselect
id="affected_monitors"
v-model="affectedMonitors"
:options="affectedMonitorsOptions"
track-by="id"
label="name"
:multiple="true"
:allow-empty="false"
:close-on-select="false"
:clear-on-select="false"
:preserve-search="true"
:placeholder="$t('Pick Affected Monitors...')"
:preselect-first="false"
:max-height="600"
:taggable="false"
></VueMultiselect>
<div class="form-text">
{{ $t("affectedMonitorsDescription") }}
</div>
</div>
<!-- Start Date Time -->
<div class="my-3">
<label for="start_date" class="form-label">{{ $t("Start of maintenance") }}</label>
<input :type="'datetime-local'" id="start_date" v-model="maintenance.start_date"
class="form-control" :class="{'darkCalendar': dark }" required>
</div>
<!-- End Date Time -->
<div class="my-3">
<label for="end_date" class="form-label">{{ $t("Expected end of maintenance") }}</label>
<input :type="'datetime-local'" id="end_date" v-model="maintenance.end_date"
class="form-control" :class="{'darkCalendar': dark }" required>
</div>
<div class="mt-5 mb-1">
<button id="monitor-submit-btn" class="btn btn-primary" type="submit"
:disabled="processing">{{ $t("Save") }}
</button>
</div>
</div>
</div>
</div>
</form>
</div>
</transition>
</template>
<script>
import CopyableInput from "../components/CopyableInput.vue";
import {useToast} from "vue-toastification";
import VueMultiselect from "vue-multiselect";
const toast = useToast();
export default {
components: {
CopyableInput,
VueMultiselect,
},
data() {
return {
processing: false,
maintenance: {},
affectedMonitors: [],
affectedMonitorsOptions: [],
dark: (this.$root.theme === "dark"),
};
},
computed: {
pageName() {
return this.$t((this.isAdd) ? "Schedule maintenance" : "Edit");
},
isAdd() {
return this.$route.path === "/addMaintenance";
},
isEdit() {
return this.$route.path.startsWith("/editMaintenance");
},
titlePlaceholder() {
return this.$t("Network infrastructure maintenance");
},
descriptionPlaceholder() {
return this.$t("Example: Network infrastructure maintenance is underway which will affect some of our services.");
}
},
watch: {
"$route.fullPath"() {
this.init();
}
},
mounted() {
this.init();
this.$root.getMonitorList((res) => {
if (res.ok) {
Object.values(this.$root.monitorList).map(monitor => {
this.affectedMonitorsOptions.push({
id: monitor.id,
name: monitor.name
});
});
}
});
},
methods: {
init() {
this.affectedMonitors = [];
if (this.isAdd) {
this.maintenance = {
title: "",
description: "",
start_date: "",
end_date: "",
};
} else if (this.isEdit) {
this.$root.getSocket().emit("getMaintenance", this.$route.params.id, (res) => {
if (res.ok) {
this.maintenance = res.maintenance;
this.$root.getSocket().emit("getMonitorMaintenance", this.$route.params.id, (res) => {
if (res.ok) {
Object.values(res.monitors).map(monitor => {
this.affectedMonitors.push(monitor);
});
} else {
toast.error(res.msg);
}
});
} else {
toast.error(res.msg);
}
});
}
},
async submit() {
this.processing = true;
if (this.affectedMonitors.length === 0) {
toast.error(this.$t("Select at least one affected monitor"));
return this.processing = false;
}
if (this.isAdd) {
this.$root.addMaintenance(this.maintenance, async (res) => {
if (res.ok) {
await this.addMonitorMaintenance(res.maintenanceID, () => {
toast.success(res.msg);
this.processing = false;
this.$root.getMaintenanceList();
this.$router.push("/dashboard/maintenance/" + res.maintenanceID);
});
} else {
toast.error(res.msg);
this.processing = false;
}
});
} else {
this.$root.getSocket().emit("editMaintenance", this.maintenance, async (res) => {
if (res.ok) {
await this.addMonitorMaintenance(res.maintenanceID, () => {
this.processing = false;
this.$root.toastRes(res);
this.init();
});
}
else {
this.processing = false;
toast.error(res.msg);
}
});
}
},
async addMonitorMaintenance(maintenanceID, callback) {
await this.$root.addMonitorMaintenance(maintenanceID, this.affectedMonitors, async (res) => {
if (!res.ok) {
toast.error(res.msg);
} else {
this.$root.getMonitorList();
}
callback();
});
},
},
};
</script>
<style lang="scss" scoped>
.shadow-box {
padding: 20px;
}
textarea {
min-height: 200px;
}
.darkCalendar::-webkit-calendar-picker-indicator {
filter: invert(1);
}
</style>

View File

@@ -509,7 +509,7 @@ export default {
toast.success(res.msg);
this.processing = false;
this.$root.getMonitorList();
this.$router.push("/dashboard/" + res.monitorID);
this.$router.push("/dashboard/monitor/" + res.monitorID);
} else {
toast.error(res.msg);
this.processing = false;

View File

@@ -0,0 +1,141 @@
<template>
<transition name="slide-fade" appear>
<div v-if="maintenance">
<h1> {{ maintenance.title }}</h1>
<p class="url">
<span>Start: {{ $root.datetimeMaintenance(maintenance.start_date) }}</span>
<br>
<span>End: {{ $root.datetimeMaintenance(maintenance.end_date) }}</span>
</p>
<div class="functions" style="margin-top: 10px">
<router-link :to=" '/editMaintenance/' + maintenance.id " class="btn btn-secondary">
<font-awesome-icon icon="edit" /> {{ $t("Edit") }}
</router-link>
<button class="btn btn-danger" @click="deleteDialog">
<font-awesome-icon icon="trash" /> {{ $t("Delete") }}
</button>
</div>
<label for="description" class="form-label" style="margin-top: 20px">{{ $t("Description") }}</label>
<textarea id="description" class="form-control" disabled>{{ maintenance.description }}</textarea>
<label for="affected_monitors" class="form-label" style="margin-top: 20px">{{ $t("Affected Monitors") }}</label>
<br>
<button v-for="monitor in this.affectedMonitors" class="btn btn-monitor" style="margin: 5px; cursor: auto; color: white; font-weight: bold">
{{ monitor }}
</button>
<Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteMaintenance">
{{ $t("deleteMaintenanceMsg") }}
</Confirm>
</div>
</transition>
</template>
<script>
import { useToast } from "vue-toastification";
const toast = useToast();
import Confirm from "../components/Confirm.vue";
export default {
components: {
Confirm,
},
data() {
return {
affectedMonitors: [],
};
},
computed: {
maintenance() {
let id = this.$route.params.id;
return this.$root.maintenanceList[id];
},
},
mounted() {
this.init();
},
methods: {
init() {
this.$root.getSocket().emit("getMonitorMaintenance", this.$route.params.id, (res) => {
if (res.ok) {
this.affectedMonitors = Object.values(res.monitors).map(monitor => monitor.name);
} else {
toast.error(res.msg);
}
});
},
deleteDialog() {
this.$refs.confirmDelete.show();
},
deleteMaintenance() {
this.$root.deleteMaintenance(this.maintenance.id, (res) => {
if (res.ok) {
toast.success(res.msg);
this.$router.push("/dashboard");
} else {
toast.error(res.msg);
}
});
},
},
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
@media (max-width: 550px) {
.functions {
text-align: center;
button, a {
margin-left: 10px !important;
margin-right: 10px !important;
}
}
}
@media (max-width: 400px) {
.btn {
display: inline-flex;
flex-direction: column;
align-items: center;
padding-top: 10px;
}
a.btn {
padding-left: 25px;
padding-right: 25px;
}
}
.url {
color: $primary;
margin-bottom: 20px;
font-weight: bold;
a {
color: $primary;
}
}
.functions {
button, a {
margin-right: 20px;
}
}
textarea {
min-height: 100px;
resize: none;
}
.btn-monitor {
background-color: #5cdd8b;
}
</style>

View File

@@ -144,6 +144,18 @@
</div>
</div>
<!-- Maintenance -->
<div v-if="maintenance.length !== 0" v-for="maintenanceItem in maintenance" class="shadow-box alert mb-4 p-4 maintenance" role="alert" :class="maintenanceClass">
<h4 v-text="maintenanceItem.title" class="alert-heading" />
<div v-text="maintenanceItem.description" class="content" />
<!-- Incident Date -->
<div class="date mt-3">
{{ $t("End") }}: {{ $root.datetimeMaintenance(maintenanceItem.end_date) }} ({{ dateFromNowMaintenance(maintenanceItem.start_date) }})<br />
</div>
</div>
<!-- Overall Status -->
<div class="shadow-box list p-4 overall-status mb-4">
<div v-if="Object.keys($root.publicMonitorList).length === 0 && loadedData">
@@ -167,6 +179,11 @@
{{ $t("Degraded Service") }}
</div>
<div v-else-if="isMaintenance">
<font-awesome-icon icon="wrench" class="statusMaintenance" />
{{ $t("Maintenance") }}
</div>
<div v-else>
<font-awesome-icon icon="question-circle" style="color: #efefef;" />
</div>
@@ -217,7 +234,14 @@
import axios from "axios";
import PublicGroupList from "../components/PublicGroupList.vue";
import ImageCropUpload from "vue-image-crop-upload";
import { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_PARTIAL_DOWN, UP } from "../util.ts";
import {
STATUS_PAGE_ALL_DOWN,
STATUS_PAGE_ALL_UP,
STATUS_PAGE_MAINTENANCE,
STATUS_PAGE_PARTIAL_DOWN,
UP,
MAINTENANCE
} from "../util.ts";
import { useToast } from "vue-toastification";
import dayjs from "dayjs";
const toast = useToast();
@@ -259,6 +283,7 @@ export default {
loadedTheme: false,
loadedData: false,
baseURL: "",
maintenance: [],
};
},
computed: {
@@ -320,6 +345,10 @@ export default {
return "bg-" + this.incident.style;
},
maintenanceClass() {
return "bg-maintenance";
},
overallStatus() {
if (Object.keys(this.$root.publicLastHeartbeatList).length === 0) {
@@ -332,7 +361,10 @@ export default {
for (let id in this.$root.publicLastHeartbeatList) {
let beat = this.$root.publicLastHeartbeatList[id];
if (beat.status === UP) {
if (beat.status === MAINTENANCE) {
return STATUS_PAGE_MAINTENANCE;
}
else if (beat.status === UP) {
hasUp = true;
} else {
status = STATUS_PAGE_PARTIAL_DOWN;
@@ -358,6 +390,10 @@ export default {
return this.overallStatus === STATUS_PAGE_ALL_DOWN;
},
isMaintenance() {
return this.overallStatus === STATUS_PAGE_MAINTENANCE;
},
},
watch: {
@@ -423,6 +459,10 @@ export default {
}
});
axios.get("/api/status-page/maintenance-list").then((res) => {
this.maintenance = res.data;
});
axios.get("/api/status-page/monitor-list").then((res) => {
this.$root.publicGroupList = res.data;
});
@@ -580,6 +620,10 @@ export default {
return dayjs.utc(date).fromNow();
},
dateFromNowMaintenance(date) {
return dayjs(date).fromNow();
},
}
};
</script>
@@ -671,6 +715,22 @@ footer {
}
}
.maintenance {
color: white;
.date {
font-size: 12px;
}
}
.bg-maintenance {
background-color: $maintenance;
}
.statusMaintenance {
color: $maintenance;
}
.mobile {
h1 {
font-size: 22px;

View File

@@ -5,6 +5,7 @@ import Dashboard from "./pages/Dashboard.vue";
import DashboardHome from "./pages/DashboardHome.vue";
import Details from "./pages/Details.vue";
import EditMonitor from "./pages/EditMonitor.vue";
import EditMaintenance from "./pages/EditMaintenance.vue";
import List from "./pages/List.vue";
const Settings = () => import("./pages/Settings.vue");
import Setup from "./pages/Setup.vue";
@@ -18,6 +19,7 @@ import MonitorHistory from "./components/settings/MonitorHistory.vue";
import Security from "./components/settings/Security.vue";
import Backup from "./components/settings/Backup.vue";
import About from "./components/settings/About.vue";
import MaintenanceDetails from "./pages/MaintenanceDetails.vue";
const routes = [
{
@@ -41,7 +43,7 @@ const routes = [
component: DashboardHome,
children: [
{
path: "/dashboard/:id",
path: "/dashboard/monitor/:id",
component: EmptyLayout,
children: [
{
@@ -54,10 +56,28 @@ const routes = [
},
],
},
{
path: "/dashboard/maintenance/:id",
component: EmptyLayout,
children: [
{
path: "",
component: MaintenanceDetails,
},
{
path: "/editMaintenance/:id",
component: EditMaintenance,
},
],
},
{
path: "/add",
component: EditMonitor,
},
{
path: "/addMaintenance",
component: EditMaintenance,
},
{
path: "/list",
component: List,

View File

@@ -7,7 +7,7 @@
// Backend uses the compiled file util.js
// Frontend uses util.ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isDev = void 0;
exports.getMaintenanceRelativeURL = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isDev = void 0;
const _dayjs = require("dayjs");
const dayjs = _dayjs;
exports.isDev = process.env.NODE_ENV === "development";
@@ -15,9 +15,11 @@ exports.appName = "Uptime Kuma";
exports.DOWN = 0;
exports.UP = 1;
exports.PENDING = 2;
exports.MAINTENANCE = 3;
exports.STATUS_PAGE_ALL_DOWN = 0;
exports.STATUS_PAGE_ALL_UP = 1;
exports.STATUS_PAGE_PARTIAL_DOWN = 2;
exports.STATUS_PAGE_MAINTENANCE = 3;
function flipStatus(s) {
if (s === exports.UP) {
return exports.DOWN;
@@ -162,6 +164,10 @@ function genSecret(length = 64) {
}
exports.genSecret = genSecret;
function getMonitorRelativeURL(id) {
return "/dashboard/" + id;
return "/dashboard/monitor/" + id;
}
exports.getMonitorRelativeURL = getMonitorRelativeURL;
function getMaintenanceRelativeURL(id) {
return "/dashboard/maintenance/" + id;
}
exports.getMaintenanceRelativeURL = getMaintenanceRelativeURL;

View File

@@ -14,10 +14,12 @@ export const appName = "Uptime Kuma";
export const DOWN = 0;
export const UP = 1;
export const PENDING = 2;
export const MAINTENANCE = 3;
export const STATUS_PAGE_ALL_DOWN = 0;
export const STATUS_PAGE_ALL_UP = 1;
export const STATUS_PAGE_PARTIAL_DOWN = 2;
export const STATUS_PAGE_MAINTENANCE = 3;
export function flipStatus(s: number) {
@@ -185,5 +187,9 @@ export function genSecret(length = 64) {
}
export function getMonitorRelativeURL(id: string) {
return "/dashboard/" + id;
return "/dashboard/monitor/" + id;
}
export function getMaintenanceRelativeURL(id: string) {
return "/dashboard/maintenance/" + id;
}