Compare commits
9 Commits
master
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03558185dd | ||
|
|
9cc3e221e0 | ||
|
|
7671026c18 | ||
|
|
aec9ca5b0a | ||
|
|
fae682efd3 | ||
|
|
9edc2e3011 | ||
|
|
8aa8547ec6 | ||
|
|
6b850a2ade | ||
|
|
7b5fffd888 |
@ -1,21 +1,9 @@
|
||||
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.last_updated_date = 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
|
||||
* @returns {object} Object ready to parse
|
||||
*/
|
||||
toPublicJSON() {
|
||||
@ -24,11 +12,9 @@ class Incident extends BeanModel {
|
||||
style: this.style,
|
||||
title: this.title,
|
||||
content: this.content,
|
||||
pin: !!this.pin,
|
||||
active: !!this.active,
|
||||
createdDate: this.created_date,
|
||||
lastUpdatedDate: this.last_updated_date,
|
||||
status_page_id: this.status_page_id,
|
||||
pin: this.pin,
|
||||
createdDate: this.createdDate,
|
||||
lastUpdatedDate: this.lastUpdatedDate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1746,55 +1746,6 @@ class Monitor extends BeanModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate JSON fields to prevent invalid JSON from being stored in database
|
||||
if (this.kafkaProducerBrokers) {
|
||||
try {
|
||||
JSON.parse(this.kafkaProducerBrokers);
|
||||
} catch (e) {
|
||||
throw new Error(`Kafka Producer Brokers must be valid JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.kafkaProducerSaslOptions) {
|
||||
try {
|
||||
JSON.parse(this.kafkaProducerSaslOptions);
|
||||
} catch (e) {
|
||||
throw new Error(`Kafka Producer SASL Options must be valid JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.rabbitmqNodes) {
|
||||
try {
|
||||
JSON.parse(this.rabbitmqNodes);
|
||||
} catch (e) {
|
||||
throw new Error(`RabbitMQ Nodes must be valid JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.conditions) {
|
||||
try {
|
||||
JSON.parse(this.conditions);
|
||||
} catch (e) {
|
||||
throw new Error(`Conditions must be valid JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.headers) {
|
||||
try {
|
||||
JSON.parse(this.headers);
|
||||
} catch (e) {
|
||||
throw new Error(`Headers must be valid JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.accepted_statuscodes_json) {
|
||||
try {
|
||||
JSON.parse(this.accepted_statuscodes_json);
|
||||
} catch (e) {
|
||||
throw new Error(`Accepted status codes must be valid JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.type === "ping") {
|
||||
// ping parameters validation
|
||||
if (this.packetSize && (this.packetSize < PING_PACKET_SIZE_MIN || this.packetSize > PING_PACKET_SIZE_MAX)) {
|
||||
|
||||
@ -7,8 +7,8 @@ const analytics = require("../analytics/analytics");
|
||||
const { marked } = require("marked");
|
||||
const { Feed } = require("feed");
|
||||
const config = require("../config");
|
||||
|
||||
const { setting } = require("../util-server");
|
||||
|
||||
const {
|
||||
STATUS_PAGE_ALL_DOWN,
|
||||
STATUS_PAGE_ALL_UP,
|
||||
@ -17,7 +17,6 @@ const {
|
||||
UP,
|
||||
MAINTENANCE,
|
||||
DOWN,
|
||||
INCIDENT_PAGE_SIZE,
|
||||
} = require("../../src/util");
|
||||
|
||||
class StatusPage extends BeanModel {
|
||||
@ -308,13 +307,12 @@ class StatusPage extends BeanModel {
|
||||
static async getStatusPageData(statusPage) {
|
||||
const config = await statusPage.toPublicJSON();
|
||||
|
||||
// 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]
|
||||
);
|
||||
incidents = incidents.map((i) => i.toPublicJSON());
|
||||
// Incident
|
||||
let incident = await R.findOne("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [statusPage.id]);
|
||||
|
||||
if (incident) {
|
||||
incident = incident.toPublicJSON();
|
||||
}
|
||||
|
||||
let maintenanceList = await StatusPage.getMaintenanceList(statusPage.id);
|
||||
|
||||
@ -332,7 +330,7 @@ class StatusPage extends BeanModel {
|
||||
// Response
|
||||
return {
|
||||
config,
|
||||
incidents,
|
||||
incident,
|
||||
publicGroupList,
|
||||
maintenanceList,
|
||||
};
|
||||
@ -501,54 +499,6 @@ class StatusPage extends BeanModel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated incident history for a status page using cursor-based pagination
|
||||
* @param {number} statusPageId ID of the status page
|
||||
* @param {string|null} cursor ISO date string cursor (created_date of last item from previous page)
|
||||
* @param {boolean} isPublic Whether to return public or admin data
|
||||
* @returns {Promise<object>} Paginated incident data with cursor
|
||||
*/
|
||||
static async getIncidentHistory(statusPageId, cursor = null, isPublic = true) {
|
||||
let incidents;
|
||||
|
||||
if (cursor) {
|
||||
incidents = await R.find(
|
||||
"incident",
|
||||
" status_page_id = ? AND created_date < ? ORDER BY created_date DESC LIMIT ? ",
|
||||
[statusPageId, cursor, INCIDENT_PAGE_SIZE]
|
||||
);
|
||||
} else {
|
||||
incidents = await R.find("incident", " status_page_id = ? ORDER BY created_date DESC LIMIT ? ", [
|
||||
statusPageId,
|
||||
INCIDENT_PAGE_SIZE,
|
||||
]);
|
||||
}
|
||||
|
||||
const total = await R.count("incident", " status_page_id = ? ", [statusPageId]);
|
||||
|
||||
const lastIncident = incidents[incidents.length - 1];
|
||||
let nextCursor = null;
|
||||
let hasMore = false;
|
||||
|
||||
if (lastIncident) {
|
||||
const moreCount = await R.count("incident", " status_page_id = ? AND created_date < ? ", [
|
||||
statusPageId,
|
||||
lastIncident.created_date,
|
||||
]);
|
||||
hasMore = moreCount > 0;
|
||||
if (hasMore) {
|
||||
nextCursor = lastIncident.created_date;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
incidents: incidents.map((i) => i.toPublicJSON()),
|
||||
total,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of maintenances
|
||||
* @param {number} statusPageId ID of status page to get maintenance for
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
const nodemailer = require("nodemailer");
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const { log } = require("../../src/util");
|
||||
|
||||
class SMTP extends NotificationProvider {
|
||||
name = "smtp";
|
||||
@ -15,24 +14,10 @@ class SMTP extends NotificationProvider {
|
||||
host: notification.smtpHost,
|
||||
port: notification.smtpPort,
|
||||
secure: notification.smtpSecure,
|
||||
};
|
||||
|
||||
// Handle TLS/STARTTLS options
|
||||
if (!notification.smtpSecure && notification.smtpIgnoreSTARTTLS) {
|
||||
// Disable STARTTLS completely for servers that don't support it
|
||||
// Connection will remain unencrypted
|
||||
log.warn(
|
||||
"notification",
|
||||
`SMTP notification using unencrypted connection (STARTTLS disabled) to ${notification.smtpHost}:${notification.smtpPort}`
|
||||
);
|
||||
config.ignoreTLS = true;
|
||||
} else {
|
||||
// SMTPS (implicit TLS on port 465)
|
||||
// or STARTTLS (default behavior for ports 25, 587)
|
||||
config.tls = {
|
||||
tls: {
|
||||
rejectUnauthorized: !notification.smtpIgnoreTLSError || false,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Fix #1129
|
||||
if (notification.smtpDkimDomain) {
|
||||
|
||||
@ -142,30 +142,6 @@ 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 cursor = request.query.cursor || null;
|
||||
const result = await StatusPage.getIncidentHistory(statusPageID, cursor, true);
|
||||
response.json({
|
||||
ok: true,
|
||||
...result,
|
||||
});
|
||||
} 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);
|
||||
|
||||
@ -8,21 +8,6 @@ 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
|
||||
@ -40,6 +25,8 @@ module.exports.statusPageSocketHandler = (socket) => {
|
||||
throw new Error("slug is not found");
|
||||
}
|
||||
|
||||
await R.exec("UPDATE incident SET pin = 0 WHERE status_page_id = ? ", [statusPageID]);
|
||||
|
||||
let incidentBean;
|
||||
|
||||
if (incident.id) {
|
||||
@ -57,13 +44,12 @@ 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) {
|
||||
incidentBean.last_updated_date = R.isoDateTime(dayjs.utc());
|
||||
incidentBean.lastUpdatedDate = R.isoDateTime(dayjs.utc());
|
||||
} else {
|
||||
incidentBean.created_date = R.isoDateTime(dayjs.utc());
|
||||
incidentBean.createdDate = R.isoDateTime(dayjs.utc());
|
||||
}
|
||||
|
||||
await R.store(incidentBean);
|
||||
@ -99,171 +85,6 @@ module.exports.statusPageSocketHandler = (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("getIncidentHistory", async (slug, cursor, callback) => {
|
||||
try {
|
||||
let statusPageID = await StatusPage.slugToID(slug);
|
||||
if (!statusPageID) {
|
||||
throw new Error("slug is not found");
|
||||
}
|
||||
|
||||
const isPublic = !socket.userID;
|
||||
const result = await StatusPage.getIncidentHistory(statusPageID, cursor, isPublic);
|
||||
callback({
|
||||
ok: true,
|
||||
...result,
|
||||
});
|
||||
} 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 is 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.toPublicJSON(),
|
||||
});
|
||||
} 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 is 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 is 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.toPublicJSON(),
|
||||
});
|
||||
} catch (error) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: error.message,
|
||||
msgi18n: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("getStatusPage", async (slug, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
@ -1,119 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="shadow-box alert mb-4 p-4 incident"
|
||||
role="alert"
|
||||
:class="'bg-' + modelValue.style"
|
||||
data-testid="incident-edit"
|
||||
>
|
||||
<strong>{{ $t("Title") }}:</strong>
|
||||
<Editable
|
||||
:model-value="modelValue.title"
|
||||
tag="h4"
|
||||
:contenteditable="true"
|
||||
:noNL="true"
|
||||
class="alert-heading"
|
||||
data-testid="incident-title"
|
||||
@update:model-value="updateField('title', $event)"
|
||||
/>
|
||||
|
||||
<strong>{{ $t("Content") }}:</strong>
|
||||
<Editable
|
||||
:model-value="modelValue.content"
|
||||
tag="div"
|
||||
:contenteditable="true"
|
||||
class="content"
|
||||
data-testid="incident-content-editable"
|
||||
@update:model-value="updateField('content', $event)"
|
||||
/>
|
||||
<div class="form-text">
|
||||
{{ $t("markdownSupported") }}
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-light me-2" data-testid="post-incident-button" @click="$emit('post')">
|
||||
<font-awesome-icon icon="bullhorn" />
|
||||
{{ $t("Post") }}
|
||||
</button>
|
||||
|
||||
<button class="btn btn-light me-2" @click="$emit('cancel')">
|
||||
<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(modelValue.style) }}
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'info')">
|
||||
{{ $t("info") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'warning')">
|
||||
{{ $t("warning") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'danger')">
|
||||
{{ $t("danger") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'primary')">
|
||||
{{ $t("primary") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'light')">
|
||||
{{ $t("light") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'dark')">
|
||||
{{ $t("dark") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "IncidentEditForm",
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "post", "cancel"],
|
||||
methods: {
|
||||
updateField(field, value) {
|
||||
this.$emit("update:modelValue", {
|
||||
...this.modelValue,
|
||||
[field]: value,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.incident {
|
||||
.content {
|
||||
&[contenteditable="true"] {
|
||||
min-height: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,154 +0,0 @@
|
||||
<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="getIncidentHTML(incident.content)"></div>
|
||||
<div class="incident-meta text-muted small mt-2">
|
||||
<div>{{ $t("createdAt", { date: datetime(incident.createdDate) }) }}</div>
|
||||
<div v-if="incident.lastUpdatedDate">
|
||||
{{ $t("lastUpdatedAt", { date: datetime(incident.lastUpdatedDate) }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
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"],
|
||||
methods: {
|
||||
/**
|
||||
* Get sanitized HTML for incident content
|
||||
* @param {string} content - Markdown content
|
||||
* @returns {string} Sanitized HTML
|
||||
*/
|
||||
getIncidentHTML(content) {
|
||||
if (content != null) {
|
||||
return DOMPurify.sanitize(marked(content));
|
||||
}
|
||||
return "";
|
||||
},
|
||||
},
|
||||
};
|
||||
</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>
|
||||
@ -1,204 +0,0 @@
|
||||
<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>
|
||||
@ -56,24 +56,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!$parent.notification.smtpSecure" class="mb-3">
|
||||
<div class="form-check">
|
||||
<input
|
||||
id="ignore-starttls"
|
||||
v-model="$parent.notification.smtpIgnoreSTARTTLS"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
value=""
|
||||
/>
|
||||
<label class="form-check-label" for="ignore-starttls">
|
||||
{{ $t("Disable STARTTLS") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
{{ $t("disableSTARTTLSDescription") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">{{ $t("Username") }}</label>
|
||||
<input
|
||||
|
||||
@ -46,9 +46,6 @@
|
||||
"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",
|
||||
@ -65,7 +62,6 @@
|
||||
"minuteShort": "{n} min | {n} min",
|
||||
"years": "{n} year | {n} years",
|
||||
"Response": "Response",
|
||||
"Pin this incident": "Pin this incident",
|
||||
"Ping": "Ping",
|
||||
"Monitor Type": "Monitor Type",
|
||||
"Keyword": "Keyword",
|
||||
@ -171,10 +167,6 @@
|
||||
"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",
|
||||
@ -231,7 +223,6 @@
|
||||
"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…",
|
||||
@ -247,7 +238,6 @@
|
||||
"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",
|
||||
@ -306,8 +296,6 @@
|
||||
"successKeyword": "Success Keyword",
|
||||
"successKeywordExplanation": "MQTT Keyword that will be considered as success",
|
||||
"recent": "Recent",
|
||||
"Resolve": "Resolve",
|
||||
"Resolved": "Resolved",
|
||||
"Reset Token": "Reset Token",
|
||||
"Done": "Done",
|
||||
"Info": "Info",
|
||||
@ -361,7 +349,6 @@
|
||||
"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",
|
||||
@ -384,7 +371,6 @@
|
||||
"Stop": "Stop",
|
||||
"Add New Status Page": "Add New Status Page",
|
||||
"Slug": "Slug",
|
||||
"slug is not found": "Slug is not found",
|
||||
"Accept characters:": "Accept characters:",
|
||||
"startOrEndWithOnly": "Start or end with {0} only",
|
||||
"No consecutive dashes": "No consecutive dashes",
|
||||
@ -408,8 +394,6 @@
|
||||
"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:",
|
||||
@ -601,8 +585,6 @@
|
||||
"secureOptionNone": "None / STARTTLS (25, 587)",
|
||||
"secureOptionTLS": "TLS (465)",
|
||||
"Ignore TLS Error": "Ignore TLS Error",
|
||||
"Disable STARTTLS": "Disable STARTTLS",
|
||||
"disableSTARTTLSDescription": "Enable this option for SMTP servers that do not support STARTTLS. This will send emails over an unencrypted connection.",
|
||||
"From Email": "From Email",
|
||||
"emailCustomisableContent": "Customisable content",
|
||||
"smtpLiquidIntroduction": "The following two fields are templatable via the Liquid templating Language. Please refer to the {0} for usage instructions. These are the available variables:",
|
||||
|
||||
@ -297,83 +297,131 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incident Edit Form -->
|
||||
<IncidentEditForm
|
||||
v-if="
|
||||
editIncidentMode &&
|
||||
incident !== null &&
|
||||
(!incident.id || !activeIncidents.some((i) => i.id === incident.id))
|
||||
"
|
||||
v-model="incident"
|
||||
@post="postIncident"
|
||||
@cancel="cancelIncident"
|
||||
/>
|
||||
|
||||
<!-- Active Pinned Incidents -->
|
||||
<template v-for="activeIncident in activeIncidents" :key="activeIncident.id">
|
||||
<!-- Edit mode for this specific incident -->
|
||||
<IncidentEditForm
|
||||
v-if="editIncidentMode && incident !== null && incident.id === activeIncident.id"
|
||||
v-model="incident"
|
||||
@post="postIncident"
|
||||
@cancel="cancelIncident"
|
||||
<!-- 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"
|
||||
/>
|
||||
|
||||
<!-- Display mode for this incident -->
|
||||
<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">
|
||||
{{ $t("markdownSupported") }}
|
||||
</div>
|
||||
<!-- eslint-disable vue/no-v-html-->
|
||||
<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 vue/no-v-html -->
|
||||
<div
|
||||
class="content"
|
||||
data-testid="incident-content"
|
||||
v-html="getIncidentHTML(activeIncident.content)"
|
||||
></div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
v-if="!editIncidentMode"
|
||||
class="content"
|
||||
data-testid="incident-content"
|
||||
v-html="incidentHTML"
|
||||
></div>
|
||||
<!-- eslint-enable vue/no-v-html-->
|
||||
|
||||
<!-- Incident Date -->
|
||||
<div class="date mt-3">
|
||||
<!-- Incident Date -->
|
||||
<div class="date mt-3">
|
||||
{{
|
||||
$t("dateCreatedAtFromNow", {
|
||||
date: $root.datetime(incident.createdDate),
|
||||
fromNow: dateFromNow(incident.createdDate),
|
||||
})
|
||||
}}
|
||||
<br />
|
||||
<span v-if="incident.lastUpdatedDate">
|
||||
{{
|
||||
$t("dateCreatedAtFromNow", {
|
||||
date: $root.datetime(activeIncident.createdDate),
|
||||
fromNow: dateFromNow(activeIncident.createdDate),
|
||||
$t("lastUpdatedAtFromNow", {
|
||||
date: $root.datetime(incident.lastUpdatedDate),
|
||||
fromNow: dateFromNow(incident.lastUpdatedDate),
|
||||
})
|
||||
}}
|
||||
<br />
|
||||
<span v-if="activeIncident.lastUpdatedDate">
|
||||
{{
|
||||
$t("lastUpdatedAtFromNow", {
|
||||
date: $root.datetime(activeIncident.lastUpdatedDate),
|
||||
fromNow: dateFromNow(activeIncident.lastUpdatedDate),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</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"
|
||||
>
|
||||
<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 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>
|
||||
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="unpinIncident">
|
||||
<font-awesome-icon icon="unlink" />
|
||||
{{ $t("Delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Overall Status -->
|
||||
<div class="shadow-box list p-4 overall-status mb-4">
|
||||
@ -496,56 +544,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Past Incidents -->
|
||||
<div v-if="pastIncidentCount > 0" class="past-incidents-section mb-4">
|
||||
<h2 class="past-incidents-title mb-3">
|
||||
{{ $t("Past Incidents") }}
|
||||
</h2>
|
||||
|
||||
<div class="past-incidents-content">
|
||||
<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="incidentHistoryHasMore" 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>
|
||||
</div>
|
||||
</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>
|
||||
@ -619,9 +617,6 @@ 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 IncidentEditForm from "../components/IncidentEditForm.vue";
|
||||
import { getResBaseURL } from "../util-frontend";
|
||||
import {
|
||||
STATUS_PAGE_ALL_DOWN,
|
||||
@ -655,9 +650,6 @@ export default {
|
||||
MaintenanceTime,
|
||||
Tag,
|
||||
VueMultiselect,
|
||||
IncidentHistory,
|
||||
IncidentManageModal,
|
||||
IncidentEditForm,
|
||||
},
|
||||
|
||||
// Leave Page for vue route change
|
||||
@ -703,10 +695,6 @@ export default {
|
||||
updateCountdown: null,
|
||||
updateCountdownText: null,
|
||||
loading: true,
|
||||
incidentHistory: [],
|
||||
incidentHistoryLoading: false,
|
||||
incidentHistoryNextCursor: null,
|
||||
incidentHistoryHasMore: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -832,7 +820,7 @@ export default {
|
||||
},
|
||||
|
||||
incidentHTML() {
|
||||
if (this.incident && this.incident.content != null) {
|
||||
if (this.incident.content != null) {
|
||||
return DOMPurify.sanitize(marked(this.incident.content));
|
||||
} else {
|
||||
return "";
|
||||
@ -858,40 +846,6 @@ 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);
|
||||
},
|
||||
|
||||
/**
|
||||
* Count of past incidents (non-active or unpinned)
|
||||
* @returns {number} Number of past incidents
|
||||
*/
|
||||
pastIncidentCount() {
|
||||
return this.incidentHistory.filter((i) => !(i.active && i.pin)).length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Group past incidents (non-active or unpinned) by date for display
|
||||
* Active+pinned incidents are shown separately at the top, not in this section
|
||||
* @returns {object} Incidents grouped by date string
|
||||
*/
|
||||
groupedIncidentHistory() {
|
||||
const groups = {};
|
||||
const pastIncidents = this.incidentHistory.filter((i) => !(i.active && i.pin));
|
||||
for (const incident of pastIncidents) {
|
||||
const dateKey = this.formatDateKey(incident.createdDate);
|
||||
if (!groups[dateKey]) {
|
||||
groups[dateKey] = [];
|
||||
}
|
||||
groups[dateKey].push(incident);
|
||||
}
|
||||
return groups;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
/**
|
||||
@ -994,18 +948,6 @@ export default {
|
||||
this.imgDataUrl = this.config.icon;
|
||||
}
|
||||
|
||||
this.maintenanceList = res.data.maintenanceList;
|
||||
this.$root.publicGroupList = res.data.publicGroupList;
|
||||
|
||||
this.loading = false;
|
||||
|
||||
feedInterval = setInterval(
|
||||
() => {
|
||||
this.updateHeartbeatList();
|
||||
},
|
||||
Math.max(5, this.config.autoRefreshInterval) * 1000
|
||||
);
|
||||
|
||||
this.incident = res.data.incident;
|
||||
this.maintenanceList = res.data.maintenanceList;
|
||||
this.$root.publicGroupList = res.data.publicGroupList;
|
||||
@ -1030,7 +972,6 @@ export default {
|
||||
});
|
||||
|
||||
this.updateHeartbeatList();
|
||||
this.loadIncidentHistory();
|
||||
|
||||
// Go to edit page if ?edit present
|
||||
// null means ?edit present, but no value
|
||||
@ -1303,8 +1244,7 @@ export default {
|
||||
this.$root.getSocket().emit("postIncident", this.slug, this.incident, (res) => {
|
||||
if (res.ok) {
|
||||
this.enableEditIncidentMode = false;
|
||||
this.incident = null;
|
||||
this.loadIncidentHistory();
|
||||
this.incident = res.incident;
|
||||
} else {
|
||||
this.$root.toastError(res.msg);
|
||||
}
|
||||
@ -1312,14 +1252,12 @@ export default {
|
||||
},
|
||||
|
||||
/**
|
||||
* Edit an incident inline
|
||||
* @param {object} incident - The incident to edit
|
||||
* Click Edit Button
|
||||
* @returns {void}
|
||||
*/
|
||||
editIncident(incident) {
|
||||
this.previousIncident = this.incident;
|
||||
this.incident = { ...incident };
|
||||
editIncident() {
|
||||
this.enableEditIncidentMode = true;
|
||||
this.previousIncident = Object.assign({}, this.incident);
|
||||
},
|
||||
|
||||
/**
|
||||
@ -1345,18 +1283,6 @@ 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
|
||||
@ -1387,105 +1313,6 @@ export default {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load incident history for the status page
|
||||
* @returns {void}
|
||||
*/
|
||||
loadIncidentHistory() {
|
||||
this.loadIncidentHistoryWithCursor(null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Load incident history using cursor-based pagination
|
||||
* @param {string|null} cursor - Cursor for pagination (created_date of last item)
|
||||
* @param {boolean} append - Whether to append to existing list
|
||||
* @returns {void}
|
||||
*/
|
||||
loadIncidentHistoryWithCursor(cursor, append = false) {
|
||||
this.incidentHistoryLoading = true;
|
||||
|
||||
if (this.enableEditMode) {
|
||||
this.$root.getSocket().emit("getIncidentHistory", this.slug, cursor, (res) => {
|
||||
this.incidentHistoryLoading = false;
|
||||
if (res.ok) {
|
||||
if (append) {
|
||||
this.incidentHistory = [...this.incidentHistory, ...res.incidents];
|
||||
} else {
|
||||
this.incidentHistory = res.incidents;
|
||||
}
|
||||
this.incidentHistoryNextCursor = res.nextCursor;
|
||||
this.incidentHistoryHasMore = res.hasMore;
|
||||
} else {
|
||||
console.error("Failed to load incident history:", res.msg);
|
||||
this.$root.toastError(res.msg);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const url = cursor
|
||||
? `/api/status-page/${this.slug}/incident-history?cursor=${encodeURIComponent(cursor)}`
|
||||
: `/api/status-page/${this.slug}/incident-history`;
|
||||
axios
|
||||
.get(url)
|
||||
.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.incidentHistoryNextCursor = res.data.nextCursor;
|
||||
this.incidentHistoryHasMore = res.data.hasMore;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.incidentHistoryLoading = false;
|
||||
console.error("Failed to load incident history:", error);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load more incident history using cursor-based pagination
|
||||
* @returns {void}
|
||||
*/
|
||||
loadMoreIncidentHistory() {
|
||||
if (this.incidentHistoryHasMore && this.incidentHistoryNextCursor) {
|
||||
this.loadIncidentHistoryWithCursor(this.incidentHistoryNextCursor, 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 this.$t("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>
|
||||
@ -1604,14 +1431,12 @@ footer {
|
||||
|
||||
/* Reset button placed at top-left of the logo */
|
||||
.reset-top-left {
|
||||
transition:
|
||||
transform $easing-in 0.18s,
|
||||
box-shadow $easing-in 0.18s,
|
||||
background-color $easing-in 0.18s;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -15px;
|
||||
z-index: 2;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -1620,6 +1445,11 @@ footer {
|
||||
border: none;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition:
|
||||
transform $easing-in 0.18s,
|
||||
box-shadow $easing-in 0.18s,
|
||||
background-color $easing-in 0.18s;
|
||||
transform-origin: center;
|
||||
|
||||
&:hover {
|
||||
@ -1756,28 +1586,4 @@ footer {
|
||||
.refresh-info {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.past-incidents-title {
|
||||
font-size: 26px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.past-incidents-section {
|
||||
.past-incidents-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
157
src/util.js
157
src/util.js
@ -10,12 +10,99 @@
|
||||
*/
|
||||
var _a;
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.CONSOLE_STYLE_FgLightBlue = exports.CONSOLE_STYLE_FgLightGreen = exports.CONSOLE_STYLE_FgOrange = exports.CONSOLE_STYLE_FgGray = exports.CONSOLE_STYLE_FgWhite = exports.CONSOLE_STYLE_FgCyan = exports.CONSOLE_STYLE_FgMagenta = exports.CONSOLE_STYLE_FgBlue = exports.CONSOLE_STYLE_FgYellow = exports.CONSOLE_STYLE_FgGreen = exports.CONSOLE_STYLE_FgRed = exports.CONSOLE_STYLE_FgBlack = exports.CONSOLE_STYLE_Hidden = exports.CONSOLE_STYLE_Reverse = exports.CONSOLE_STYLE_Blink = exports.CONSOLE_STYLE_Underscore = exports.CONSOLE_STYLE_Dim = exports.CONSOLE_STYLE_Bright = exports.CONSOLE_STYLE_Reset = exports.RESPONSE_BODY_LENGTH_MAX = exports.RESPONSE_BODY_LENGTH_DEFAULT = exports.PING_PER_REQUEST_TIMEOUT_DEFAULT = exports.PING_PER_REQUEST_TIMEOUT_MAX = exports.PING_PER_REQUEST_TIMEOUT_MIN = exports.PING_COUNT_DEFAULT = exports.PING_COUNT_MAX = exports.PING_COUNT_MIN = exports.PING_GLOBAL_TIMEOUT_DEFAULT = exports.PING_GLOBAL_TIMEOUT_MAX = exports.PING_GLOBAL_TIMEOUT_MIN = exports.PING_PACKET_SIZE_DEFAULT = exports.PING_PACKET_SIZE_MAX = exports.PING_PACKET_SIZE_MIN = exports.INCIDENT_PAGE_SIZE = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isNode = exports.isDev = void 0;
|
||||
exports.TYPES_WITH_DOMAIN_EXPIRY_SUPPORT_VIA_FIELD = exports.evaluateJsonQuery = exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.badgeConstants = exports.CONSOLE_STYLE_BgGray = exports.CONSOLE_STYLE_BgWhite = exports.CONSOLE_STYLE_BgCyan = exports.CONSOLE_STYLE_BgMagenta = exports.CONSOLE_STYLE_BgBlue = exports.CONSOLE_STYLE_BgYellow = exports.CONSOLE_STYLE_BgGreen = exports.CONSOLE_STYLE_BgRed = exports.CONSOLE_STYLE_BgBlack = exports.CONSOLE_STYLE_FgPink = exports.CONSOLE_STYLE_FgBrown = exports.CONSOLE_STYLE_FgViolet = void 0;
|
||||
exports.CONSOLE_STYLE_FgViolet =
|
||||
exports.CONSOLE_STYLE_FgLightBlue =
|
||||
exports.CONSOLE_STYLE_FgLightGreen =
|
||||
exports.CONSOLE_STYLE_FgOrange =
|
||||
exports.CONSOLE_STYLE_FgGray =
|
||||
exports.CONSOLE_STYLE_FgWhite =
|
||||
exports.CONSOLE_STYLE_FgCyan =
|
||||
exports.CONSOLE_STYLE_FgMagenta =
|
||||
exports.CONSOLE_STYLE_FgBlue =
|
||||
exports.CONSOLE_STYLE_FgYellow =
|
||||
exports.CONSOLE_STYLE_FgGreen =
|
||||
exports.CONSOLE_STYLE_FgRed =
|
||||
exports.CONSOLE_STYLE_FgBlack =
|
||||
exports.CONSOLE_STYLE_Hidden =
|
||||
exports.CONSOLE_STYLE_Reverse =
|
||||
exports.CONSOLE_STYLE_Blink =
|
||||
exports.CONSOLE_STYLE_Underscore =
|
||||
exports.CONSOLE_STYLE_Dim =
|
||||
exports.CONSOLE_STYLE_Bright =
|
||||
exports.CONSOLE_STYLE_Reset =
|
||||
exports.RESPONSE_BODY_LENGTH_MAX =
|
||||
exports.RESPONSE_BODY_LENGTH_DEFAULT =
|
||||
exports.PING_PER_REQUEST_TIMEOUT_DEFAULT =
|
||||
exports.PING_PER_REQUEST_TIMEOUT_MAX =
|
||||
exports.PING_PER_REQUEST_TIMEOUT_MIN =
|
||||
exports.PING_COUNT_DEFAULT =
|
||||
exports.PING_COUNT_MAX =
|
||||
exports.PING_COUNT_MIN =
|
||||
exports.PING_GLOBAL_TIMEOUT_DEFAULT =
|
||||
exports.PING_GLOBAL_TIMEOUT_MAX =
|
||||
exports.PING_GLOBAL_TIMEOUT_MIN =
|
||||
exports.PING_PACKET_SIZE_DEFAULT =
|
||||
exports.PING_PACKET_SIZE_MAX =
|
||||
exports.PING_PACKET_SIZE_MIN =
|
||||
exports.MIN_INTERVAL_SECOND =
|
||||
exports.MAX_INTERVAL_SECOND =
|
||||
exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND =
|
||||
exports.SQL_DATETIME_FORMAT =
|
||||
exports.SQL_DATE_FORMAT =
|
||||
exports.STATUS_PAGE_MAINTENANCE =
|
||||
exports.STATUS_PAGE_PARTIAL_DOWN =
|
||||
exports.STATUS_PAGE_ALL_UP =
|
||||
exports.STATUS_PAGE_ALL_DOWN =
|
||||
exports.MAINTENANCE =
|
||||
exports.PENDING =
|
||||
exports.UP =
|
||||
exports.DOWN =
|
||||
exports.appName =
|
||||
exports.isNode =
|
||||
exports.isDev =
|
||||
void 0;
|
||||
exports.TYPES_WITH_DOMAIN_EXPIRY_SUPPORT_VIA_FIELD =
|
||||
exports.evaluateJsonQuery =
|
||||
exports.intHash =
|
||||
exports.localToUTC =
|
||||
exports.utcToLocal =
|
||||
exports.utcToISODateTime =
|
||||
exports.isoToUTCDateTime =
|
||||
exports.parseTimeFromTimeObject =
|
||||
exports.parseTimeObject =
|
||||
exports.getMonitorRelativeURL =
|
||||
exports.genSecret =
|
||||
exports.getCryptoRandomInt =
|
||||
exports.getRandomInt =
|
||||
exports.getRandomArbitrary =
|
||||
exports.TimeLogger =
|
||||
exports.polyfill =
|
||||
exports.log =
|
||||
exports.debug =
|
||||
exports.ucfirst =
|
||||
exports.sleep =
|
||||
exports.flipStatus =
|
||||
exports.badgeConstants =
|
||||
exports.CONSOLE_STYLE_BgGray =
|
||||
exports.CONSOLE_STYLE_BgWhite =
|
||||
exports.CONSOLE_STYLE_BgCyan =
|
||||
exports.CONSOLE_STYLE_BgMagenta =
|
||||
exports.CONSOLE_STYLE_BgBlue =
|
||||
exports.CONSOLE_STYLE_BgYellow =
|
||||
exports.CONSOLE_STYLE_BgGreen =
|
||||
exports.CONSOLE_STYLE_BgRed =
|
||||
exports.CONSOLE_STYLE_BgBlack =
|
||||
exports.CONSOLE_STYLE_FgPink =
|
||||
exports.CONSOLE_STYLE_FgBrown =
|
||||
void 0;
|
||||
const dayjs_1 = require("dayjs");
|
||||
const jsonata = require("jsonata");
|
||||
exports.isDev = process.env.NODE_ENV === "development";
|
||||
exports.isNode = typeof process !== "undefined" && ((_a = process === null || process === void 0 ? void 0 : process.versions) === null || _a === void 0 ? void 0 : _a.node);
|
||||
exports.isNode =
|
||||
typeof process !== "undefined" &&
|
||||
((_a = process === null || process === void 0 ? void 0 : process.versions) === null || _a === void 0
|
||||
? void 0
|
||||
: _a.node);
|
||||
const dayjs = exports.isNode ? require("dayjs") : dayjs_1.default;
|
||||
exports.appName = "Uptime Kuma";
|
||||
exports.DOWN = 0;
|
||||
@ -31,7 +118,6 @@ exports.SQL_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
|
||||
exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = "YYYY-MM-DD HH:mm";
|
||||
exports.MAX_INTERVAL_SECOND = 2073600;
|
||||
exports.MIN_INTERVAL_SECOND = 1;
|
||||
exports.INCIDENT_PAGE_SIZE = 10;
|
||||
exports.PING_PACKET_SIZE_MIN = 1;
|
||||
exports.PING_PACKET_SIZE_MAX = 65500;
|
||||
exports.PING_PACKET_SIZE_DEFAULT = 56;
|
||||
@ -171,8 +257,7 @@ class Logger {
|
||||
let now;
|
||||
if (dayjs.tz) {
|
||||
now = dayjs.tz(new Date()).format();
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
now = dayjs().format();
|
||||
}
|
||||
const levelColor = consoleLevelColors[level];
|
||||
@ -191,8 +276,7 @@ class Logger {
|
||||
}
|
||||
modulePart = "[" + moduleColor + module + exports.CONSOLE_STYLE_Reset + "]";
|
||||
levelPart = levelColor + `${level}:` + exports.CONSOLE_STYLE_Reset;
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
timePart = now;
|
||||
modulePart = `[${module}]`;
|
||||
levelPart = `${level}:`;
|
||||
@ -266,21 +350,21 @@ function getRandomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
exports.getRandomInt = getRandomInt;
|
||||
const getRandomBytes = (typeof window !== "undefined" && window.crypto
|
||||
?
|
||||
function () {
|
||||
return (numBytes) => {
|
||||
const randomBytes = new Uint8Array(numBytes);
|
||||
for (let i = 0; i < numBytes; i += 65536) {
|
||||
window.crypto.getRandomValues(randomBytes.subarray(i, i + Math.min(numBytes - i, 65536)));
|
||||
}
|
||||
return randomBytes;
|
||||
};
|
||||
}
|
||||
:
|
||||
function () {
|
||||
return require("crypto").randomBytes;
|
||||
})();
|
||||
const getRandomBytes = (
|
||||
typeof window !== "undefined" && window.crypto
|
||||
? function () {
|
||||
return (numBytes) => {
|
||||
const randomBytes = new Uint8Array(numBytes);
|
||||
for (let i = 0; i < numBytes; i += 65536) {
|
||||
window.crypto.getRandomValues(randomBytes.subarray(i, i + Math.min(numBytes - i, 65536)));
|
||||
}
|
||||
return randomBytes;
|
||||
};
|
||||
}
|
||||
: function () {
|
||||
return require("crypto").randomBytes;
|
||||
}
|
||||
)();
|
||||
function getCryptoRandomInt(min, max) {
|
||||
const range = max - min;
|
||||
if (range >= Math.pow(2, 32)) {
|
||||
@ -306,8 +390,7 @@ function getCryptoRandomInt(min, max) {
|
||||
randomValue = randomValue & mask;
|
||||
if (randomValue <= range) {
|
||||
return min + randomValue;
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
return getCryptoRandomInt(min, max);
|
||||
}
|
||||
}
|
||||
@ -388,8 +471,7 @@ async function evaluateJsonQuery(data, jsonPath, jsonPathOperator, expectedValue
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(data);
|
||||
}
|
||||
catch (_a) {
|
||||
} catch (_a) {
|
||||
response =
|
||||
(typeof data === "object" || typeof data === "number") && !Buffer.isBuffer(data) ? data : data.toString();
|
||||
}
|
||||
@ -401,13 +483,17 @@ async function evaluateJsonQuery(data, jsonPath, jsonPathOperator, expectedValue
|
||||
if (Array.isArray(response)) {
|
||||
const responseStr = JSON.stringify(response);
|
||||
const truncatedResponse = responseStr.length > 25 ? responseStr.substring(0, 25) + "...]" : responseStr;
|
||||
throw new Error("JSON query returned the array " +
|
||||
truncatedResponse +
|
||||
", but a primitive value is required. " +
|
||||
"Modify your query to return a single value via [0] to get the first element or use an aggregation like $count(), $sum() or $boolean().");
|
||||
throw new Error(
|
||||
"JSON query returned the array " +
|
||||
truncatedResponse +
|
||||
", but a primitive value is required. " +
|
||||
"Modify your query to return a single value via [0] to get the first element or use an aggregation like $count(), $sum() or $boolean()."
|
||||
);
|
||||
}
|
||||
if (typeof response === "object" || response instanceof Date || typeof response === "function") {
|
||||
throw new Error(`The post-JSON query evaluated response from the server is of type ${typeof response}, which cannot be directly compared to the expected value`);
|
||||
throw new Error(
|
||||
`The post-JSON query evaluated response from the server is of type ${typeof response}, which cannot be directly compared to the expected value`
|
||||
);
|
||||
}
|
||||
let jsonQueryExpression;
|
||||
switch (jsonPathOperator) {
|
||||
@ -435,14 +521,15 @@ async function evaluateJsonQuery(data, jsonPath, jsonPathOperator, expectedValue
|
||||
expected: expectedValue.toString(),
|
||||
});
|
||||
if (status === undefined) {
|
||||
throw new Error("Query evaluation returned undefined. Check query syntax and the structure of the response data");
|
||||
throw new Error(
|
||||
"Query evaluation returned undefined. Check query syntax and the structure of the response data"
|
||||
);
|
||||
}
|
||||
return {
|
||||
status,
|
||||
response,
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
} catch (err) {
|
||||
response = JSON.stringify(response);
|
||||
response = response && response.length > 50 ? `${response.substring(0, 100)}… (truncated)` : response;
|
||||
throw new Error(`Error evaluating JSON query: ${err.message}. Response from server was: ${response}`);
|
||||
|
||||
@ -46,8 +46,6 @@ export const SQL_DATETIME_FORMAT_WITHOUT_SECOND = "YYYY-MM-DD HH:mm";
|
||||
export const MAX_INTERVAL_SECOND = 2073600; // 24 days
|
||||
export const MIN_INTERVAL_SECOND = 1; // 1 second
|
||||
|
||||
export const INCIDENT_PAGE_SIZE = 10;
|
||||
|
||||
// Packet Size limits
|
||||
export const PING_PACKET_SIZE_MIN = 1;
|
||||
export const PING_PACKET_SIZE_MAX = 65500;
|
||||
|
||||
@ -1,150 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { login, restoreSqliteSnapshot, screenshot } from "../util-test";
|
||||
|
||||
test.describe("Incident History", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await restoreSqliteSnapshot(page);
|
||||
});
|
||||
|
||||
test("past incidents section is hidden when no incidents exist", async ({ page }, testInfo) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
await page.goto("./add");
|
||||
await login(page);
|
||||
await expect(page.getByTestId("monitor-type-select")).toBeVisible();
|
||||
|
||||
await page.goto("./add-status-page");
|
||||
await page.getByTestId("name-input").fill("Empty Test");
|
||||
await page.getByTestId("slug-input").fill("empty-test");
|
||||
await page.getByTestId("submit-button").click();
|
||||
await page.waitForURL("/status/empty-test?edit");
|
||||
|
||||
await page.getByTestId("save-button").click();
|
||||
await expect(page.getByTestId("edit-sidebar")).toHaveCount(0);
|
||||
|
||||
const pastIncidentsSection = page.locator(".past-incidents-section");
|
||||
await expect(pastIncidentsSection).toHaveCount(0);
|
||||
|
||||
await screenshot(testInfo, page);
|
||||
});
|
||||
|
||||
test("active pinned incidents are shown at top and not in past incidents", async ({ page }, testInfo) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
await page.goto("./add");
|
||||
await login(page);
|
||||
await expect(page.getByTestId("monitor-type-select")).toBeVisible();
|
||||
|
||||
await page.goto("./add-status-page");
|
||||
await page.getByTestId("name-input").fill("Dedup Test");
|
||||
await page.getByTestId("slug-input").fill("dedup-test");
|
||||
await page.getByTestId("submit-button").click();
|
||||
await page.waitForURL("/status/dedup-test?edit");
|
||||
|
||||
await page.getByTestId("create-incident-button").click();
|
||||
await page.getByTestId("incident-title").fill("Active Incident");
|
||||
await page.getByTestId("incident-content-editable").fill("This is an active incident");
|
||||
await page.getByTestId("post-incident-button").click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.getByTestId("save-button").click();
|
||||
await expect(page.getByTestId("edit-sidebar")).toHaveCount(0);
|
||||
|
||||
const activeIncident = page.getByTestId("incident").filter({ hasText: "Active Incident" });
|
||||
await expect(activeIncident).toBeVisible();
|
||||
|
||||
const pastIncidentsSection = page.locator(".past-incidents-section");
|
||||
await expect(pastIncidentsSection).toHaveCount(0);
|
||||
|
||||
await screenshot(testInfo, page);
|
||||
});
|
||||
|
||||
test("resolved incidents appear in past incidents section", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
await page.goto("./add");
|
||||
await login(page);
|
||||
await expect(page.getByTestId("monitor-type-select")).toBeVisible();
|
||||
|
||||
await page.goto("./add-status-page");
|
||||
await page.getByTestId("name-input").fill("Resolve Test");
|
||||
await page.getByTestId("slug-input").fill("resolve-test");
|
||||
await page.getByTestId("submit-button").click();
|
||||
await page.waitForURL("/status/resolve-test?edit");
|
||||
|
||||
await page.getByTestId("create-incident-button").click();
|
||||
await page.getByTestId("incident-title").fill("Resolved Incident");
|
||||
await page.getByTestId("incident-content-editable").fill("This incident will be resolved");
|
||||
await page.getByTestId("post-incident-button").click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const activeIncidentBanner = page.getByTestId("incident").filter({ hasText: "Resolved Incident" });
|
||||
await expect(activeIncidentBanner).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const resolveButton = activeIncidentBanner.locator("button", { hasText: "Resolve" });
|
||||
await expect(resolveButton).toBeVisible();
|
||||
await resolveButton.click();
|
||||
|
||||
await expect(activeIncidentBanner).toHaveCount(0, { timeout: 10000 });
|
||||
|
||||
await page.getByTestId("save-button").click();
|
||||
await expect(page.getByTestId("edit-sidebar")).toHaveCount(0);
|
||||
|
||||
await page.goto("./status/resolve-test");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const pastIncidentsSection = page.locator(".past-incidents-section");
|
||||
await expect(pastIncidentsSection).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const resolvedIncidentTitle = pastIncidentsSection.locator(".incident-title");
|
||||
await expect(resolvedIncidentTitle).toContainText("Resolved Incident", { timeout: 15000 });
|
||||
|
||||
await screenshot(testInfo, page);
|
||||
});
|
||||
|
||||
test("incident history pagination loads more incidents", async ({ page }, testInfo) => {
|
||||
test.setTimeout(180000);
|
||||
|
||||
await page.goto("./add");
|
||||
await login(page);
|
||||
await expect(page.getByTestId("monitor-type-select")).toBeVisible();
|
||||
|
||||
await page.goto("./add-status-page");
|
||||
await page.getByTestId("name-input").fill("Pagination Test");
|
||||
await page.getByTestId("slug-input").fill("pagination-test");
|
||||
await page.getByTestId("submit-button").click();
|
||||
await page.waitForURL("/status/pagination-test?edit");
|
||||
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
await page.getByTestId("create-incident-button").click();
|
||||
await page.getByTestId("incident-title").fill("Incident " + i);
|
||||
await page.getByTestId("incident-content-editable").fill("Content for incident " + i);
|
||||
await page.getByTestId("post-incident-button").click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const resolveButton = page.locator("button", { hasText: "Resolve" }).first();
|
||||
if (await resolveButton.isVisible()) {
|
||||
await resolveButton.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
|
||||
await page.getByTestId("save-button").click();
|
||||
await expect(page.getByTestId("edit-sidebar")).toHaveCount(0);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const pastIncidentsSection = page.locator(".past-incidents-section");
|
||||
await expect(pastIncidentsSection).toBeVisible();
|
||||
|
||||
const loadMoreButton = page.locator("button", { hasText: "Load More" });
|
||||
|
||||
if (await loadMoreButton.isVisible()) {
|
||||
await loadMoreButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await screenshot(testInfo, page);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user