This commit is contained in:
Mason Coloretti 2025-05-29 06:39:28 +00:00 committed by GitHub
commit 8c2a7bd2d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1703 additions and 3819 deletions

View file

@ -8,5 +8,8 @@
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
"license": "ISC",
"dependencies": {
"update-language-files": "file:"
}
}

4953
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -141,6 +141,7 @@
"tcp-ping": "~0.1.1",
"thirty-two": "~1.0.2",
"tough-cookie": "~4.1.3",
"uptime-kuma": "file:",
"ws": "^8.13.0"
},
"devDependencies": {

View file

@ -388,7 +388,6 @@ let needSetup = false;
}
});
socket.on("login", async (data, callback) => {
const clientIP = await server.getClientIP(socket);
@ -693,6 +692,16 @@ let needSetup = false;
}
});
socket.on("fetchIncidentReports", async () => {
try {
const incidentReports = await R.findAll("incident");
socket.emit("incidentReports", incidentReports);
} catch (error) {
console.error(error);
socket.emit("incidentReportsError", { error: "Failed to fetch incident reports" });
}
});
// ***************************
// Auth Only API
// ***************************
@ -754,7 +763,6 @@ let needSetup = false;
});
}
});
// Edit a monitor
socket.on("editMonitor", async (monitor, callback) => {
try {

View file

@ -357,6 +357,50 @@ module.exports.statusPageSocketHandler = (socket) => {
});
}
});
/**
* Get incident history for a status page
*/
socket.on("getStatusPageIncidentHistory", async (slug, callback) => {
try {
const statusPageBean = await R.findOne("status_page", " slug = ? ", [
slug
]);
if (!statusPageBean) {
throw new Error("Status page not found");
}
// Fetch all incidents for this status page, ordered by creation date descending
const incidents = await R.find("incident", " status_page_id = ? ORDER BY created_date DESC ", [
statusPageBean.id
]);
// Convert to public JSON format
const incidentsJSON = incidents.map(incident => {
return {
id: incident.id,
title: incident.title,
content: incident.content,
style: incident.style,
createdDate: incident.created_date,
lastUpdatedDate: incident.last_updated_date,
pin: incident.pin,
active: incident.active
};
});
callback({
ok: true,
incidents: incidentsJSON
});
} catch (error) {
callback({
ok: false,
msg: error.message
});
}
});
};
/**

6
src/languages/en.js Normal file
View file

@ -0,0 +1,6 @@
export default {
// Add translations for Incident History
"Incident History": "Incident History",
"No incident reports found.": "No incident reports found.",
"Loading": "Loading",
};

View file

@ -0,0 +1,79 @@
<template>
<div>
<h1>{{ $t("Incident Reports") }}</h1>
<div v-if="isLoading">Loading...</div>
<div v-else-if="filteredReports.length">
<div
v-for="report in filteredReports"
:key="report._id"
class="big-padding"
>
<h3>{{ datetimeFormat(report._createdDate) }}</h3>
<hr />
<h4>{{ report._title }}</h4>
<p>{{ report._content }}</p>
<hr />
<br /><br />
</div>
</div>
<p v-else>No incident reports found or an error occurred.</p>
</div>
</template>
<script>
export default {
data() {
return {
incidentReports: [],
isLoading: false,
error: null,
};
},
computed: {
filteredReports() {
return this.incidentReports
.slice() // Create a copy to avoid mutating the original array
.sort(
(a, b) =>
new Date(b._createdDate) - new Date(a._createdDate),
)
.slice(-25); // Get the last 25 sorted reports
},
},
mounted() {
this.fetchIncidentReports();
},
methods: {
async fetchIncidentReports() {
this.isLoading = true;
try {
const response = await fetch("/api/incident-reports"); // Replace with your API endpoint
const data = await response.json();
this.incidentReports = data;
} catch (error) {
this.error = error;
console.error("Error fetching incident reports:", error);
} finally {
this.isLoading = false;
}
},
},
};
</script>
<style>
.incident-report-container {
display: flex;
flex-direction: column;
gap: 10px; /* Adjust gap between boxes */
}
.incident-report {
background-color: #fff;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
</style>

View file

@ -41,7 +41,6 @@
{{ $t("Refresh Interval Description", [config.autoRefreshInterval]) }}
</div>
</div>
<div class="my-3">
<label for="switch-theme" class="form-label">{{ $t("Theme") }}</label>
<select id="switch-theme" v-model="config.theme" class="form-select" data-testid="theme-select">
@ -281,6 +280,41 @@
</div>
</template>
<!-- Incident History with improved styling -->
<div class="mb-4 incident-history">
<h2>{{ $t("Incident History") }}</h2>
<div v-if="isLoading">{{ $t("Loading") }}...</div>
<div v-else-if="incidentReports.length">
<div
v-for="report in incidentReports"
:key="report.id"
class="shadow-box alert mb-4 p-4 incident-report"
:class="'bg-' + report.style"
role="alert"
>
<h4 class="alert-heading">{{ report.title }}</h4>
<!-- eslint-disable-next-line vue/no-v-html-->
<div class="content markdown-content" v-html="formatIncidentContent(report.content)"></div>
<div class="incident-meta mt-3">
<div class="incident-date">
<font-awesome-icon icon="calendar-alt" class="me-1" />
{{ $t("Date Created") }}: {{ datetimeFormat(report.createdDate) }}
<span class="text-muted">({{ dateFromNow(report.createdDate) }})</span>
</div>
<div v-if="report.lastUpdatedDate" class="incident-updated">
<font-awesome-icon icon="clock" class="me-1" />
{{ $t("Last Updated") }}: {{ datetimeFormat(report.lastUpdatedDate) }}
<span class="text-muted">({{ dateFromNow(report.lastUpdatedDate) }})</span>
</div>
</div>
</div>
</div>
<p v-else class="text-center py-4">
<font-awesome-icon icon="info-circle" class="me-2" />
{{ $t("No incident reports found.") }}
</p>
</div>
<!-- Description -->
<strong v-if="editMode">{{ $t("Description") }}:</strong>
<Editable v-if="enableEditMode" v-model="config.description" :contenteditable="editMode" tag="div" class="mb-4 description" data-testid="description-editable" />
@ -327,7 +361,25 @@
<!-- 👀 Nothing here, please add a group or a monitor. -->
👀 {{ $t("statusPageNothing") }}
</div>
<div>
<h1>Incident Reports</h1>
<div v-if="isLoading">Loading...</div>
<div v-else-if="filteredReports.length">
<div
v-for="report in filteredReports"
:key="report._id"
class="big-padding"
>
<h3>{{ (report._createdDate) }}</h3>
<hr />
<h4>{{ report._title }}</h4>
<p>{{ report._content }}</p>
<hr />
<br /><br />
</div>
</div>
<p v-else>No incident reports found or an error occurred.</p>
</div>
<PublicGroupList :edit-mode="enableEditMode" :show-tags="config.showTags" :show-certificate-expiry="config.showCertificateExpiry" />
</div>
@ -379,12 +431,14 @@ import DOMPurify from "dompurify";
import Confirm from "../components/Confirm.vue";
import PublicGroupList from "../components/PublicGroupList.vue";
import MaintenanceTime from "../components/MaintenanceTime.vue";
import dateTime from "../mixins/datetime.js";
import { getResBaseURL } from "../util-frontend";
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 VueMultiselect from "vue-multiselect";
const toast = useToast();
dayjs.extend(duration);
const leavePageMsg = "Do you really want to leave? you have unsaved changes!";
@ -407,7 +461,7 @@ export default {
Tag,
VueMultiselect
},
mixins: [ dateTime ],
// Leave Page for vue route change
beforeRouteLeave(to, from, next) {
if (this.editMode) {
@ -429,7 +483,6 @@ export default {
default: null,
},
},
data() {
return {
slug: null,
@ -451,10 +504,24 @@ export default {
updateCountdown: null,
updateCountdownText: null,
loading: true,
isLoading: false,
incidentReports: [],
error: null,
};
},
computed: {
filteredReports() {
for (let reports in this.incidentReports) {
this.datetime(reports._createdDate);
}
return this.incidentReports
.slice()
.sort(
(a, b) =>
new Date(b._createdDate) - new Date(a._createdDate),
)
.slice(-25);
},
logoURL() {
if (this.imgDataUrl.startsWith("data:")) {
return this.imgDataUrl;
@ -462,7 +529,6 @@ export default {
return this.baseURL + this.imgDataUrl;
}
},
/**
* If the monitor is added to public list, which will not be in this list.
* @returns {object[]} List of monitors
@ -731,7 +797,7 @@ export default {
});
this.updateHeartbeatList();
this.fetchIncidentReports();
// Go to edit page if ?edit present
// null means ?edit present, but no value
if (this.$route.query.edit || this.$route.query.edit === null) {
@ -797,7 +863,33 @@ export default {
});
}
},
async fetchIncidentReports() {
this.isLoading = true;
try {
const socket = this.$root.getSocket();
socket.emit("getStatusPageIncidentHistory", this.slug, (data) => {
if (data.ok) {
this.incidentReports = data.incidents;
} else {
this.error = data.msg;
console.error("Error fetching incident reports:", data.msg);
}
this.isLoading = false;
});
} catch (error) {
this.error = error;
console.error("Error fetching incident reports:", error);
this.isLoading = false;
}
},
formatIncidentContent(content) {
// Convert markdown to HTML and sanitize
if (!content) {
return "";
}
return DOMPurify.sanitize(marked(content));
},
/**
* Setup timer to display countdown to refresh
* @returns {void}
@ -1056,216 +1148,157 @@ export default {
};
</script>
<style lang="scss" scoped>
<style lang="scss">
/* Import the variables file to access the theme colors */
@import "../assets/vars.scss";
.overall-status {
font-weight: bold;
font-size: 25px;
/* Remove the scoped attribute from the style tag to allow targeting child components */
.incident-history {
.incident-report {
transition: all 0.3s ease;
border-left: 5px solid;
.ok {
color: $primary;
&.bg-info {
border-left-color: #0dcaf0; /* Hardcoded Bootstrap info color */
}
.warning {
color: $warning;
&.bg-warning {
border-left-color: $warning;
}
.danger {
color: $danger;
}
&.bg-danger {
border-left-color: $danger;
}
h1 {
font-size: 30px;
img {
vertical-align: middle;
height: 60px;
width: 60px;
}
&.bg-primary {
border-left-color: $primary;
}
.main {
transition: all ease-in-out 0.1s;
&.edit {
margin-left: 300px;
}
&.bg-light {
border-left-color: #ccc;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
width: 300px;
height: 100vh;
border-right: 1px solid #ededed;
.danger-zone {
border-top: 1px solid #ededed;
padding-top: 15px;
&.bg-dark {
border-left-color: #333;
}
.sidebar-body {
padding: 0 10px 10px 10px;
overflow-x: hidden;
overflow-y: auto;
height: calc(100% - 70px);
&.bg-maintenance {
border-left-color: $maintenance;
}
.sidebar-footer {
border-top: 1px solid #ededed;
border-right: 1px solid #ededed;
padding: 10px;
width: 300px;
height: 70px;
position: fixed;
left: 0;
bottom: 0;
background-color: white;
display: flex;
align-items: center;
}
}
footer {
text-align: center;
font-size: 14px;
}
.description span {
min-width: 50px;
}
.title-flex {
display: flex;
align-items: center;
gap: 10px;
}
.logo-wrapper {
display: inline-block;
position: relative;
&:hover {
.icon-upload {
transform: scale(1.2);
}
}
.icon-upload {
transition: all $easing-in 0.2s;
position: absolute;
bottom: 6px;
font-size: 20px;
left: -14px;
background-color: white;
padding: 5px;
border-radius: 10px;
cursor: pointer;
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.9);
}
}
.logo {
transition: all $easing-in 0.2s;
&.edit-mode {
cursor: pointer;
&:hover {
transform: scale(1.2);
}
}
}
.incident {
.content {
&[contenteditable="true"] {
min-height: 60px;
}
}
.date {
font-size: 12px;
}
}
.maintenance-bg-info {
color: $maintenance;
}
.maintenance-icon {
font-size: 35px;
vertical-align: middle;
}
.dark .shadow-box {
background-color: #0d1117;
}
.status-maintenance {
color: $maintenance;
margin-right: 5px;
}
.mobile {
h1 {
font-size: 22px;
}
.overall-status {
font-size: 20px;
}
}
.dark {
.sidebar {
background-color: $dark-header-bg;
border-right-color: $dark-border-color;
.danger-zone {
border-top-color: $dark-border-color;
}
.sidebar-footer {
border-right-color: $dark-border-color;
border-top-color: $dark-border-color;
background-color: $dark-header-bg;
}
}
}
.domain-name-list {
li {
display: flex;
align-items: center;
padding: 10px 0 10px 10px;
.domain-input {
flex-grow: 1;
background-color: transparent;
border: none;
color: $dark-font-color;
outline: none;
&::placeholder {
color: #1d2634;
}
}
}
}
.bg-maintenance {
.alert-heading {
font-weight: bold;
margin-bottom: 1rem;
}
.incident-meta {
font-size: 0.85rem;
opacity: 0.8;
border-top: 1px solid rgba(0, 0, 0, 0.1);
padding-top: 0.75rem;
margin-top: 1rem;
.incident-date, .incident-updated {
margin-bottom: 0.25rem;
}
}
}
.refresh-info {
opacity: 0.7;
/* Markdown content styles without using ::v-deep */
.markdown-content {
h1, h2, h3, h4, h5, h6 {
margin-top: 1rem;
margin-bottom: 0.5rem;
}
p {
margin-bottom: 1rem;
}
ul, ol {
margin-bottom: 1rem;
padding-left: 2rem;
}
code {
background-color: rgba(0, 0, 0, 0.05);
padding: 0.2rem 0.4rem;
border-radius: 3px;
}
pre {
background-color: rgba(0, 0, 0, 0.05);
padding: 1rem;
border-radius: 5px;
overflow-x: auto;
}
blockquote {
border-left: 4px solid rgba(0, 0, 0, 0.1);
padding-left: 1rem;
margin-left: 0;
color: rgba(0, 0, 0, 0.6);
}
img {
max-width: 100%;
height: auto;
}
a {
text-decoration: underline;
}
table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1rem;
th, td {
border: 1px solid rgba(0, 0, 0, 0.1);
padding: 0.5rem;
}
th {
background-color: rgba(0, 0, 0, 0.05);
}
}
}
}
/* Dark mode adjustments */
.dark {
.incident-history {
.incident-report {
&.bg-light {
color: $dark-bg;
}
.incident-meta {
border-top-color: rgba(255, 255, 255, 0.1);
}
}
.markdown-content {
code, pre {
background-color: rgba(255, 255, 255, 0.05);
}
blockquote {
border-left-color: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.7);
}
table {
th, td {
border-color: rgba(255, 255, 255, 0.1);
}
th {
background-color: rgba(255, 255, 255, 0.05);
}
}
}
}
}
</style>

View file

@ -7,6 +7,7 @@ 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 ListIncidents from "./pages/ListIncidents.vue";
import List from "./pages/List.vue";
const Settings = () => import("./pages/Settings.vue");
import Setup from "./pages/Setup.vue";
@ -162,6 +163,10 @@ const routes = [
path: "/maintenance/edit/:id",
component: EditMaintenance,
},
{
path: "/incident-history",
component: ListIncidents,
},
],
},
],