feat:mutiple incident support

This commit is contained in:
joebnb 2024-08-02 18:12:30 +08:00
parent cd5644d6d2
commit 0d39d03b04
4 changed files with 167 additions and 105 deletions

1
.gitignore vendored
View file

@ -4,6 +4,7 @@ dist
dist-ssr dist-ssr
*.local *.local
.idea .idea
.history/
/data /data
!/data/.gitkeep !/data/.gitkeep

View file

@ -106,16 +106,10 @@ class StatusPage extends BeanModel {
static async getStatusPageData(statusPage) { static async getStatusPageData(statusPage) {
const config = await statusPage.toPublicJSON(); const config = await statusPage.toPublicJSON();
// Incident // Incident List
let incident = await R.findOne("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [ const incidentList = await StatusPage.getIncidentList(statusPage.id);
statusPage.id,
]);
if (incident) { const maintenanceList = await StatusPage.getMaintenanceList(statusPage.id);
incident = incident.toPublicJSON();
}
let maintenanceList = await StatusPage.getMaintenanceList(statusPage.id);
// Public Group List // Public Group List
const publicGroupList = []; const publicGroupList = [];
@ -133,7 +127,7 @@ class StatusPage extends BeanModel {
// Response // Response
return { return {
config, config,
incident, incidentList,
publicGroupList, publicGroupList,
maintenanceList, maintenanceList,
}; };
@ -329,6 +323,29 @@ class StatusPage extends BeanModel {
return []; return [];
} }
} }
/**
* Get list of incidents
* @param {number} statusPageId ID of status page to get incidents for
* @returns {object} Object representing incidents sanitized for public
*/
static async getIncidentList(statusPageId) {
try {
const publicIncidentList = [];
let incidentList = await R.find("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [
statusPageId,
]);
for (const incident of incidentList) {
publicIncidentList.push(await incident.toPublicJSON());
}
return publicIncidentList;
} catch (error) {
return [];
}
}
} }
module.exports = StatusPage; module.exports = StatusPage;

View file

