incident history foundation

This commit is contained in:
ryana 2025-12-09 07:25:05 +08:00
parent 2135adfed5
commit 1a9748de6f
8 changed files with 1018 additions and 51 deletions

View File

@ -1,7 +1,20 @@
const { BeanModel } = require("redbean-node/dist/bean-model");
const { R } = require("redbean-node");
const dayjs = require("dayjs");
class Incident extends BeanModel {
/**
* Resolve the incident and mark it as inactive
* @returns {Promise<void>}
*/
async resolve() {
this.active = false;
this.pin = false;
this.lastUpdatedDate = R.isoDateTime(dayjs.utc());
await R.store(this);
}
/**
* Return an object that ready to parse to JSON for public
* Only show necessary data to public
@ -13,11 +26,23 @@ class Incident extends BeanModel {
style: this.style,
title: this.title,
content: this.content,
pin: this.pin,
pin: !!this.pin,
active: !!this.active,
createdDate: this.createdDate,
lastUpdatedDate: this.lastUpdatedDate,
};
}
/**
* Return full object for admin use
* @returns {object} Object ready to parse
*/
toJSON() {
return {
...this.toPublicJSON(),
status_page_id: this.status_page_id,
};
}
}
module.exports = Incident;

View File

@ -263,14 +263,11 @@ class StatusPage extends BeanModel {
static async getStatusPageData(statusPage) {
const config = await statusPage.toPublicJSON();
// Incident
let incident = await R.findOne("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [
// All active incidents
let incidents = await R.find("incident", " pin = 1 AND active = 1 AND status_page_id = ? ORDER BY created_date DESC", [
statusPage.id,
]);
if (incident) {
incident = incident.toPublicJSON();
}
incidents = incidents.map(i => i.toPublicJSON());
let maintenanceList = await StatusPage.getMaintenanceList(statusPage.id);
@ -290,7 +287,7 @@ class StatusPage extends BeanModel {
// Response
return {
config,
incident,
incidents,
publicGroupList,
maintenanceList,
};

View File

@ -147,6 +147,43 @@ router.get("/api/status-page/:slug/manifest.json", cache("1440 minutes"), async
}
});
router.get("/api/status-page/:slug/incident-history", cache("5 minutes"), async (request, response) => {
allowDevAllOrigin(response);
try {
let slug = request.params.slug;
slug = slug.toLowerCase();
let statusPageID = await StatusPage.slugToID(slug);
if (!statusPageID) {
sendHttpError(response, "Status Page Not Found");
return;
}
const page = parseInt(request.query.page) || 1;
const limit = 10;
const offset = (page - 1) * limit;
const incidents = await R.find("incident",
" status_page_id = ? ORDER BY created_date DESC LIMIT ? OFFSET ? ",
[ statusPageID, limit, offset ]
);
const total = await R.count("incident", " status_page_id = ? ", [ statusPageID ]);
response.json({
ok: true,
incidents: incidents.map(i => i.toPublicJSON()),
total: total,
page: page,
totalPages: Math.ceil(total / limit)
});
} catch (error) {
sendHttpError(response, error.message);
}
});
// overall status-page status badge
router.get("/api/status-page/:slug/badge", cache("5 minutes"), async (request, response) => {
allowDevAllOrigin(response);

View File

@ -8,6 +8,21 @@ const apicache = require("../modules/apicache");
const StatusPage = require("../model/status_page");
const { UptimeKumaServer } = require("../uptime-kuma-server");
/**
* Validates incident data
* @param {object} incident - The incident object
* @returns {void}
* @throws {Error} If validation fails
*/
function validateIncident(incident) {
if (!incident.title || incident.title.trim() === "") {
throw new Error("Please input title");
}
if (!incident.content || incident.content.trim() === "") {
throw new Error("Please input content");
}
}
/**
* Socket handlers for status page
* @param {Socket} socket Socket.io instance to add listeners on
@ -23,13 +38,9 @@ module.exports.statusPageSocketHandler = (socket) => {
let statusPageID = await StatusPage.slugToID(slug);
if (!statusPageID) {
throw new Error("slug is not found");
throw new Error("slug not found");
}
await R.exec("UPDATE incident SET pin = 0 WHERE status_page_id = ? ", [
statusPageID
]);
let incidentBean;
if (incident.id) {
@ -47,6 +58,7 @@ module.exports.statusPageSocketHandler = (socket) => {
incidentBean.content = incident.content;
incidentBean.style = incident.style;
incidentBean.pin = true;
incidentBean.active = true;
incidentBean.status_page_id = statusPageID;
if (incident.id) {
@ -90,6 +102,216 @@ module.exports.statusPageSocketHandler = (socket) => {
}
});
socket.on("getIncidentHistory", async (slug, page, callback) => {
try {
checkLogin(socket);
let statusPageID = await StatusPage.slugToID(slug);
if (!statusPageID) {
throw new Error("slug not found");
}
const limit = 10;
const offset = (page - 1) * limit;
const incidents = await R.find("incident",
" status_page_id = ? ORDER BY created_date DESC LIMIT ? OFFSET ? ",
[ statusPageID, limit, offset ]
);
const total = await R.count("incident", " status_page_id = ? ", [ statusPageID ]);
callback({
ok: true,
incidents: incidents.map(i => i.toJSON()),
total: total,
page: page,
totalPages: Math.ceil(total / limit)
});
} catch (error) {
callback({
ok: false,
msg: error.message,
});
}
});
socket.on("getPublicIncidentHistory", async (slug, page, callback) => {
try {
let statusPageID = await StatusPage.slugToID(slug);
if (!statusPageID) {
throw new Error("slug not found");
}
const limit = 10;
const offset = (page - 1) * limit;
const incidents = await R.find("incident",
" status_page_id = ? ORDER BY created_date DESC LIMIT ? OFFSET ? ",
[ statusPageID, limit, offset ]
);
const total = await R.count("incident", " status_page_id = ? ", [ statusPageID ]);
callback({
ok: true,
incidents: incidents.map(i => i.toPublicJSON()),
total: total,
page: page,
totalPages: Math.ceil(total / limit)
});
} catch (error) {
callback({
ok: false,
msg: error.message,
});
}
});
socket.on("editIncident", async (slug, incidentID, incident, callback) => {
try {
checkLogin(socket);
let statusPageID = await StatusPage.slugToID(slug);
if (!statusPageID) {
callback({
ok: false,
msg: "slug not found",
msgi18n: true
});
return;
}
let bean = await R.findOne("incident", " id = ? AND status_page_id = ? ", [ incidentID, statusPageID ]);
if (!bean) {
callback({
ok: false,
msg: "Incident not found or access denied",
msgi18n: true
});
return;
}
try {
validateIncident(incident);
} catch (e) {
callback({
ok: false,
msg: e.message,
msgi18n: true
});
return;
}
const validStyles = [ "info", "warning", "danger", "primary", "light", "dark" ];
if (!validStyles.includes(incident.style)) {
incident.style = "warning";
}
bean.title = incident.title;
bean.content = incident.content;
bean.style = incident.style;
bean.pin = incident.pin !== false;
bean.lastUpdatedDate = R.isoDateTime(dayjs.utc());
await R.store(bean);
callback({
ok: true,
msg: "Saved.",
msgi18n: true,
incident: bean.toJSON(),
});
} catch (error) {
callback({
ok: false,
msg: error.message,
msgi18n: true,
});
}
});
socket.on("deleteIncident", async (slug, incidentID, callback) => {
try {
checkLogin(socket);
let statusPageID = await StatusPage.slugToID(slug);
if (!statusPageID) {
callback({
ok: false,
msg: "slug not found",
msgi18n: true
});
return;
}
let bean = await R.findOne("incident", " id = ? AND status_page_id = ? ", [ incidentID, statusPageID ]);
if (!bean) {
callback({
ok: false,
msg: "Incident not found or access denied",
msgi18n: true
});
return;
}
await R.trash(bean);
callback({
ok: true,
msg: "successDeleted",
msgi18n: true,
});
} catch (error) {
callback({
ok: false,
msg: error.message,
msgi18n: true,
});
}
});
socket.on("resolveIncident", async (slug, incidentID, callback) => {
try {
checkLogin(socket);
let statusPageID = await StatusPage.slugToID(slug);
if (!statusPageID) {
callback({
ok: false,
msg: "slug not found",
msgi18n: true
});
return;
}
let bean = await R.findOne("incident", " id = ? AND status_page_id = ? ", [ incidentID, statusPageID ]);
if (!bean) {
callback({
ok: false,
msg: "Incident not found or access denied",
msgi18n: true
});
return;
}
await bean.resolve();
callback({
ok: true,
msg: "Resolved",
msgi18n: true,
incident: bean.toJSON(),
});
} catch (error) {
callback({
ok: false,
msg: error.message,
msgi18n: true,
});
}
});
socket.on("getStatusPage", async (slug, callback) => {
try {
checkLogin(socket);

View File

@ -0,0 +1,145 @@
<template>
<div class="incident-group" data-testid="incident-group">
<div v-if="loading && incidents.length === 0" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{{ $t("Loading...") }}</span>
</div>
</div>
<div v-else-if="incidents.length === 0" class="text-center py-4 text-muted">
{{ $t("No incidents recorded") }}
</div>
<div v-else class="incident-list">
<div
v-for="incident in incidents"
:key="incident.id"
class="incident-item"
:class="{ 'resolved': !incident.active }"
>
<div class="incident-style-indicator" :class="`bg-${incident.style}`"></div>
<div class="incident-body">
<div class="incident-header d-flex justify-content-between align-items-start">
<h5 class="incident-title mb-0">{{ incident.title }}</h5>
<div v-if="editMode" class="incident-actions">
<button
v-if="incident.active"
class="btn btn-success btn-sm me-1"
:title="$t('Resolve')"
@click="$emit('resolve-incident', incident)"
>
<font-awesome-icon icon="check" />
</button>
<button
class="btn btn-outline-secondary btn-sm me-1"
:title="$t('Edit')"
@click="$emit('edit-incident', incident)"
>
<font-awesome-icon icon="edit" />
</button>
<button
class="btn btn-outline-danger btn-sm"
:title="$t('Delete')"
@click="$emit('delete-incident', incident)"
>
<font-awesome-icon icon="trash" />
</button>
</div>
</div>
<!-- eslint-disable-next-line vue/no-v-html-->
<div class="incident-content mt-1" v-html="incident.content"></div>
<div class="incident-meta text-muted small mt-2">
<div>
{{ $t("Created") }}: {{ datetime(incident.createdDate) }}
</div>
<div v-if="incident.lastUpdatedDate">
{{ $t("Last Updated") }}: {{ datetime(incident.lastUpdatedDate) }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import datetimeMixin from "../mixins/datetime";
export default {
name: "IncidentHistory",
mixins: [ datetimeMixin ],
props: {
incidents: {
type: Array,
default: () => []
},
editMode: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
}
},
emits: [
"edit-incident",
"delete-incident",
"resolve-incident"
]
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
.incident-group {
padding: 10px;
.incident-list {
.incident-item {
display: flex;
padding: 13px 15px 10px 15px;
border-radius: 10px;
transition: all ease-in-out 0.15s;
&:hover {
background-color: $highlight-white;
}
&.resolved {
opacity: 0.7;
}
.incident-style-indicator {
width: 6px;
min-height: 100%;
border-radius: 3px;
flex-shrink: 0;
margin-right: 12px;
}
.incident-body {
flex: 1;
min-width: 0;
}
.incident-meta {
font-size: 12px;
}
}
}
}
.dark {
.incident-group {
.incident-list {
.incident-item {
&:hover {
background-color: $dark-bg2;
}
}
}
}
}
</style>

View File

@ -0,0 +1,256 @@
<template>
<div ref="modal" class="modal fade" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ $t("Edit Incident") }}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<form @submit.prevent="submit">
<div class="mb-3">
<label for="incident-title" class="form-label">{{
$t("Title")
}}</label>
<input
id="incident-title"
v-model="form.title"
type="text"
class="form-control"
:placeholder="$t('Incident title')"
required
/>
</div>
<div class="mb-3">
<label for="incident-content" class="form-label">{{
$t("Content")
}}</label>
<textarea
id="incident-content"
v-model="form.content"
class="form-control"
rows="4"
:placeholder="$t('Incident description')"
required
></textarea>
</div>
<div class="mb-3">
<label for="incident-style" class="form-label">{{
$t("Style")
}}</label>
<select
id="incident-style"
v-model="form.style"
class="form-select"
>
<option value="info">{{ $t("info") }}</option>
<option value="warning">
{{ $t("warning") }}
</option>
<option value="danger">
{{ $t("danger") }}
</option>
<option value="primary">
{{ $t("primary") }}
</option>
<option value="light">{{ $t("light") }}</option>
<option value="dark">{{ $t("dark") }}</option>
</select>
</div>
<div class="mb-3 form-check">
<input
id="incident-pin"
v-model="form.pin"
type="checkbox"
class="form-check-input"
/>
<label for="incident-pin" class="form-check-label">
{{ $t("Pin this incident") }}
</label>
<div class="form-text">
{{
$t(
"Pinned incidents are shown prominently on the status page"
)
}}
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
{{ $t("Cancel") }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="processing"
@click="submit"
>
<span
v-if="processing"
class="spinner-border spinner-border-sm me-1"
role="status"
></span>
{{ $t("Save") }}
</button>
</div>
</div>
</div>
</div>
<Confirm
ref="confirmDelete"
btn-style="btn-danger"
:yes-text="$t('Yes')"
:no-text="$t('No')"
@yes="confirmDeleteIncident"
>
{{ $t("deleteIncidentMsg") }}
</Confirm>
</template>
<script>
import { Modal } from "bootstrap";
import Confirm from "./Confirm.vue";
export default {
name: "IncidentManageModal",
components: {
Confirm,
},
props: {
slug: {
type: String,
required: true,
},
},
emits: [ "incident-updated" ],
data() {
return {
modal: null,
processing: false,
incidentId: null,
pendingDeleteIncident: null,
form: {
title: "",
content: "",
style: "warning",
pin: true,
},
};
},
mounted() {
this.modal = new Modal(this.$refs.modal);
},
methods: {
/**
* Show the modal for editing an existing incident
* @param {object} incident - The incident to edit
* @returns {void}
*/
showEdit(incident) {
this.incidentId = incident.id;
this.form = {
title: incident.title,
content: incident.content,
style: incident.style || "warning",
pin: !!incident.pin,
};
this.modal.show();
},
/**
* Show delete confirmation dialog
* @param {object} incident - The incident to delete
* @returns {void}
*/
showDelete(incident) {
this.pendingDeleteIncident = incident;
this.$refs.confirmDelete.show();
},
/**
* Submit the form to edit the incident
* @returns {void}
*/
submit() {
if (!this.form.title || this.form.title.trim() === "") {
this.$root.toastError(this.$t("Please input title"));
return;
}
if (!this.form.content || this.form.content.trim() === "") {
this.$root.toastError(this.$t("Please input content"));
return;
}
this.processing = true;
this.$root
.getSocket()
.emit(
"editIncident",
this.slug,
this.incidentId,
this.form,
(res) => {
this.processing = false;
this.$root.toastRes(res);
if (res.ok) {
this.modal.hide();
this.$emit("incident-updated");
}
}
);
},
/**
* Confirm and delete the incident
* @returns {void}
*/
confirmDeleteIncident() {
if (!this.pendingDeleteIncident) {
return;
}
this.$root
.getSocket()
.emit(
"deleteIncident",
this.slug,
this.pendingDeleteIncident.id,
(res) => {
this.$root.toastRes(res);
if (res.ok) {
this.$emit("incident-updated");
}
this.pendingDeleteIncident = null;
}
);
},
},
};
</script>
<style lang="scss" scoped>
.modal-body {
.form-text {
font-size: 0.875rem;
}
}
</style>

View File

@ -41,6 +41,9 @@
"Status": "Status",
"DateTime": "DateTime",
"Message": "Message",
"No incidents recorded": "No incidents recorded",
"Load More": "Load More",
"Loading...": "Loading...",
"No important events": "No important events",
"Resume": "Resume",
"Edit": "Edit",
@ -57,6 +60,7 @@
"-hour": "-hour",
"-year": "-year",
"Response": "Response",
"Pin this incident": "Pin this incident",
"Ping": "Ping",
"Monitor Type": "Monitor Type",
"Keyword": "Keyword",
@ -183,6 +187,10 @@
"Last Result": "Last Result",
"Create your admin account": "Create your admin account",
"Repeat Password": "Repeat Password",
"Incident description": "Incident description",
"Incident not found or access denied": "Incident not found or access denied",
"Past Incidents": "Past Incidents",
"Incident title": "Incident title",
"Import Backup": "Import Backup",
"Export Backup": "Export Backup",
"Export": "Export",
@ -239,6 +247,7 @@
"Blue": "Blue",
"Indigo": "Indigo",
"Purple": "Purple",
"Pinned incidents are shown prominently on the status page": "Pinned incidents are shown prominently on the status page",
"Pink": "Pink",
"Custom": "Custom",
"Search...": "Search…",
@ -254,6 +263,7 @@
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Incident": "Edit Incident",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
"Status Page": "Status Page",
@ -308,6 +318,7 @@
"successKeyword": "Success Keyword",
"successKeywordExplanation": "MQTT Keyword that will be considered as success",
"recent": "Recent",
"Resolve": "Resolve",
"Reset Token": "Reset Token",
"Done": "Done",
"Info": "Info",
@ -356,6 +367,7 @@
"Customize": "Customize",
"Custom Footer": "Custom Footer",
"Custom CSS": "Custom CSS",
"deleteIncidentMsg": "Are you sure you want to delete this incident?",
"deleteStatusPageMsg": "Are you sure want to delete this status page?",
"Proxies": "Proxies",
"default": "Default",
@ -378,6 +390,7 @@
"Stop": "Stop",
"Add New Status Page": "Add New Status Page",
"Slug": "Slug",
"slug is not found": "Slug not found",
"Accept characters:": "Accept characters:",
"startOrEndWithOnly": "Start or end with {0} only",
"No consecutive dashes": "No consecutive dashes",
@ -401,6 +414,8 @@
"Trust Proxy": "Trust Proxy",
"Other Software": "Other Software",
"For example: nginx, Apache and Traefik.": "For example: nginx, Apache and Traefik.",
"Please input content": "Please input content",
"Please input title": "Please input title",
"Please read": "Please read",
"Subject:": "Subject:",
"Valid To:": "Valid To:",
@ -1233,3 +1248,4 @@
"Unable to get permission to notify": "Unable to get permission to notify (request either denied or ignored).",
"Webpush Helptext": "Web push only works with SSL (HTTPS) connections. For iOS devices, webpage must be added to homescreen beforehand."
}

View File

@ -183,43 +183,28 @@
</div>
<!-- Incident -->
<div v-if="incident !== null" class="shadow-box alert mb-4 p-4 incident" role="alert" :class="incidentClass" data-testid="incident">
<strong v-if="editIncidentMode">{{ $t("Title") }}:</strong>
<Editable v-model="incident.title" tag="h4" :contenteditable="editIncidentMode" :noNL="true" class="alert-heading" data-testid="incident-title" />
<div v-if="editIncidentMode && incident !== null && !incident.id" class="shadow-box alert mb-4 p-4 incident" role="alert" :class="incidentClass" data-testid="incident-edit">
<strong>{{ $t("Title") }}:</strong>
<Editable v-model="incident.title" tag="h4" :contenteditable="true" :noNL="true" class="alert-heading" data-testid="incident-title" />
<strong v-if="editIncidentMode">{{ $t("Content") }}:</strong>
<Editable v-if="editIncidentMode" v-model="incident.content" tag="div" :contenteditable="editIncidentMode" class="content" data-testid="incident-content-editable" />
<div v-if="editIncidentMode" class="form-text">
<strong>{{ $t("Content") }}:</strong>
<Editable v-model="incident.content" tag="div" :contenteditable="true" class="content" data-testid="incident-content-editable" />
<div class="form-text">
{{ $t("markdownSupported") }}
</div>
<!-- eslint-disable-next-line vue/no-v-html-->
<div v-if="! editIncidentMode" class="content" data-testid="incident-content" v-html="incidentHTML"></div>
<!-- Incident Date -->
<div class="date mt-3">
{{ $t("Date 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" data-testid="post-incident-button" @click="postIncident">
<div class="mt-3">
<button class="btn btn-light me-2" data-testid="post-incident-button" @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">
<button 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">
<div 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>
@ -232,14 +217,92 @@
<li><a class="dropdown-item" href="#" @click="incident.style = 'dark'">{{ $t("dark") }}</a></li>
</ul>
</div>
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="unpinIncident">
<font-awesome-icon icon="unlink" />
{{ $t("Delete") }}
</button>
</div>
</div>
<!-- Active Pinned Incidents -->
<template v-for="activeIncident in activeIncidents" :key="activeIncident.id">
<!-- Edit mode for this specific incident -->
<div
v-if="editIncidentMode && incident !== null && incident.id === activeIncident.id"
class="shadow-box alert mb-4 p-4 incident"
role="alert"
:class="incidentClass"
data-testid="incident-edit"
>
<strong>{{ $t("Title") }}:</strong>
<Editable v-model="incident.title" tag="h4" :contenteditable="true" :noNL="true" class="alert-heading" data-testid="incident-title" />
<strong>{{ $t("Content") }}:</strong>
<Editable v-model="incident.content" tag="div" :contenteditable="true" class="content" data-testid="incident-content-editable" />
<div class="form-text">
{{ $t("markdownSupported") }}
</div>
<div class="mt-3">
<button class="btn btn-light me-2" data-testid="post-incident-button" @click="postIncident">
<font-awesome-icon icon="bullhorn" />
{{ $t("Post") }}
</button>
<button class="btn btn-light me-2" @click="cancelIncident">
<font-awesome-icon icon="times" />
{{ $t("Cancel") }}
</button>
<div 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>
</div>
<!-- Display mode for this incident -->
<div
v-else
class="shadow-box alert mb-4 p-4 incident"
role="alert"
:class="'bg-' + activeIncident.style"
data-testid="incident"
>
<h4 class="alert-heading" data-testid="incident-title">{{ activeIncident.title }}</h4>
<!-- eslint-disable-next-line vue/no-v-html-->
<div class="content" data-testid="incident-content" v-html="getIncidentHTML(activeIncident.content)"></div>
<!-- Incident Date -->
<div class="date mt-3">
{{ $t("Date Created") }}: {{ $root.datetime(activeIncident.createdDate) }} ({{ dateFromNow(activeIncident.createdDate) }})<br />
<span v-if="activeIncident.lastUpdatedDate">
{{ $t("Last Updated") }}: {{ $root.datetime(activeIncident.lastUpdatedDate) }} ({{ dateFromNow(activeIncident.lastUpdatedDate) }})
</span>
</div>
<div v-if="editMode" class="mt-3">
<button class="btn btn-light me-2" @click="resolveIncident(activeIncident)">
<font-awesome-icon icon="check" />
{{ $t("Resolve") }}
</button>
<button class="btn btn-light me-2" @click="editIncident(activeIncident)">
<font-awesome-icon icon="edit" />
{{ $t("Edit") }}
</button>
<button class="btn btn-light me-2" @click="$refs.incidentManageModal.showDelete(activeIncident)">
<font-awesome-icon icon="unlink" />
{{ $t("Delete") }}
</button>
</div>
</div>
</template>
<!-- Overall Status -->
<div class="shadow-box list p-4 overall-status mb-4">
<div v-if="Object.keys($root.publicMonitorList).length === 0 && loadedData">
@ -337,6 +400,56 @@
<PublicGroupList :edit-mode="enableEditMode" :show-tags="config.showTags" :show-certificate-expiry="config.showCertificateExpiry" :show-only-last-heartbeat="config.showOnlyLastHeartbeat" />
</div>
<!-- Past Incidents -->
<div class="past-incidents-section mb-4">
<h2 class="past-incidents-title mb-3">{{ $t("Past Incidents") }}</h2>
<div v-if="incidentHistoryLoading && incidentHistory.length === 0" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{{ $t("Loading...") }}</span>
</div>
</div>
<div v-else-if="incidentHistory.length === 0" class="text-center py-4 text-muted">
{{ $t("No incidents recorded") }}
</div>
<template v-else>
<div v-for="(dateGroup, dateKey) in groupedIncidentHistory" :key="dateKey" class="incident-date-group mb-4">
<h4 class="incident-date-header">{{ dateKey }}</h4>
<div class="shadow-box incident-list-box">
<IncidentHistory
:incidents="dateGroup"
:edit-mode="enableEditMode"
:loading="incidentHistoryLoading"
@edit-incident="$refs.incidentManageModal.showEdit($event)"
@delete-incident="$refs.incidentManageModal.showDelete($event)"
@resolve-incident="resolveIncident"
/>
</div>
</div>
<div v-if="incidentHistoryPage < incidentHistoryTotalPages" class="load-more-controls d-flex justify-content-center mt-3">
<button
class="btn btn-outline-secondary btn-sm"
:disabled="incidentHistoryLoading"
@click="loadMoreIncidentHistory"
>
<span v-if="incidentHistoryLoading" class="spinner-border spinner-border-sm me-1" role="status"></span>
{{ $t("Load More") }}
</button>
</div>
</template>
</div>
<!-- Incident Manage Modal -->
<IncidentManageModal
v-if="enableEditMode"
ref="incidentManageModal"
:slug="slug"
@incident-updated="loadIncidentHistory"
/>
<footer class="mt-5 mb-4">
<div class="custom-footer-text text-start">
<strong v-if="enableEditMode">{{ $t("Custom Footer") }}:</strong>
@ -385,6 +498,8 @@ import DOMPurify from "dompurify";
import Confirm from "../components/Confirm.vue";
import PublicGroupList from "../components/PublicGroupList.vue";
import MaintenanceTime from "../components/MaintenanceTime.vue";
import IncidentHistory from "../components/IncidentHistory.vue";
import IncidentManageModal from "../components/IncidentManageModal.vue";
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";
@ -411,7 +526,9 @@ export default {
PrismEditor,
MaintenanceTime,
Tag,
VueMultiselect
VueMultiselect,
IncidentHistory,
IncidentManageModal,
},
// Leave Page for vue route change
@ -457,6 +574,10 @@ export default {
updateCountdown: null,
updateCountdownText: null,
loading: true,
incidentHistory: [],
incidentHistoryLoading: false,
incidentHistoryPage: 1,
incidentHistoryTotalPages: 1,
};
},
computed: {
@ -585,7 +706,7 @@ export default {
},
incidentHTML() {
if (this.incident.content != null) {
if (this.incident && this.incident.content != null) {
return DOMPurify.sanitize(marked(this.incident.content));
} else {
return "";
@ -610,6 +731,30 @@ export default {
lastUpdateTimeDisplay() {
return this.$root.datetime(this.lastUpdateTime);
},
/**
* Get all active pinned incidents for display at the top
* @returns {object[]} List of active pinned incidents
*/
activeIncidents() {
return this.incidentHistory.filter(i => i.active && i.pin);
},
/**
* Group incidents by date for display
* @returns {object} Incidents grouped by date string
*/
groupedIncidentHistory() {
const groups = {};
for (const incident of this.incidentHistory) {
const dateKey = this.formatDateKey(incident.createdDate);
if (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey].push(incident);
}
return groups;
}
},
watch: {
@ -717,13 +862,15 @@ export default {
this.imgDataUrl = this.config.icon;
}
this.incident = res.data.incident;
if (res.data.incidents && res.data.incidents.length > 0) {
this.incidentHistory = res.data.incidents;
}
this.maintenanceList = res.data.maintenanceList;
this.$root.publicGroupList = res.data.publicGroupList;
this.loading = false;
this.loadIncidentHistory();
// Configure auto-refresh loop
feedInterval = setInterval(() => {
this.updateHeartbeatList();
}, Math.max(5, this.config.autoRefreshInterval) * 1000);
@ -994,7 +1141,8 @@ export default {
if (res.ok) {
this.enableEditIncidentMode = false;
this.incident = res.incident;
this.incident = null;
this.loadIncidentHistory();
} else {
this.$root.toastError(res.msg);
}
@ -1004,12 +1152,13 @@ export default {
},
/**
* Click Edit Button
* Edit an incident inline
* @param {object} incident - The incident to edit
* @returns {void}
*/
editIncident() {
editIncident(incident) {
this.incident = { ...incident };
this.enableEditIncidentMode = true;
this.previousIncident = Object.assign({}, this.incident);
},
/**
@ -1035,6 +1184,18 @@ export default {
});
},
/**
* Get HTML for incident content
* @param {string} content - Markdown content
* @returns {string} Sanitized HTML
*/
getIncidentHTML(content) {
if (content != null) {
return DOMPurify.sanitize(marked(content));
}
return "";
},
/**
* Get the relative time difference of a date from now
* @param {any} date Date to get time difference
@ -1066,6 +1227,96 @@ export default {
}
},
/**
* Load incident history for the status page
* @returns {void}
*/
loadIncidentHistory() {
this.loadIncidentHistoryPage(1);
},
/**
* Load a specific page of incident history
* @param {number} page - Page number to load
* @param {boolean} append - Whether to append to existing list
* @returns {void}
*/
loadIncidentHistoryPage(page, append = false) {
this.incidentHistoryLoading = true;
if (this.enableEditMode) {
this.$root.getSocket().emit("getIncidentHistory", this.slug, page, (res) => {
this.incidentHistoryLoading = false;
if (res.ok) {
if (append) {
this.incidentHistory = [ ...this.incidentHistory, ...res.incidents ];
} else {
this.incidentHistory = res.incidents;
}
this.incidentHistoryPage = res.page;
this.incidentHistoryTotalPages = res.totalPages;
}
});
} else {
axios.get(`/api/status-page/${this.slug}/incident-history?page=${page}`).then((res) => {
this.incidentHistoryLoading = false;
if (res.data.ok) {
if (append) {
this.incidentHistory = [ ...this.incidentHistory, ...res.data.incidents ];
} else {
this.incidentHistory = res.data.incidents;
}
this.incidentHistoryPage = res.data.page;
this.incidentHistoryTotalPages = res.data.totalPages;
}
}).catch((error) => {
this.incidentHistoryLoading = false;
console.error("Failed to load incident history:", error);
});
}
},
/**
* Load more incident history (next page, appended)
* @returns {void}
*/
loadMoreIncidentHistory() {
if (this.incidentHistoryPage < this.incidentHistoryTotalPages) {
this.loadIncidentHistoryPage(this.incidentHistoryPage + 1, true);
}
},
/**
* Format date key for grouping (e.g., "December 8, 2025")
* @param {string} dateStr - ISO date string
* @returns {string} Formatted date key
*/
formatDateKey(dateStr) {
if (!dateStr) {
return "Unknown";
}
const date = new Date(dateStr);
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric"
});
},
/**
* Resolve an incident
* @param {object} incident - The incident to resolve
* @returns {void}
*/
resolveIncident(incident) {
this.$root.getSocket().emit("resolveIncident", this.slug, incident.id, (res) => {
this.$root.toastRes(res);
if (res.ok) {
this.loadIncidentHistory();
}
});
},
}
};
</script>
@ -1282,4 +1533,22 @@ footer {
opacity: 0.7;
}
.past-incidents-title {
font-size: 26px;
font-weight: normal;
}
.incident-date-group {
.incident-date-header {
font-size: 1rem;
font-weight: normal;
color: var(--bs-secondary);
margin-bottom: 0.75rem;
}
.incident-list-box {
padding: 0;
}
}
</style>