New system for creating and managing incidents

This commit is contained in:
Karel Krýda 2022-02-02 12:31:36 +01:00
parent a9df7b4a14
commit 0c75711f99
31 changed files with 4861 additions and 2802 deletions

View file

@ -0,0 +1,37 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
create table incident_dg_tmp
(
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
type VARCHAR(30) DEFAULT 'started' NOT NULL,
style VARCHAR(30) DEFAULT 'info',
title VARCHAR(255),
description TEXT NOT NULL,
user_id INTEGER references user on update cascade on delete set null DEFAULT 1,
override_status BOOLEAN DEFAULT 0 NOT NULL,
status VARCHAR(50),
created_date DATETIME DEFAULT (DATETIME('now')) NOT NULL,
parent_incident INTEGER,
resolved BOOLEAN DEFAULT 0 NOT NULL,
resolved_date DATETIME
);
insert into incident_dg_tmp(id, title, description, created_date) select id, title, content, created_date from incident;
drop table incident;
alter table incident_dg_tmp rename to incident;
create index incident_user_id on incident (user_id);
CREATE TABLE monitor_incident
(
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
monitor_id INTEGER NOT NULL,
incident_id INTEGER NOT NULL,
CONSTRAINT FK_incident FOREIGN KEY (incident_id) REFERENCES incident (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT FK_monitor FOREIGN KEY (monitor_id) REFERENCES monitor (id) ON DELETE CASCADE ON UPDATE CASCADE
);
COMMIT;

5247
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -64,16 +64,16 @@
"axios": "~0.21.4",
"bcryptjs": "~2.4.3",
"bootstrap": "5.1.3",
"bree": "~7.1.0",
"bree": "~7.1.5",
"chardet": "^1.3.0",
"chart.js": "~3.6.0",
"chart.js": "~3.6.2",
"chartjs-adapter-dayjs": "~1.0.0",
"check-password-strength": "^2.0.3",
"command-exists": "~1.2.9",
"compare-versions": "~3.6.0",
"dayjs": "~1.10.7",
"express": "~4.17.1",
"express-basic-auth": "~1.2.0",
"express": "~4.17.2",
"express-basic-auth": "~1.2.1",
"form-data": "~4.0.0",
"http-graceful-shutdown": "~3.1.5",
"iconv-lite": "^0.6.3",
@ -84,7 +84,7 @@
"notp": "~2.0.3",
"password-hash": "~1.2.2",
"postcss-rtlcss": "~3.4.1",
"postcss-scss": "~4.0.2",
"postcss-scss": "~4.0.3",
"prom-client": "~13.2.0",
"prometheus-api-metrics": "~3.2.0",
"qrcode": "~1.5.0",
@ -112,10 +112,10 @@
"@actions/github": "~5.0.0",
"@babel/eslint-parser": "~7.15.8",
"@babel/preset-env": "^7.15.8",
"@types/bootstrap": "~5.1.6",
"@vitejs/plugin-legacy": "~1.6.3",
"@types/bootstrap": "~5.1.9",
"@vitejs/plugin-legacy": "~1.6.4",
"@vitejs/plugin-vue": "~1.9.4",
"@vue/compiler-sfc": "~3.2.22",
"@vue/compiler-sfc": "~3.2.29",
"babel-plugin-rewire": "~1.2.0",
"core-js": "~3.18.3",
"cross-env": "~7.0.3",
@ -123,7 +123,7 @@
"eslint": "~7.32.0",
"eslint-plugin-vue": "~7.18.0",
"jest": "~27.2.5",
"jest-puppeteer": "~6.0.0",
"jest-puppeteer": "~6.0.3",
"puppeteer": "~10.4.0",
"sass": "~1.42.1",
"stylelint": "~14.2.0",

View file

@ -53,6 +53,7 @@ class Database {
"patch-2fa-invalidate-used-token.sql": true,
"patch-notification_sent_history.sql": true,
"patch-monitor-basic-auth.sql": true,
"patch-incident-system.sql": true,
}
/**

View file

@ -7,10 +7,28 @@ class Incident extends BeanModel {
id: this.id,
style: this.style,
title: this.title,
content: this.content,
pin: this.pin,
description: this.description,
overrideStatus: this.overrideStatus,
status: this.status,
createdDate: this.createdDate,
lastUpdatedDate: this.lastUpdatedDate,
resolved: this.resolved,
resolvedDate: this.resolvedDate,
};
}
toJSON() {
return {
id: this.id,
type: this.type,
style: this.style,
title: this.title,
description: this.description,
overrideStatus: this.overrideStatus,
status: this.status,
createdDate: this.createdDate,
parentIncident: this.parentIncident,
resolved: this.resolved,
resolvedDate: this.resolvedDate,
};
}
}

View file

@ -107,23 +107,53 @@ router.get("/api/status-page/config", async (_request, response) => {
response.json(config);
});
// Status Page - Get the current Incident
// Status Page - Incidents List
// Can fetch only if published
router.get("/api/status-page/incident", async (_, response) => {
router.get("/api/status-page/incidents", async (_, response) => {
allowDevAllOrigin(response);
try {
await checkPublished();
let incident = await R.findOne("incident", " pin = 1 AND active = 1");
let incidentList = [];
let incidents = await R.find("incident", " parent_incident IS NULL");
if (incident) {
incident = incident.toPublicJSON();
for (let incident of incidents) {
incidentList.push(await incident.toPublicJSON());
}
response.json({
ok: true,
incident,
incidents: incidentList
});
} catch (error) {
send403(response, error.message);
}
});
// Status Page - Single Incident
// Can fetch only if published
router.get("/api/status-page/incident/:incident", async (request, response) => {
allowDevAllOrigin(response);
try {
await checkPublished();
let incidentID = request.params.incident;
let incidents = await R.find("incident", " id = ? OR parent_incident = ?", [
incidentID,
incidentID
]);
let monitors = await R.getAll("SELECT monitor.name FROM monitor_incident mi JOIN monitor ON mi.monitor_id = monitor.id WHERE mi.incident_id = ? ", [
incidentID
]);
response.json({
ok: true,
incidents,
monitors
});
} catch (error) {

View file

@ -132,6 +132,7 @@ const { sendNotificationList, sendHeartbeatList, sendImportantHeartbeatList, sen
const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler");
const databaseSocketHandler = require("./socket-handlers/database-socket-handler");
const TwoFA = require("./2fa");
const apicache = require("./modules/apicache");
app.use(express.json());
@ -162,6 +163,12 @@ let jwtSecret = null;
*/
let monitorList = {};
/**
* Main incident list
* @type {{}}
*/
let incidentList = {};
/**
* Show Setup Page
* @type {boolean}
@ -533,7 +540,7 @@ exports.entryPage = "dashboard";
// ***************************
// Add a new monitor
socket.on("add", async (monitor, callback) => {
socket.on("addMonitor", async (monitor, callback) => {
try {
checkLogin(socket);
let bean = R.dispense("monitor");
@ -625,6 +632,215 @@ exports.entryPage = "dashboard";
}
});
// Add a new incident
socket.on("addIncident", async (incident, callback) => {
try {
checkLogin(socket);
let bean = R.dispense("incident");
bean.import(incident);
bean.user_id = socket.userID;
await R.store(bean);
await sendIncidentList(socket);
callback({
ok: true,
msg: "Added Successfully.",
incidentID: bean.id,
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("editIncident", async (incident, callback) => {
try {
checkLogin(socket);
let bean = await R.findOne("incident", " id = ? ", [ incident.id ]);
if (bean.user_id !== socket.userID) {
throw new Error("Permission denied.");
}
bean.type = incident.type;
bean.style = incident.style;
bean.title = incident.title;
bean.description = incident.description;
bean.overrideStatus = incident.overrideStatus;
bean.status = incident.status;
await R.store(bean);
await sendIncidentList(socket);
callback({
ok: true,
msg: "Saved.",
incidentID: bean.id,
});
} catch (e) {
console.error(e);
callback({
ok: false,
msg: e.message,
});
}
});
// Add a new monitor_incident
socket.on("addMonitorIncident", async (incidentID, monitors, callback) => {
try {
checkLogin(socket);
await R.exec("DELETE FROM monitor_incident WHERE incident_id = ?", [
incidentID
]);
for await (const monitor of monitors) {
let bean = R.dispense("monitor_incident");
bean.import({
monitor_id: monitor.id,
incident_id: incidentID
});
await R.store(bean);
}
apicache.clear();
callback({
ok: true,
msg: "Added Successfully.",
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("resolveIncident", async (incidentID, description, callback) => {
try {
checkLogin(socket);
await checkIncidentOwner(socket.userID, incidentID);
console.log(`Resolve Incident: ${incidentID} User ID: ${socket.userID}`);
await R.exec("UPDATE incident SET resolved = 1, resolved_date = DATETIME('now') WHERE id = ? AND user_id = ? ", [
incidentID,
socket.userID,
]);
// Add new incident update
let bean = R.dispense("incident");
let incident = {
type: "resolved",
description,
parentIncident: incidentID,
};
bean.import(incident);
bean.user_id = socket.userID;
await R.store(bean);
await sendIncidentList(socket);
callback({
ok: true,
msg: "Resolved Successfully.",
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("postIncidentUpdate", async (incidentID, description, callback) => {
try {
checkLogin(socket);
await checkIncidentOwner(socket.userID, incidentID);
console.log(`Post update for Incident: ${incidentID} User ID: ${socket.userID}`);
// Add new incident update
let bean = R.dispense("incident");
let incident = {
type: "update",
description,
parentIncident: incidentID,
};
bean.import(incident);
bean.user_id = socket.userID;
await R.store(bean);
callback({
ok: true,
msg: "Update Posted Successfully.",
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("reopenIncident", async (incidentID, description, callback) => {
try {
checkLogin(socket);
await checkIncidentOwner(socket.userID, incidentID);
console.log(`Reopen Incident: ${incidentID} User ID: ${socket.userID}`);
await R.exec("UPDATE incident SET resolved = 0, resolved_date = NULL WHERE id = ? AND user_id = ? ", [
incidentID,
socket.userID,
]);
// Add new incident update
let bean = R.dispense("incident");
let incident = {
type: "reopened",
description,
parentIncident: incidentID,
};
bean.import(incident);
bean.user_id = socket.userID;
await R.store(bean);
await sendIncidentList(socket);
callback({
ok: true,
msg: "Reopened Successfully.",
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("getMonitorList", async (callback) => {
try {
checkLogin(socket);
@ -665,6 +881,70 @@ exports.entryPage = "dashboard";
}
});
socket.on("getIncidentList", async (callback) => {
try {
checkLogin(socket);
await sendIncidentList(socket);
callback({
ok: true,
});
} catch (e) {
console.error(e);
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("getIncident", async (incidentID, callback) => {
try {
checkLogin(socket);
console.log(`Get Incident: ${incidentID} User ID: ${socket.userID}`);
let bean = await R.findOne("incident", " id = ? AND user_id = ? ", [
incidentID,
socket.userID,
]);
callback({
ok: true,
incident: await bean.toJSON(),
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("getMonitorIncident", async (incidentID, callback) => {
try {
checkLogin(socket);
console.log(`Get Monitors for Incident: ${incidentID} User ID: ${socket.userID}`);
let monitors = await R.getAll("SELECT monitor.id, monitor.name FROM monitor_incident mi JOIN monitor ON mi.monitor_id = monitor.id WHERE mi.incident_id = ? ", [
incidentID,
]);
callback({
ok: true,
monitors,
});
} catch (e) {
console.error(e);
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("getMonitorBeats", async (monitorID, period, callback) => {
try {
checkLogin(socket);
@ -769,6 +1049,36 @@ exports.entryPage = "dashboard";
}
});
socket.on("deleteIncident", async (incidentID, callback) => {
try {
checkLogin(socket);
console.log(`Delete Incident: ${incidentID} User ID: ${socket.userID}`);
if (incidentID in incidentList) {
delete incidentList[incidentID];
}
await R.exec("DELETE FROM incident WHERE id = ? AND user_id = ? ", [
incidentID,
socket.userID,
]);
callback({
ok: true,
msg: "Deleted Successfully.",
});
await sendIncidentList(socket);
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("getTags", async (callback) => {
try {
checkLogin(socket);
@ -1388,17 +1698,35 @@ async function checkOwner(userID, monitorID) {
}
}
async function checkIncidentOwner(userID, incidentID) {
let row = await R.getRow("SELECT id FROM incident WHERE id = ? AND user_id = ? ", [
incidentID,
userID,
]);
if (! row) {
throw new Error("You do not own this incident.");
}
}
async function sendMonitorList(socket) {
let list = await getMonitorJSONList(socket.userID);
io.to(socket.userID).emit("monitorList", list);
return list;
}
async function sendIncidentList(socket) {
let list = await getIncidentJSONList(socket.userID);
io.to(socket.userID).emit("incidentList", list);
return list;
}
async function afterLogin(socket, user) {
socket.userID = user.id;
socket.join(user.id);
let monitorList = await sendMonitorList(socket);
sendIncidentList(socket);
sendNotificationList(socket);
await sleep(500);
@ -1430,6 +1758,20 @@ async function getMonitorJSONList(userID) {
return result;
}
async function getIncidentJSONList(userID) {
let result = {};
let incidentList = await R.find("incident", " parent_incident IS NULL AND user_id = ? ORDER BY title", [
userID,
]);
for (let incident of incidentList) {
result[incident.id] = await incident.toJSON();
}
return result;
}
async function initDatabase(testMode = false) {
if (! fs.existsSync(Database.path)) {
console.log("Copying Database");

95
src/assets/_timeline.scss Normal file
View file

@ -0,0 +1,95 @@
.incident-timeline {
/* Variables */
$outline: #00b277;
$background: #00b277;
$color-primary: rgba(153, 160, 164, 0.59);
$color-light: white;
$spacing: 50px;
$radius: 4px;
$date: 120px;
$dotBorder: 3px;
$dot: 15px;
$line: 4px;
/* Base */
#timeline-content {
margin-top: $spacing;
text-align: center;
}
/* Timeline */
@media (max-width: 500px) {
.timeline {
max-width: 40% !important;
}
}
.timeline {
border-left: $line solid $color-primary;
border-bottom-right-radius: $radius;
border-top-right-radius: $radius;
background: fade($color-light, 3%);
margin: $spacing auto;
letter-spacing: 0.5px;
position: relative;
line-height: 1.4em;
font-size: 1.03em;
padding: $spacing;
list-style: none;
text-align: left;
max-width: 60%;
.event {
border-bottom: 1px dashed fade($color-light, 10%);
padding-bottom: ($spacing * 0.5);
margin-bottom: $spacing;
position: relative;
&:last-of-type {
padding-bottom: 0;
margin-bottom: 0;
border: none;
}
&:before, &:after {
position: absolute;
display: block;
top: 0;
}
&:before {
left: ((($date * 0.6) + $spacing + $line + $dot + ($dotBorder * 2)) * 1.5) * -1;
color: fade($color-light, 40%);
content: attr(data-date);
text-align: right;
font-weight: bolder;
font-size: 1em;
min-width: $date;
}
&:after {
box-shadow: 0 0 0 $dotBorder fade($color-primary, 100%);
left: ($spacing + $line + ($dot * 0.35)) * -1;
border: $dotBorder solid lighten($background, 5%);
background: $color-light;
border-radius: 50%;
height: $dot;
width: $dot;
content: "";
top: 5px;
}
}
}
.date {
font-size: 12px;
}
}
.dark .incident-timeline .timeline .event:after {
background: #0d1117 !important;
}