@ -26,9 +26,6 @@ module.exports.statusPageSocketHandler = (socket) => {
throw new Error("slug is not found"); throw new Error("slug is not found");
} }
await R.exec("UPDATE incident SET pin = 0 WHERE status_page_id = ? ", [
statusPageID
]);
let incidentBean; let incidentBean;
@ -69,14 +66,15 @@ module.exports.statusPageSocketHandler = (socket) => {
} }
}); });
socket.on("unpinIncident", async (slug, callback) => { socket.on("unpinIncident", async (slug, incident, callback) => {
try { try {
checkLogin(socket); checkLogin(socket);
let statusPageID = await StatusPage.slugToID(slug); let statusPageID = await StatusPage.slugToID(slug);
await R.exec("UPDATE incident SET pin = 0 WHERE pin = 1 AND status_page_id = ? ", [ await R.exec("UPDATE incident SET pin = 0 WHERE pin = 1 AND status_page_id = ? AND id = ? ", [
statusPageID statusPageID,
incident.id
]); ]);
callback({ callback({

View file

@ -169,7 +169,7 @@
</div> </div>
<div v-else> <div v-else>
<button class="btn btn-primary btn-add-group me-2" @click="createIncident"> <button class="btn btn-primary btn-add-group me-2" :disabled="enableEditIncidentMode" @click="createIncident">
<font-awesome-icon icon="bullhorn" /> <font-awesome-icon icon="bullhorn" />
{{ $t("Create Incident") }} {{ $t("Create Incident") }}
</button> </button>
@ -177,62 +177,64 @@
</div> </div>
<!-- Incident --> <!-- Incident -->
<div v-if="incident !== null" class="shadow-box alert mb-4 p-4 incident" role="alert" :class="incidentClass"> <template v-for="(incidentItem, index) in incidentList" :key="incidentItem.id">
<strong v-if="editIncidentMode">{{ $t("Title") }}:</strong> <div v-if="incidentItem !== null" :ref="`incident-${index}`" class="shadow-box alert mb-4 p-4 incident incident-hook" role="alert" :class="incidentClass(index)">
<Editable v-model="incident.title" tag="h4" :contenteditable="editIncidentMode" :noNL="true" class="alert-heading" /> <strong v-if="editIncidentMode(index)">{{ $t("Title") }}:</strong>
<Editable v-model="incidentItem.title" tag="h4" :contenteditable="editIncidentMode(index)" :noNL="true" class="alert-heading" />
<strong v-if="editIncidentMode">{{ $t("Content") }}:</strong> <strong v-if="editIncidentMode(index)">{{ $t("Content") }}:</strong>
<Editable v-if="editIncidentMode" v-model="incident.content" tag="div" :contenteditable="editIncidentMode" class="content" /> <Editable v-if="editIncidentMode(index)" v-model="incidentItem.content" tag="div" :contenteditable="editIncidentMode(index)" class="content" />
<div v-if="editIncidentMode" class="form-text"> <div v-if="editIncidentMode(index)" class="form-text">
{{ $t("markdownSupported") }} {{ $t("markdownSupported") }}
</div> </div>
<!-- eslint-disable-next-line vue/no-v-html--> <!-- eslint-disable-next-line vue/no-v-html-->
<div v-if="! editIncidentMode" class="content" v-html="incidentHTML"></div> <div v-if="! editIncidentMode(index)" class="content" v-html="incidentHTML(index)"></div>
<!-- Incident Date --> <!-- Incident Date -->
<div class="date mt-3"> <div class="date mt-3">
{{ $t("Date Created") }}: {{ $root.datetime(incident.createdDate) }} ({{ dateFromNow(incident.createdDate) }})<br /> {{ $t("Date Created") }}: {{ $root.datetime(incidentItem.createdDate) }} ({{ dateFromNow(incidentItem.createdDate) }})<br />
<span v-if="incident.lastUpdatedDate"> <span v-if="incidentItem.lastUpdatedDate">
{{ $t("Last Updated") }}: {{ $root.datetime(incident.lastUpdatedDate) }} ({{ dateFromNow(incident.lastUpdatedDate) }}) {{ $t("Last Updated") }}: {{ $root.datetime(incidentItem.lastUpdatedDate) }} ({{ dateFromNow(incidentItem.lastUpdatedDate) }})
</span> </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>
</div> </div>
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="unpinIncident"> <div v-if="editMode" class="mt-3">
<font-awesome-icon icon="unlink" /> <button v-if="editIncidentMode(index)" class="btn btn-light me-2" @click="postIncident">
{{ $t("Delete") }} <font-awesome-icon icon="bullhorn" />
</button> {{ $t("Post") }}
</button>
<button v-if="!editIncidentMode(index) && incidentItem.id" class="btn btn-light me-2" :disabled="editIncidentMode(editIncidentIndex)" @click="editIncident(index)">
<font-awesome-icon icon="edit" />
{{ $t("Edit") }}
</button>
<button v-if="editIncidentMode(index)" class="btn btn-light me-2" @click="cancelIncident">
<font-awesome-icon icon="times" />
{{ $t("Cancel") }}
</button>
<div v-if="editIncidentMode(index)" 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(incidentItem.style) }}
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
<li><a class="dropdown-item" href="#" @click="incidentItem.style = 'info'">{{ $t("info") }}</a></li>
<li><a class="dropdown-item" href="#" @click="incidentItem.style = 'warning'">{{ $t("warning") }}</a></li>
<li><a class="dropdown-item" href="#" @click="incidentItem.style = 'danger'">{{ $t("danger") }}</a></li>
<li><a class="dropdown-item" href="#" @click="incidentItem.style = 'primary'">{{ $t("primary") }}</a></li>
<li><a class="dropdown-item" href="#" @click="incidentItem.style = 'light'">{{ $t("light") }}</a></li>
<li><a class="dropdown-item" href="#" @click="incidentItem.style = 'dark'">{{ $t("dark") }}</a></li>
</ul>
</div>
<button v-if="!editIncidentMode(index) && incidentItem.id" class="btn btn-light me-2" @click="unpinIncident(index)">
<font-awesome-icon icon="unlink" />
{{ $t("Delete") }}
</button>
</div>
</div> </div>
</div> </template>
<!-- Overall Status --> <!-- Overall Status -->
<div class="shadow-box list p-4 overall-status mb-4"> <div class="shadow-box list p-4 overall-status mb-4">
@ -370,18 +372,18 @@ import "prismjs/components/prism-css";
import "prismjs/themes/prism-tomorrow.css"; // import syntax highlighting styles import "prismjs/themes/prism-tomorrow.css"; // import syntax highlighting styles
import ImageCropUpload from "vue-image-crop-upload"; import ImageCropUpload from "vue-image-crop-upload";
// import Prism Editor // import Prism Editor
import DOMPurify from "dompurify";
import { marked } from "marked";
import VueMultiselect from "vue-multiselect";
import { PrismEditor } from "vue-prism-editor"; import { PrismEditor } from "vue-prism-editor";
import "vue-prism-editor/dist/prismeditor.min.css"; // import the styles somewhere import "vue-prism-editor/dist/prismeditor.min.css"; // import the styles somewhere
import { useToast } from "vue-toastification"; import { useToast } from "vue-toastification";
import { marked } from "marked";
import DOMPurify from "dompurify";
import Confirm from "../components/Confirm.vue"; import Confirm from "../components/Confirm.vue";
import PublicGroupList from "../components/PublicGroupList.vue";
import MaintenanceTime from "../components/MaintenanceTime.vue"; import MaintenanceTime from "../components/MaintenanceTime.vue";
import { getResBaseURL } from "../util-frontend"; import PublicGroupList from "../components/PublicGroupList.vue";
import { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE } from "../util.ts";
import Tag from "../components/Tag.vue"; import Tag from "../components/Tag.vue";
import VueMultiselect from "vue-multiselect"; import { getResBaseURL } from "../util-frontend";
import { MAINTENANCE, STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP } from "../util.ts";
const toast = useToast(); const toast = useToast();
dayjs.extend(duration); dayjs.extend(duration);
@ -434,10 +436,12 @@ export default {
slug: null, slug: null,
enableEditMode: false, enableEditMode: false,
enableEditIncidentMode: false, enableEditIncidentMode: false,
editIncidentIndex: -1,
hasToken: false, hasToken: false,
config: {}, config: {},
selectedMonitor: null, selectedMonitor: null,
incident: null, incident: null,
incidentList: [],
previousIncident: null, previousIncident: null,
showImageCropUpload: false, showImageCropUpload: false,
imgDataUrl: "/icon.svg", imgDataUrl: "/icon.svg",
@ -508,10 +512,6 @@ export default {
return this.enableEditMode && this.$root.socket.connected; return this.enableEditMode && this.$root.socket.connected;
}, },
editIncidentMode() {
return this.enableEditIncidentMode;
},
isPublished() { isPublished() {
return this.config.published; return this.config.published;
}, },
@ -525,10 +525,6 @@ export default {
return {}; return {};
}, },
incidentClass() {
return "bg-" + this.incident.style;
},
maintenanceClass() { maintenanceClass() {
return "bg-maintenance"; return "bg-maintenance";
}, },
@ -577,14 +573,6 @@ export default {
return this.overallStatus === STATUS_PAGE_MAINTENANCE; return this.overallStatus === STATUS_PAGE_MAINTENANCE;
}, },
incidentHTML() {
if (this.incident.content != null) {
return DOMPurify.sanitize(marked(this.incident.content));
} else {
return "";
}
},
descriptionHTML() { descriptionHTML() {
if (this.config.description != null) { if (this.config.description != null) {
return DOMPurify.sanitize(marked(this.config.description)); return DOMPurify.sanitize(marked(this.config.description));
@ -710,7 +698,7 @@ export default {
this.imgDataUrl = this.config.icon; this.imgDataUrl = this.config.icon;
} }
this.incident = res.data.incident; this.incidentList = res.data.incidentList;
this.maintenanceList = res.data.maintenanceList; this.maintenanceList = res.data.maintenanceList;
this.$root.publicGroupList = res.data.publicGroupList; this.$root.publicGroupList = res.data.publicGroupList;
@ -952,17 +940,31 @@ export default {
* @returns {void} * @returns {void}
*/ */
createIncident() { createIncident() {
this.enableEditIncidentMode = true; const incidentListLength = this.incidentList.length - 1;
// if not contain id we assume it wasn't save and if cancel it's will lost all contents
if (this.incidentList.some(item => !item.id)) {
this.$root.toastError("Please input title and content");
return;
} else {
this.enableEditIncidentMode = true;
const incident = {
title: "",
content: "",
style: "primary",
};
if (this.incident) { this.incidentList.push(incident);
this.previousIncident = this.incident; this.incident = incident;
} }
this.incident = { this.editIncidentIndex = incidentListLength + 1;
title: "",
content: "", // scroll previous into view,cause new not created
style: "primary", const lastIncidentRef = this.$refs?.[`incident-${incidentListLength}`]?.[0];
}; lastIncidentRef && lastIncidentRef.scrollIntoView({
behavior: "smooth",
block: "start",
});
}, },
/** /**
@ -979,7 +981,7 @@ export default {
if (res.ok) { if (res.ok) {
this.enableEditIncidentMode = false; this.enableEditIncidentMode = false;
this.incident = res.incident; this.incidentList[this.editIncidentIndex] = res.incident;
} else { } else {
this.$root.toastError(res.msg); this.$root.toastError(res.msg);
} }
@ -990,10 +992,13 @@ export default {
/** /**
* Click Edit Button * Click Edit Button
* @param {number} index index of incedentList
* @returns {void} * @returns {void}
*/ */
editIncident() { editIncident(index) {
this.enableEditIncidentMode = true; this.enableEditIncidentMode = true;
this.incident = this.incidentList[index];
this.editIncidentIndex = index;
this.previousIncident = Object.assign({}, this.incident); this.previousIncident = Object.assign({}, this.incident);
}, },
@ -1005,18 +1010,28 @@ export default {
this.enableEditIncidentMode = false; this.enableEditIncidentMode = false;
if (this.previousIncident) { if (this.previousIncident) {
this.incident = this.previousIncident; this.incidentList[this.editIncidentIndex] = this.previousIncident;
this.previousIncident = null; this.previousIncident = null;
} }
// a new incident cancel should remove them
if (!this.incident?.id) {
this.incidentList.splice(this.editIncidentIndex, 1);
}
this.incident = null;
this.editIncidentIndex = -1;
}, },
/** /**
* Unpin the incident * Unpin the incident with id
* @param {number} index of incident
* @returns {void} * @returns {void}
*/ */
unpinIncident() { unpinIncident(index) {
this.$root.getSocket().emit("unpinIncident", this.slug, () => { const incident = this.incidentList[index];
this.$root.getSocket().emit("unpinIncident", this.slug, incident, () => {
this.incident = null; this.incident = null;
this.incidentList.splice(index, 1);
}); });
}, },
@ -1051,6 +1066,37 @@ export default {
} }
}, },
/**
* Generate sanitized HTML from maintenance description
* @param {number} index Text to sanitize
* @returns {string} Sanitized HTML
*/
incidentClass(index) {
return "bg-" + this.incidentList[index].style;
},
/**
* Generate sanitized HTML from incident description
* @param {number} index Text to sanitize
* @returns {string} Sanitized HTML
*/
incidentHTML(index) {
if (this.incidentList[index].content != null) {
return DOMPurify.sanitize(marked(this.incidentList[index].content));
} else {
return "";
}
},
/**
* Generate sanitized HTML from incident description
* @param {number} index Text to sanitize
* @returns {string} Sanitized HTML
*/
editIncidentMode(index) {
return this.enableEditIncidentMode && this.editIncidentIndex === index;
},
} }
}; };
</script> </script>