View file

@ -6,7 +6,7 @@
<h5 id="exampleModalLabel" class="modal-title">
{{ $t("Confirm") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" @click="no" />
</div>
<div class="modal-body">
<slot />
@ -15,7 +15,7 @@
<button type="button" class="btn" :class="btnStyle" data-bs-dismiss="modal" @click="yes">
{{ yesText }}
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @click="no">
{{ noText }}
</button>
</div>
@ -55,6 +55,9 @@ export default {
yes() {
this.$emit("yes");
},
no() {
this.$emit("no");
},
},
}
</script>

View file

@ -1,7 +1,13 @@
<template>
<div class="shadow-box mb-3">
<div class="list-header">
<div class="placeholder"></div>
<div class="search-wrapper float-start" style="margin-left: 5px;">
<font-awesome-icon icon="filter" />
<select v-model="selectedList" class="form-control" style="margin-left: 5px">
<option value="monitors" selected>{{$t('Monitors')}}</option>
<option value="incidents" selected>{{$t('Incidents')}}</option>
</select>
</div>
<div class="search-wrapper">
<a v-if="searchText == ''" class="search-icon">
<font-awesome-icon icon="search" />
@ -13,11 +19,14 @@
</div>
</div>
<div class="monitor-list" :class="{ scrollbar: scrollbar }">
<div v-if="Object.keys($root.monitorList).length === 0" class="text-center mt-3">
{{ $t("No Monitors, please") }} <router-link to="/add">{{ $t("add one") }}</router-link>
<div v-if="Object.keys($root.monitorList).length === 0 && selectedList === 'monitors'" class="text-center mt-3">
{{ $t("No Monitors, please") }} <router-link to="/addMonitor">{{ $t("add one") }}</router-link>
</div>
<div v-if="Object.keys($root.incidentList).length === 0 && selectedList === 'incidents'" class="text-center mt-3">
{{ $t("No Incidents, please") }} <router-link to="/addIncident">{{ $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 === 'monitors'" 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">
@ -39,6 +48,24 @@
</div>
</div>
</router-link>
<router-link v-if="selectedList === 'incidents'" v-for="(item, index) in sortedIncidentList" :key="index" :to="incidentURL(item.id)" class="item" :class="{ 'disabled': item.resolved }">
<div class="row">
<div class="col-9 col-md-8 small-padding">
<div class="info incident-info">
<font-awesome-icon v-if="item.resolved" icon="check-circle"
class="incident-icon incident-bg-resolved"/>
<font-awesome-icon v-else-if="item.style === 'info'" icon="info-circle"
class="incident-icon incident-bg-info"/>
<font-awesome-icon v-else-if="item.style === 'warning'"
icon="exclamation-triangle"
class="incident-icon incident-bg-warning"/>
<font-awesome-icon v-else-if="item.style === 'critical'" icon="exclamation-circle"
class="incident-icon incident-bg-danger"/>
{{ item.title }}
</div>
</div>
</div>
</router-link>
</div>
</div>
</template>
@ -47,7 +74,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 { getMonitorRelativeURL, getIncidentRelativeURL } from "../util.ts";
export default {
components: {
@ -63,6 +90,7 @@ export default {
data() {
return {
searchText: "",
selectedList: "monitors",
};
},
computed: {
@ -105,6 +133,46 @@ export default {
});
}
return result;
},
sortedIncidentList() {
let result = Object.values(this.$root.incidentList);
result.sort((i1, i2) => {
if (i1.resolved !== i2.resolved) {
if (i1.resolved) {
return 1;
}
if (i2.resolved) {
return -1;
}
}
else {
if (Date.parse(i1.createdDate) > Date.parse(i2.createdDate)) {
return -1;
}
if (Date.parse(i2.createdDate) < Date.parse(i1.createdDate)) {
return 1;
}
}
return i1.title.localeCompare(i2.title);
});
// Simple filter by search text
// finds monitor name, tag name or tag value
if (this.searchText != "") {
const loweredSearchText = this.searchText.toLowerCase();
result = result.filter(incident => {
return incident.name.toLowerCase().includes(loweredSearchText)
|| incident.description.toLowerCase().includes(loweredSearchText)
});
}
return result;
},
},
@ -112,6 +180,9 @@ export default {
monitorURL(id) {
return getMonitorRelativeURL(id);
},
incidentURL(id) {
return getIncidentRelativeURL(id);
},
clearSearchText() {
this.searchText = "";
}
@ -174,4 +245,37 @@ export default {
flex-wrap: wrap;
gap: 0;
}
select {
text-align: center;
}
.incident-info {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
column-gap: 10px;
}
.incident-bg-resolved {
color: rgba(84, 220, 53, 0.52);
}
.incident-bg-info {
color: rgba(53, 162, 220, 0.52);
}
.incident-bg-warning {
color: rgba(255, 165, 0, 0.52);
}
.incident-bg-danger {
color: #dc354585;
}
.incident-icon {
font-size: 20px;
vertical-align: middle;
}
</style>

View file

@ -0,0 +1,111 @@
<template>
<div class="mb-5 ">
<h2 class="incident-title">{{ $t("Incident History") }}</h2>
<div class="shadow-box monitor-list mt-4 position-relative">
<div v-if="Object.values($root.publicIncidentsList).filter(incident => incident.resolved).length === 0"
class="text-center">
{{ $t("No Incidents") }}
</div>
<!-- Incident List -->
<template
v-for="incident in Object.values($root.publicIncidentsList).filter(incident => incident.resolved).sort((i1, i2) => Date.parse(i2.resolvedDate) - Date.parse(i1.resolvedDate)).slice(0, 5)">
<router-link :to="'/incident/' + incident.id">
<div class="item">
<div class="row">
<div class="col-12 col-md-12 small-padding">
<div class="info">
<p class="title">{{ incident.title }}</p>
<p class="description">{{ incident.description }}</p>
</div>
<div class="sub-info">
<span>{{ $t("Resolved") }}</span>
<font-awesome-icon icon="circle" class="dot"/>
<span>{{ $root.datetime(incident.resolvedDate) }}</span>
</div>
</div>
</div>
</div>
</router-link>
</template>
<template
v-if="Object.values($root.publicIncidentsList).filter((incident) => incident.resolved).length > 5">
<div class="item item-link">
<div class="row">
<div class="col-12 col-md-12 small-padding">
<span class="title d-flex justify-content-end">
<router-link :to="'/incidents'">
<span>{{ $t("Show all incidents") }} <font-awesome-icon icon="chevron-right"
class="chevron-right"/></span>
</router-link>
</span>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script>
export default {
components: {},
props: {},
data() {
return {};
},
computed: {},
created() {
},
methods: {}
};
</script>
<style lang="scss" scoped>
@import "../assets/vars";
.monitor-list {
min-height: 46px;
}
.mobile {
.item {
padding: 13px 0 10px 0;
}
}
a {
text-decoration: none;
}
.info {
.title {
font-weight: 700;
font-size: 1rem;
}
white-space: normal !important;
}
.sub-info {
font-size: .875rem;
color: #637381;
}
.dot {
font-size: 5px;
vertical-align: middle;
margin: 0 5px;
}
.chevron-right {
font-size: 13px;
}
.item-link:hover {
background-color: inherit !important;
}
</style>

View file

@ -34,6 +34,14 @@ import {
faAward,
faLink,
faChevronDown,
faHeartbeat,
faFilter,
faCircle,
faInfoCircle,
faExclamationTriangle,
faChevronLeft,
faChevronRight,
faExclamation,
} from "@fortawesome/free-solid-svg-icons";
library.add(
@ -67,6 +75,14 @@ library.add(
faAward,
faLink,
faChevronDown,
faHeartbeat,
faFilter,
faCircle,
faInfoCircle,
faExclamationTriangle,
faChevronLeft,
faChevronRight,
faExclamation,
);
export { FontAwesomeIcon };

View file

@ -38,6 +38,19 @@ export default {
"Check Update On GitHub": "Check Update On GitHub",
List: "List",
Add: "Add",
Monitor: "Monitor",
Incident: "Incident",
Monitors: "Monitors",
Incidents: "Incidents",
"Incident History": "Incident History",
"No Incidents": "No Incidents",
Opened: "Opened",
started: "Started",
update: "Update",
resolved: "Resolved",
reopened: "Reopened",
backToStatus: "Back to status page",
backToIncidents: "Back to incident history",
"Add New Monitor": "Add New Monitor",
"Quick Stats": "Quick Stats",
Up: "Up",
@ -52,6 +65,30 @@ export default {
"No important events": "No important events",
Resume: "Resume",
Edit: "Edit",
"Edit Monitor": "Edit Monitor",
"Edit Incident": "Edit Incident",
Type: "Type",
"Affected Monitors": "Affected Monitors",
"Pick Affected Monitors...": "Pick Affected Monitors...",
affectedMonitorsIncident: "Select monitors that are affected by current incident",
overrideStatus: "Override status",
overrideStatusDescription: "Override overall status on status page",
"Select status": "Select status",
Operational: "Operational",
Degraded: "Degraded",
"Partial outage": "Partial outage",
"Full outage": "Full outage",
Informative: "Informative",
Resolved: "Resolved",
Ongoing: "Ongoing",
"Show all incidents": "Show all incidents",
Warning: "Warning",
Critical: "Critical",
"Post update": "Post update",
Resolve: "Resolve",
Reopen: "Reopen",
deleteIncidentMsg: "Are you sure want to delete this incident?",
descriptionRequired: "Please enter a description of the current incident situation.",
Delete: "Delete",
Current: "Current",
Uptime: "Uptime",
@ -112,6 +149,7 @@ export default {
"Remember me": "Remember me",
Login: "Login",
"No Monitors, please": "No Monitors, please",
"No Incidents, please": "No Incidents, please",
"add one": "add one",
"Notification Type": "Notification Type",
Email: "Email",

View file

@ -62,7 +62,7 @@
{{ $t("List") }}
</router-link>
<router-link to="/add" class="nav-link">
<router-link to="/addMonitor" class="nav-link">
<div><font-awesome-icon icon="plus" /></div>
{{ $t("Add") }}
</router-link>

View file

@ -2,6 +2,7 @@ import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
@ -41,6 +42,30 @@ export default {
return dayjs.utc(value).tz(this.timezone).format(format);
}
return "";
},
groupTimesBy(list, timeParamName = 'createdDate') {
let toReturn = {};
for (let listItem of list) {
const year = dayjs.utc(listItem[timeParamName]).tz(this.timezone).format("YYYY");
const month = dayjs.utc(listItem[timeParamName]).tz(this.timezone).format("MM");
if (toReturn[year] == null) {
toReturn[year] = {};
}
if (toReturn[year][month] == null) {
toReturn[year][month] = [];
}
toReturn[year][month].push(listItem);
}
return toReturn;
},
getMonthName(month) {
return dayjs().month(month - 1).format("MMMM");
}
},

View file

@ -11,6 +11,7 @@ export default {
data() {
return {
publicGroupList: [],
publicIncidentsList: [],
};
},
computed: {

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: { },
incidentList: { },
heartbeatList: { },
importantHeartbeatList: { },
avgPingList: { },
@ -99,6 +100,10 @@ export default {
this.monitorList = data;
});
socket.on("incidentList", (data) => {
this.incidentList = data;
});
socket.on("notificationList", (data) => {
this.notificationList = data;
});
@ -309,14 +314,33 @@ export default {
socket.emit("getMonitorList", callback);
},
add(monitor, callback) {
socket.emit("add", monitor, callback);
addMonitor(monitor, callback) {
socket.emit("addMonitor", monitor, callback);
},
deleteMonitor(monitorID, callback) {
socket.emit("deleteMonitor", monitorID, callback);
},
getIncidentList(callback) {
if (! callback) {
callback = () => { };
}
socket.emit("getIncidentList", callback);
},
addIncident(incident, callback) {
socket.emit("addIncident", incident, callback);
},
deleteIncident(incidentID, callback) {
socket.emit("deleteIncident", incidentID, callback);
},
addMonitorIncident(incidentID, monitors, callback) {
socket.emit("addMonitorIncident", incidentID, monitors, callback);
},
clearData() {
console.log("reset heartbeat list");
this.heartbeatList = {};

View file

@ -33,7 +33,7 @@ export default {
return "light";
}
if (this.path === "/status-page" || this.path === "/status") {
if (this.path === "/status-page" || this.path === "/status" || this.path === "/incidents" || this.path.split("/")[1] === "incident") {
return this.statusPageTheme;
} else {
if (this.userTheme === "auto") {

View file

@ -2,8 +2,22 @@
<div class="container-fluid">
<div class="row">
<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>
<div class="dropdown dropdown-create">
<button class="btn btn-primary mb-3 dropdown-toggle" type="button" data-bs-toggle="dropdown">
<font-awesome-icon icon="plus" /> {{ $t("Create") }}
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button type="button" class="dropdown-item" @click="this.$router.push('/addMonitor')">
<font-awesome-icon icon="heartbeat" /> {{ $t("Monitor") }}
</button>
</li>
<li>
<button type="button" class="dropdown-item" @click="this.$router.push('/addIncident')">
<font-awesome-icon icon="exclamation-circle" /> {{ $t("Incident") }}
</button>
</li>
</ul>
</div>
<MonitorList :scrollbar="true" />
</div>
@ -31,7 +45,32 @@ export default {
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
.container-fluid {
width: 98%;
}
.dropdown-create {
display: flex;
justify-content: end;
}
.dark {
.dropdown-create {
ul {
background-color: $dark-bg;
border-color: $dark-bg2;
border-width: 2px;
li button {
color: $dark-font-color;
}
li button:hover {
background-color: $dark-bg2;
}
}
}
}
</style>

View file

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

View file

@ -26,7 +26,7 @@
<button v-if="! monitor.active" class="btn btn-primary" @click="resumeMonitor">
<font-awesome-icon icon="play" /> {{ $t("Resume") }}
</button>
<router-link :to=" '/edit/' + monitor.id " class="btn btn-secondary">
<router-link :to=" '/editMonitor/' + monitor.id " class="btn btn-secondary">
<font-awesome-icon icon="edit" /> {{ $t("Edit") }}
</router-link>
<button class="btn btn-danger" @click="deleteDialog">

294
src/pages/EditIncident.vue Normal file
View file

@ -0,0 +1,294 @@
<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>
<div class="my-3">
<label for="type" class="form-label">{{ $t("Style") }}</label>
<select id="type" v-model="incident.style" class="form-select">
<option value="info">
{{ $t("Informative") }}
</option>
<option value="warning">
{{ $t("Warning") }}
</option>
<option value="critical">
{{ $t("Critical") }}
</option>
</select>
</div>
<!-- Friendly Name -->
<div class="my-3">
<label for="name" class="form-label">{{ $t("Title") }}</label>
<input id="name" v-model="incident.title" type="text" class="form-control" required>
</div>
<!-- Description -->
<div class="my-3">
<label for="description" class="form-label">{{ $t("Description") }}</label>
<textarea id="description" v-model="incident.description"
class="form-control"></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"
: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("affectedMonitorsIncident") }}
</div>
</div>
<h2 class="mt-5 mb-2">{{ $t("Advanced") }}</h2>
<div class="my-3 form-check">
<input id="override-status" class="form-check-input"
type="checkbox" value="" v-model="incident.overrideStatus"
:checked="incident.overrideStatus">
<label class="form-check-label" for="override-status">
{{ $t("overrideStatus") }}
</label>
<div class="form-text">
{{ $t("overrideStatusDescription") }}
</div>
</div>
<div class="my-3" v-if="incident.overrideStatus">
<label for="override-status-value" class="form-label">{{ $t("Select status") }}</label>
<VueMultiselect
class="status-selector"
id="override-status-value"
v-model="incident.status"
:options="overrideStatusOptions"
track-by="status"
label="name"
:multiple="false"
:close-on-select="true"
:clear-on-select="false"
:preserve-search="true"
:placeholder="$t('Select status') + '...'"
:preselect-first="false"
:max-height="600"
:taggable="true"
></VueMultiselect>
</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 {useToast} from "vue-toastification";
import VueMultiselect from "vue-multiselect";
const toast = useToast();
export default {
components: {
VueMultiselect,
},
data() {
return {
processing: false,
incident: {
// Do not add default value here, please check init() method
},
affectedMonitors: [],
affectedMonitorsOptions: [],
overrideStatusOptions: [],
};
},
computed: {
pageName() {
return this.$t((this.isAdd) ? "Create Incident" : "Edit Incident");
},
isAdd() {
return this.$route.path === "/addIncident";
},
isEdit() {
return this.$route.path.startsWith("/editIncident");
},
},
watch: {
"$route.fullPath"() {
this.init();
},
},
mounted() {
this.init();
let overrideStatusOptions = [
{
status: "operational",
name: this.$t("Operational")
},
{
status: "partial-outage",
name: this.$t("Partial outage")
},
{
status: "full-outage",
name: this.$t("Full outage")
},
];
this.$root.getMonitorList((res) => {
if (res.ok) {
Object.values(this.$root.monitorList).map(monitor => {
this.affectedMonitorsOptions.push({
id: monitor.id,
name: monitor.name,
});
});
}
});
this.overrideStatusOptions = overrideStatusOptions;
},
methods: {
init() {
this.affectedMonitors = [];
if (this.isAdd) {
this.incident = {
style: "info",
title: "",
description: "",
overrideStatus: false,
status: "",
};
} else if (this.isEdit) {
this.$root.getSocket().emit("getIncident", this.$route.params.id, (res) => {
if (res.ok) {
this.incident = res.incident;
if (this.incident.status) {
this.incident.status = this.overrideStatusOptions.filter(status => status.status === this.incident.status);
}
this.$root.getSocket().emit("getMonitorIncident", 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.incident.status) {
this.incident.status = this.incident.status.status;
}
if (this.isAdd) {
this.$root.addIncident(this.incident, async (res) => {
if (res.ok) {
await this.addMonitorIncident(res.incidentID, () => {
toast.success(res.msg);
this.processing = false;
this.$root.getIncidentList();
this.$router.push("/dashboard/incident/" + res.incidentID);
});
} else {
toast.error(res.msg);
this.processing = false;
}
});
} else {
this.$root.getSocket().emit("editIncident", this.incident, async (res) => {
if (res.ok) {
await this.addMonitorIncident(res.incidentID, () => {
this.processing = false;
this.$root.toastRes(res);
this.init();
});
} else {
this.processing = false;
toast.error(res.msg);
}
});
}
},
async addMonitorIncident(incidentID, callback) {
await this.$root.addMonitorIncident(incidentID, this.affectedMonitors, async (res) => {
if (!res.ok) {
toast.error(res.msg);
} else {
this.$root.getIncidentList();
}
callback();
});
},
},
};
</script>
<style lang="scss" scoped>
.shadow-box {
padding: 20px;
}
textarea {
min-height: 200px;
}
</style>
<style>
.status-selector span {
line-height: initial !important;
}
</style>

View file

@ -337,15 +337,15 @@ export default {
},
pageName() {
return this.$t((this.isAdd) ? "Add New Monitor" : "Edit");
return this.$t((this.isAdd) ? "Add New Monitor" : "Edit Monitor");
},
isAdd() {
return this.$route.path === "/add";
return this.$route.path === "/addMonitor";
},
isEdit() {
return this.$route.path.startsWith("/edit");
return this.$route.path.startsWith("/editMonitor");
},
pushURL() {
@ -501,7 +501,7 @@ export default {
}
if (this.isAdd) {
this.$root.add(this.monitor, async (res) => {
this.$root.addMonitor(this.monitor, async (res) => {
if (res.ok) {
await this.$refs.tagsManager.submit(res.monitorID);
@ -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,243 @@
<template>
<transition name="slide-fade" appear>
<div v-if="incident">
<h1> {{ incident.title }}</h1>
<p class="date">
<span>{{ $t("Opened") }}: {{ $root.datetime(incident.createdDate) }}</span>
</p>
<p v-if="incident.resolved" class="date">
<span>{{ $t("Resolved") }}: {{ $root.datetime(incident.resolvedDate) }}</span>
</p>
<div class="functions">
<button v-if="!incident.resolved" class="btn btn-primary" @click="resolveDialog">
<font-awesome-icon icon="check"/>
{{ $t("Resolve") }}
</button>
<button v-if="!incident.resolved" class="btn btn-info" @click="updateDialog">
<font-awesome-icon icon="bullhorn"/>
{{ $t("Post update") }}
</button>
<button v-if="incident.resolved" class="btn btn-warning" @click="reopenDialog">
<font-awesome-icon icon="exclamation"/>
{{ $t("Reopen") }}
</button>
<router-link :to=" '/editIncident/' + incident.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>
<div v-if="this.affectedMonitors.length" class="shadow-box table-shadow-box">
<label for="dependent-monitors" class="form-label" style="font-weight: bold">{{
$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: 500">
{{ monitor }}
</button>
</div>
<Confirm ref="confirmUpdate" :yes-text="$t('Post update')" :no-text="$t('Cancel')" @yes="updateIncident"
@no="clear">
<span class="textarea-title">{{ $t("Description") }}:</span>
<textarea id="update-msg" class="form-control" v-model="messages.update"></textarea>
</Confirm>
<Confirm ref="confirmResolve" :yes-text="$t('Resolve')" :no-text="$t('Cancel')" @yes="resolveIncident"
@no="clear">
<span class="textarea-title">{{ $t("Description") }}:</span>
<textarea id="resolve-msg" class="form-control" v-model="messages.resolve"></textarea>
</Confirm>
<Confirm ref="confirmReopen" :yes-text="$t('Reopen')" :no-text="$t('Cancel')" @yes="reopenIncident"
@no="clear">
<span class="textarea-title">{{ $t("Description") }}:</span>
<textarea id="reopen-msg" class="form-control" v-model="messages.reopen"></textarea>
</Confirm>
<Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')"
@yes="deleteIncident">
{{ $t("deleteIncidentMsg") }}
</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 {
messages: {
update: "",
resolve: "",
reopen: "",
},
affectedMonitors: [],
};
},
computed: {
incident() {
let id = this.$route.params.id;
return this.$root.incidentList[id];
},
},
mounted() {
this.init();
},
methods: {
init() {
this.$root.getSocket().emit("getMonitorIncident", this.$route.params.id, (res) => {
if (res.ok) {
Object.values(res.monitors).map(monitor => {
this.affectedMonitors.push(monitor.name);
});
} else {
toast.error(res.msg);
}
});
},
updateIncident() {
if (!this.messages.update.trim().length) {
return toast.error(this.$t("descriptionRequired"));
}
this.$root.getSocket().emit("postIncidentUpdate", this.incident.id, this.messages.update, (res) => {
this.$root.toastRes(res);
});
},
reopenIncident() {
if (!this.messages.reopen.trim().length) {
return toast.error(this.$t("descriptionRequired"));
}
this.$root.getSocket().emit("reopenIncident", this.incident.id, this.messages.reopen, (res) => {
this.$root.toastRes(res);
});
},
resolveIncident() {
if (!this.messages.resolve.trim().length) {
return toast.error(this.$t("descriptionRequired"));
}
this.$root.getSocket().emit("resolveIncident", this.incident.id, this.messages.resolve, (res) => {
this.$root.toastRes(res);
});
},
updateDialog() {
this.$refs.confirmUpdate.show();
},
reopenDialog() {
this.$refs.confirmReopen.show();
},
resolveDialog() {
this.$refs.confirmResolve.show();
},
deleteDialog() {
this.$refs.confirmDelete.show();
},
deleteIncident() {
this.$root.deleteIncident(this.incident.id, (res) => {
if (res.ok) {
toast.success(res.msg);
this.$router.push("/dashboard");
} else {
toast.error(res.msg);
}
});
},
clear() {
this.messages.update = "";
this.messages.resolve = "";
this.messages.reopen = "";
},
},
};
</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;
}
}
.date {
color: $primary;
font-weight: bold;
margin-bottom: 0;
}
.functions {
margin-top: 20px;
button, a {
margin-right: 20px;
}
}
.shadow-box {
padding: 20px;
margin-top: 25px;
}
.textarea-title {
font-size: 17px;
font-weight: bolder;
}
textarea {
margin-top: 10px;
}
.btn-monitor {
color: white;
background-color: #5cdd8b;
}
.dark .btn-monitor {
color: #0d1117 !important;
}
</style>

287
src/pages/IncidentPage.vue Normal file
View file

@ -0,0 +1,287 @@
<template>
<div v-if="loadedTheme" class="container mt-3">
<!-- Logo & Title -->
<h1 class="mb-4">
<!-- Logo -->
<span class="logo-wrapper">
<img :src="logoURL" alt class="logo me-2"/>
</span>
<!-- Title -->
<span>{{ config.title }}</span>
</h1>
<router-link v-if="prevRoute === '/status' || !prevRoute" :to="'/status'">
<span class="route-back">
<font-awesome-icon icon="chevron-left" class="chevron-left"/>
{{ $t("backToStatus") }}
</span>
</router-link>
<router-link v-if="prevRoute === '/incidents'" :to="'/incidents'">
<span class="route-back">
<font-awesome-icon icon="chevron-left" class="chevron-left"/>
{{ $t("backToIncidents") }}
</span>
</router-link>
<!-- Incident -->
<h2 class="incident-title">{{ $t("Incident") }}</h2>
<div class="shadow-box alert mb-4 p-4 incident mt-4 position-relative" role="alert">
<div class="item">
<div class="row">
<div class="col-12 col-md-12">
<div class="div-title d-flex">
<font-awesome-icon v-if="incidentDetails.style === 'info'" icon="info-circle"
class="incident-icon incident-bg-info"/>
<font-awesome-icon v-else-if="incidentDetails.style === 'warning'"
icon="exclamation-triangle"
class="incident-icon incident-bg-warning"/>
<font-awesome-icon v-else-if="incidentDetails.style === 'critical'"
icon="exclamation-circle"
class="incident-icon incident-bg-danger"/>
{{ incidentDetails.title }}
<span class="actual-status">
<span v-if="incidentDetails.resolved" class="status">{{ $t("Resolved") }}</span>
<span v-if="!incidentDetails.resolved" class="status">{{ $t("Ongoing") }}</span>
</span>
</div>
<div v-if="this.affectedMonitors.length" style="margin-top: 10px">
<button v-for="monitor in this.affectedMonitors" class="btn btn-monitor"
style="margin: 5px; cursor: auto; font-weight: 500">
{{ monitor.name }}
</button>
</div>
<div class="incident-timeline">
<div id="timeline-content">
<ul class="timeline">
<li v-for="incident_line in sortedIncidentHistory()" class="event"
:data-date="$t(incident_line.type)">
<p class="description">{{ incident_line.description }}</p>
<p class="date">{{ $t("Created") }}:
{{ $root.datetime(incident_line.createdDate) }}</p>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="mt-5 mb-4">
{{ $t("Powered by") }} <a target="_blank"
href="https://github.com/louislam/uptime-kuma">{{ $t("Uptime Kuma") }}</a>
</footer>
</div>
</template>
<script>
import axios from "axios";
import {useToast} from "vue-toastification";
import dayjs from "dayjs";
const toast = useToast();
export default {
components: {},
data() {
return {
prevRoute: null,
config: {},
imgDataUrl: "/icon.svg",
loadedTheme: false,
loadedData: false,
baseURL: "",
incident: [],
incidentDetails: {},
affectedMonitors: [],
};
},
async beforeRouteEnter(to, from, next) {
//TODO: remember from where I came after refresh
next(vm => {
if (from.path !== "/") {
vm.prevRoute = from.path;
}
})
},
computed: {
logoURL() {
if (this.imgDataUrl.startsWith("data:")) {
return this.imgDataUrl;
} else {
return this.baseURL + this.imgDataUrl;
}
},
isPublished() {
return this.config.statusPagePublished;
},
theme() {
return this.config.statusPageTheme;
},
},
watch: {
// Set Theme
"config.statusPageTheme"() {
this.$root.statusPageTheme = this.config.statusPageTheme;
this.loadedTheme = true;
},
"config.title"(title) {
document.title = title;
}
},
async created() {
// Special handle for dev
const env = process.env.NODE_ENV;
if (env === "development" || localStorage.dev === "dev") {
this.baseURL = location.protocol + "//" + location.hostname + ":3001";
}
},
async mounted() {
axios.get("/api/status-page/config").then((res) => {
this.config = res.data;
if (this.config.logo) {
this.imgDataUrl = this.config.logo;
}
});
axios.get("/api/status-page/incident/" + this.$route.params.id).then((res) => {
if (res.data.ok) {
this.incident = res.data.incidents;
this.incidentDetails = this.sortedIncidentHistory(res.data.incidents)[0];
this.affectedMonitors = res.data.monitors;
}
});
},
methods: {
sortedIncidentHistory() {
return Object.values(this.incident).sort((i1, i2) => Date.parse(i1.createdDate) - Date.parse(i2.createdDate));
},
dateFromNow(date) {
return dayjs.utc(date).fromNow();
},
}
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
@import "../assets/timeline.scss";
h1 {
font-size: 30px;
img {
vertical-align: middle;
height: 60px;
width: 60px;
}
}
a {
text-decoration: none;
}
.route-back {
color: #637381;
.chevron-left {
font-size: 13px;
}
}
.dark .route-back {
color: #b0b7bf;
}
.incident-title {
margin-top: 50px;
}
footer {
text-align: center;
font-size: 14px;
}
.description span {
min-width: 50px;
}
.logo-wrapper {
display: inline-block;
position: relative;
}
.logo {
transition: all $easing-in 0.2s;
}
.mobile {
h1 {
font-size: 22px;
}
.overall-status {
font-size: 20px;
}
}
.div-title {
font-size: 1.5rem;
font-weight: 500;
flex-direction: row;
justify-content: flex-start;
align-items: center;
column-gap: 10px;
}
.incident-bg-info {
color: rgba(53, 162, 220, 0.52);
}
.incident-bg-warning {
color: rgba(255, 165, 0, 0.52);
}
.incident-bg-danger {
color: #dc354585;
}
.incident-icon {
font-size: 30px;
vertical-align: middle;
}
.dark .shadow-box {
background-color: #0d1117;
}
.btn-monitor {
color: white;
background-color: #5cdd8b;
}
.dark .btn-monitor {
color: #0d1117 !important;
}
.actual-status {
margin-top: auto;
color: #637381;
font-size: 13px;
}
</style>

298
src/pages/IncidentsPage.vue Normal file
View file

@ -0,0 +1,298 @@
<template>
<div v-if="loadedTheme" class="container mt-3">
<!-- Logo & Title -->
<h1 class="mb-4">
<!-- Logo -->
<span class="logo-wrapper">
<img :src="logoURL" alt class="logo me-2"/>
</span>
<!-- Title -->
<span>{{ config.title }}</span>
</h1>
<router-link :to="'/status'">
<span class="route-back"><font-awesome-icon icon="chevron-left"
class="chevron-left"/> {{ $t("backToStatus") }}</span>
</router-link>
<!-- Incidents -->
<template v-if="Object.entries(incidents).length">
<div v-for="incidents in sortByYear(incidents)"
class="shadow-box alert mb-4 p-4 incident mt-4 position-relative d-flex flex-column year-box" role="alert">
<h1>{{ incidents[0] }}</h1>
<div v-for="incidents in sortByMonth(incidents[1])"
class="shadow-box alert mb-4 p-4 incident mt-4 position-relative month-box" role="alert">
<h1>{{ $root.getMonthName(incidents[0]) }}</h1>
<div v-for="incident in incidents[1]"
class="shadow-box alert mb-4 p-4 incident mt-4 position-relative incident-box" role="alert">
<div class="item">
<div class="row">
<div class="col-1 col-md-1 d-flex justify-content-center align-items-center">
<font-awesome-icon v-if="incident.style === 'info'" icon="info-circle"
class="incident-icon incident-bg-info"/>
<font-awesome-icon v-if="incident.style === 'warning'" icon="exclamation-triangle"
class="incident-icon incident-bg-warning"/>
<font-awesome-icon v-if="incident.style === 'critical'" icon="exclamation-circle"
class="incident-icon incident-bg-danger"/>
</div>
<div class="col-11 col-md-11">
<router-link :to="'/incident/' + incident.id">
<h4 class="alert-heading">{{ incident.title }}</h4>
</router-link>
<div class="content">{{ incident.description }}</div>
<!-- Incident Date -->
<div class="date mt-3">
{{ $t("Opened") }}: {{ $root.datetime(incident.createdDate) }} ({{
dateFromNow(incident.createdDate)
}})<br/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<footer class="mt-5 mb-4">
{{ $t("Powered by") }} <a target="_blank"
href="https://github.com/louislam/uptime-kuma">{{ $t("Uptime Kuma") }}</a>
</footer>
</div>
</template>
<script>
import axios from "axios";
import {useToast} from "vue-toastification";
import dayjs from "dayjs";
const toast = useToast();
let feedInterval;
export default {
components: {},
data() {
return {
config: {},
imgDataUrl: "/icon.svg",
loadedTheme: false,
loadedData: false,
baseURL: "",
incident: [],
incidents: [],
};
},
computed: {
logoURL() {
if (this.imgDataUrl.startsWith("data:")) {
return this.imgDataUrl;
} else {
return this.baseURL + this.imgDataUrl;
}
},
isPublished() {
return this.config.statusPagePublished;
},
theme() {
return this.config.statusPageTheme;
},
},
watch: {
// Set Theme
"config.statusPageTheme"() {
this.$root.statusPageTheme = this.config.statusPageTheme;
this.loadedTheme = true;
},
"config.title"(title) {
document.title = title;
}
},
async created() {
// Special handle for dev
const env = process.env.NODE_ENV;
if (env === "development" || localStorage.dev === "dev") {
this.baseURL = location.protocol + "//" + location.hostname + ":3001";
}
},
async mounted() {
axios.get("/api/status-page/config").then((res) => {
this.config = res.data;
if (this.config.logo) {
this.imgDataUrl = this.config.logo;
}
});
axios.get("/api/status-page/incidents/").then((res) => {
if (res.data.ok) {
this.incidents = this.$root.groupTimesBy(this.sortedIncidentHistory(res.data.incidents));
}
});
},
methods: {
sortByYear(incidents) {
let result = Object.entries(incidents);
result.sort((y1, y2) => y2[0] - y1[0]);
return result;
},
sortByMonth(incidents) {
let result = Object.entries(incidents);
result.sort((m1, m2) => m2[0] - m1[0]);
return result;
},
sortedIncidentHistory(incidents) {
let result = Object.values(incidents);
result.sort((i1, i2) => {
if (Date.parse(i1.createdDate) > Date.parse(i2.createdDate)) {
return -1;
}
if (Date.parse(i2.createdDate) < Date.parse(i1.createdDate)) {
return 1;
}
});
return result;
},
dateFromNow(date) {
return dayjs.utc(date).fromNow();
},
}
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
@import "../assets/timeline.scss";
h1 {
font-size: 30px;
img {
vertical-align: middle;
height: 60px;
width: 60px;
}
}
a {
text-decoration: none;
}
.route-back {
color: #637381;
.chevron-left {
font-size: 13px;
}
}
.dark .route-back {
color: #b0b7bf;
}
.incident-title {
margin-top: 50px;
}
footer {
text-align: center;
font-size: 14px;
}
.description span {
min-width: 50px;
}
.logo-wrapper {
display: inline-block;
position: relative;
}
.logo {
transition: all $easing-in 0.2s;
}
.mobile {
h1 {
font-size: 22px;
}
.overall-status {
font-size: 20px;
}
}
.div-title {
font-size: 1.5rem;
font-weight: 500;
flex-direction: row;
justify-content: flex-start;
align-items: center;
column-gap: 10px;
}
.incident-bg-info {
color: rgba(53, 162, 220, 0.52);
}
.incident-bg-warning {
color: rgba(255, 165, 0, 0.52);
}
.incident-bg-danger {
color: #dc354585;
}
.incident-icon {
font-size: 30px;
vertical-align: middle;
}
.dark .shadow-box.year-box {
background-color: #0d1117;
}
.dark .shadow-box.month-box {
background-color: #090c11;
}
.dark .shadow-box.incident-box {
background-color: #0d1117;
}
.btn-monitor {
color: white;
background-color: #5cdd8b;
}
.dark .btn-monitor {
color: #0d1117 !important;
}
.actual-status {
margin-top: auto;
color: #637381;
font-size: 13px;
}
</style>

View file

@ -50,11 +50,6 @@
{{ $t("Discard") }}
</button>
<button class="btn btn-primary btn-add-group me-2" @click="createIncident">
<font-awesome-icon icon="bullhorn" />
{{ $t("Create Incident") }}
</button>
<!--
<button v-if="isPublished" class="btn btn-light me-2" @click="">
<font-awesome-icon icon="save" />
@ -91,58 +86,36 @@
</div>
</div>
<!-- Incident -->
<div v-if="incident !== null" class="shadow-box alert mb-4 p-4 incident" role="alert" :class="incidentClass">
<strong v-if="editIncidentMode">{{ $t("Title") }}:</strong>
<Editable v-model="incident.title" tag="h4" :contenteditable="editIncidentMode" :noNL="true" class="alert-heading" />
<!-- Incidents -->
<template v-if="incidents.length">
<div v-for="incident in sortedIncidentsList" class="shadow-box alert mb-4 p-4 incident mt-4 position-relative" role="alert">
<div class="item">
<div class="row">
<div class="col-1 col-md-1 d-flex justify-content-center align-items-center">
<font-awesome-icon v-if="incident.style === 'info'" icon="info-circle"
class="incident-icon incident-bg-info"/>
<font-awesome-icon v-if="incident.style === 'warning'" icon="exclamation-triangle"
class="incident-icon incident-bg-warning"/>
<font-awesome-icon v-if="incident.style === 'critical'" icon="exclamation-circle"
class="incident-icon incident-bg-danger"/>
</div>
<div class="col-11 col-md-11">
<router-link :to="'/incident/' + incident.id">
<h4 class="alert-heading">{{ incident.title }}</h4>
</router-link>
<div class="content">{{ incident.description }}</div>
<strong v-if="editIncidentMode">{{ $t("Content") }}:</strong>
<Editable v-model="incident.content" tag="div" :contenteditable="editIncidentMode" class="content" />
<!-- Incident Date -->
<div class="date mt-3">
{{ $t("Created") }}: {{ $root.datetime(incident.createdDate) }} ({{ dateFromNow(incident.createdDate) }})<br />
<span v-if="incident.lastUpdatedDate">
{{ $t("Last Updated") }}: {{ $root.datetime(incident.lastUpdatedDate) }} ({{ dateFromNow(incident.lastUpdatedDate) }})
</span>
</div>
<div v-if="editMode" class="mt-3">
<button v-if="editIncidentMode" class="btn btn-light me-2" @click="postIncident">
<font-awesome-icon icon="bullhorn" />
{{ $t("Post") }}
</button>
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="editIncident">
<font-awesome-icon icon="edit" />
{{ $t("Edit") }}
</button>
<button v-if="editIncidentMode" class="btn btn-light me-2" @click="cancelIncident">
<font-awesome-icon icon="times" />
{{ $t("Cancel") }}
</button>
<div v-if="editIncidentMode" class="dropdown d-inline-block me-2">
<button id="dropdownMenuButton1" class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ $t("Style") }}: {{ $t(incident.style) }}
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
<li><a class="dropdown-item" href="#" @click="incident.style = 'info'">{{ $t("info") }}</a></li>
<li><a class="dropdown-item" href="#" @click="incident.style = 'warning'">{{ $t("warning") }}</a></li>
<li><a class="dropdown-item" href="#" @click="incident.style = 'danger'">{{ $t("danger") }}</a></li>
<li><a class="dropdown-item" href="#" @click="incident.style = 'primary'">{{ $t("primary") }}</a></li>
<li><a class="dropdown-item" href="#" @click="incident.style = 'light'">{{ $t("light") }}</a></li>
<li><a class="dropdown-item" href="#" @click="incident.style = 'dark'">{{ $t("dark") }}</a></li>
</ul>
<!-- Incident Date -->
<div class="date mt-3">
{{ $t("Opened") }}: {{ $root.datetime(incident.createdDate) }} ({{
dateFromNow(incident.createdDate)
}})<br/>
</div>
</div>
</div>
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="unpinIncident">
<font-awesome-icon icon="unlink" />
{{ $t("Unpin") }}
</button>
</div>
</div>
</template>
<!-- Overall Status -->
<div class="shadow-box list p-4 overall-status mb-4">
@ -152,9 +125,9 @@
</div>
<template v-else>
<div v-if="allUp">
<font-awesome-icon icon="check-circle" class="ok" />
{{ $t("All Systems Operational") }}
<div v-if="allDown">
<font-awesome-icon icon="times-circle" class="danger" />
{{ $t("Degraded Service") }}
</div>
<div v-else-if="partialDown">
@ -162,9 +135,9 @@
{{ $t("Partially Degraded Service") }}
</div>
<div v-else-if="allDown">
<font-awesome-icon icon="times-circle" class="danger" />
{{ $t("Degraded Service") }}
<div v-else-if="allUp">
<font-awesome-icon icon="check-circle" class="ok" />
{{ $t("All Systems Operational") }}
</div>
<div v-else>
@ -193,7 +166,7 @@
</select>
</div>
<div v-else class="text-center">
{{ $t("No monitors available.") }} <router-link to="/add">{{ $t("Add one") }}</router-link>
{{ $t("No monitors available.") }} <router-link to="/addMonitor">{{ $t("Add one") }}</router-link>
</div>
</div>
</div>
@ -207,6 +180,10 @@
<PublicGroupList :edit-mode="enableEditMode" />
</div>
<div class="mb-4">
<PublicIncidentsList />
</div>
<footer class="mt-5 mb-4">
{{ $t("Powered by") }} <a target="_blank" href="https://github.com/louislam/uptime-kuma">{{ $t("Uptime Kuma" ) }}</a>
</footer>
@ -216,6 +193,7 @@
<script>
import axios from "axios";
import PublicGroupList from "../components/PublicGroupList.vue";
import PublicIncidentsList from "../components/PublicIncidentsList.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 { useToast } from "vue-toastification";
@ -229,7 +207,8 @@ let feedInterval;
export default {
components: {
PublicGroupList,
ImageCropUpload
PublicIncidentsList,
ImageCropUpload,
},
// Leave Page for vue route change
@ -248,20 +227,56 @@ export default {
data() {
return {
enableEditMode: false,
enableEditIncidentMode: false,
hasToken: false,
config: {},
selectedMonitor: null,
incident: null,
previousIncident: null,
showImageCropUpload: false,
imgDataUrl: "/icon.svg",
loadedTheme: false,
loadedData: false,
baseURL: "",
incidents: [],
overrideStatus: {},
};
},
computed: {
sortedIncidentsList() {
let result = Object.values(this.incidents).filter((incident) => !incident.resolved);
result.sort((i1, i2) => {
if (i1.style !== i2.style) {
if (i1.style === "critical") {
return -1;
}
if (i2.style === "critical") {
return 1;
}
if (i1.style === "warning") {
return -1;
}
if (i2.style === "warning") {
return 1;
}
}
else {
if (Date.parse(i1.createdDate) > Date.parse(i2.createdDate)) {
return -1;
}
if (Date.parse(i2.createdDate) < Date.parse(i1.createdDate)) {
return 1;
}
}
return i1.title.localeCompare(i2.title);
});
return result;
},
logoURL() {
if (this.imgDataUrl.startsWith("data:")) {
@ -291,10 +306,6 @@ export default {
return this.enableEditMode && this.$root.socket.connected;
},
editIncidentMode() {
return this.enableEditIncidentMode;
},
isPublished() {
return this.config.statusPagePublished;
},
@ -347,15 +358,27 @@ export default {
},
allUp() {
return this.overallStatus === STATUS_PAGE_ALL_UP;
if (this.overrideStatus.override) {
return this.overrideStatus.allUp;
} else {
return this.overallStatus === STATUS_PAGE_ALL_UP;
}
},
partialDown() {
return this.overallStatus === STATUS_PAGE_PARTIAL_DOWN;
if (this.overrideStatus.override) {
return this.overrideStatus.partialDown;
} else {
return this.overallStatus === STATUS_PAGE_PARTIAL_DOWN;
}
},
allDown() {
return this.overallStatus === STATUS_PAGE_ALL_DOWN;
if (this.overrideStatus.override) {
return this.overrideStatus.allDown;
} else {
return this.overallStatus === STATUS_PAGE_ALL_DOWN;
}
},
},
@ -417,9 +440,17 @@ export default {
}
});
axios.get("/api/status-page/incident").then((res) => {
axios.get("/api/status-page/incidents").then((res) => {
if (res.data.ok) {
this.incident = res.data.incident;
this.incidents = res.data.incidents;
this.$root.publicIncidentsList = res.data.incidents;
this.overrideStatus = {
override: Object.values(this.incidents).filter((incident) => !incident.resolved && incident.overrideStatus).length !== 0,
allUp: Object.values(this.incidents).filter((incident) => !incident.resolved && incident.overrideStatus && incident.status === "operational").length !== 0,
partialDown: Object.values(this.incidents).filter((incident) => !incident.resolved && incident.overrideStatus && incident.status === "partial-outage").length !== 0,
allDown: Object.values(this.incidents).filter((incident) => !incident.resolved && incident.overrideStatus && incident.status === "full-outage").length !== 0,
};
}
});
@ -520,62 +551,9 @@ export default {
}
},
createIncident() {
this.enableEditIncidentMode = true;
if (this.incident) {
this.previousIncident = this.incident;
}
this.incident = {
title: "",
content: "",
style: "primary",
};
},
postIncident() {
if (this.incident.title == "" || this.incident.content == "") {
toast.error(this.$t("Please input title and content"));
return;
}
this.$root.getSocket().emit("postIncident", this.incident, (res) => {
if (res.ok) {
this.enableEditIncidentMode = false;
this.incident = res.incident;
} else {
toast.error(res.msg);
}
});
},
/**
* Click Edit Button
*/
editIncident() {
this.enableEditIncidentMode = true;
this.previousIncident = Object.assign({}, this.incident);
},
cancelIncident() {
this.enableEditIncidentMode = false;
if (this.previousIncident) {
this.incident = this.previousIncident;
this.previousIncident = null;
}
},
unpinIncident() {
this.$root.getSocket().emit("unpinIncident", () => {
this.incident = null;
});
},
dateFromNow(date) {
return dayjs.utc(date).fromNow();
},
@ -586,6 +564,7 @@ export default {
<style lang="scss" scoped>
@import "../assets/vars.scss";
@import "../assets/timeline.scss";
.overall-status {
font-weight: bold;
@ -659,18 +638,6 @@ footer {
}
}
.incident {
.content {
&[contenteditable=true] {
min-height: 60px;
}
}
.date {
font-size: 12px;
}
}
.mobile {
h1 {
font-size: 22px;
@ -681,4 +648,33 @@ footer {
}
}
.incident.info {
background-color: #0c4128;
}
.incident-bg-info {
color: rgba(53, 162, 220, 0.52);
}
.incident-bg-warning {
color: rgba(255, 165, 0, 0.52);
}
.incident-bg-danger {
color: #dc354585;
}
.incident a {
text-decoration: none;
}
.incident-icon {
font-size: 30px;
vertical-align: middle;
}
.dark .shadow-box {
background-color: #0d1117;
}
</style>

View file

@ -4,11 +4,15 @@ import Layout from "./layouts/Layout.vue";
import Dashboard from "./pages/Dashboard.vue";
import DashboardHome from "./pages/DashboardHome.vue";
import Details from "./pages/Details.vue";
import IncidentDetails from "./pages/IncidentDetails.vue";
import EditMonitor from "./pages/EditMonitor.vue";
import EditIncident from "./pages/EditIncident.vue";
import List from "./pages/List.vue";
const Settings = () => import("./pages/Settings.vue");
import Setup from "./pages/Setup.vue";
const StatusPage = () => import("./pages/StatusPage.vue");
const IncidentPage = () => import("./pages/IncidentPage.vue");
const IncidentsPage = () => import("./pages/IncidentsPage.vue");
import Entry from "./pages/Entry.vue";
import Appearance from "./components/settings/Appearance.vue";
@ -41,7 +45,7 @@ const routes = [
component: DashboardHome,
children: [
{
path: "/dashboard/:id",
path: "/dashboard/monitor/:id",
component: EmptyLayout,
children: [
{
@ -49,15 +53,33 @@ const routes = [
component: Details,
},
{
path: "/edit/:id",
path: "/editMonitor/:id",
component: EditMonitor,
},
],
},
{
path: "/add",
path: "/dashboard/incident/:id",
component: EmptyLayout,
children: [
{
path: "",
component: IncidentDetails,
},
{
path: "/editIncident/:id",
component: EditIncident,
},
],
},
{
path: "/addMonitor",
component: EditMonitor,
},
{
path: "/addIncident",
component: EditIncident,
},
{
path: "/list",
component: List,
@ -113,7 +135,21 @@ const routes = [
},
{
path: "/status",
component: StatusPage,
component: EmptyLayout,
children: [
{
path: "",
component: StatusPage,
},
{
path: "/incident/:id",
component: IncidentPage,
},
{
path: "/incidents",
component: IncidentsPage,
},
],
},
];

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.getIncidentRelativeURL = 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;
const _dayjs = require("dayjs");
const dayjs = _dayjs;
exports.isDev = process.env.NODE_ENV === "development";
@ -162,6 +162,10 @@ function genSecret(length = 64) {
}
exports.genSecret = genSecret;
function getMonitorRelativeURL(id) {
return "/dashboard/" + id;
return "/dashboard/monitor/" + id;
}
exports.getMonitorRelativeURL = getMonitorRelativeURL;
function getIncidentRelativeURL(id) {
return "/dashboard/incident/" + id;
}
exports.getIncidentRelativeURL = getIncidentRelativeURL;

View file

@ -185,5 +185,9 @@ export function genSecret(length = 64) {
}
export function getMonitorRelativeURL(id: string) {
return "/dashboard/" + id;
return "/dashboard/monitor/" + id;
}
export function getIncidentRelativeURL(id: string) {
return "/dashboard/incident/" + id;
}

View file

@ -66,7 +66,7 @@ describe("Init", () => {
it("should create monitor", async () => {
// Create monitor
await page.goto(baseURL + "/add");
await page.goto(baseURL + "/addMonitor");
await page.waitForSelector("#name");
await page.type("#name", "Myself");