Merge remote-tracking branch 'upstream/master' into fix/push-monitor-retries
This commit is contained in:
commit
e6eb8f327b
4
.github/workflows/autofix.yml
vendored
4
.github/workflows/autofix.yml
vendored
@ -30,6 +30,10 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Update RDAP DNS data from IANA
|
||||||
|
run: wget -O server/model/rdap-dns.json https://data.iana.org/rdap/dns.json
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Auto-fix JavaScript/Vue linting issues
|
- name: Auto-fix JavaScript/Vue linting issues
|
||||||
run: npm run lint-fix:js
|
run: npm run lint-fix:js
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
11
db/knex_migrations/2025-12-31-2143-add-snmp-v3-username.js
Normal file
11
db/knex_migrations/2025-12-31-2143-add-snmp-v3-username.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
exports.up = async function (knex) {
|
||||||
|
await knex.schema.alterTable("monitor", (table) => {
|
||||||
|
table.string("snmp_v3_username", 255);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
await knex.schema.alterTable("monitor", (table) => {
|
||||||
|
table.dropColumn("snmp_v3_username");
|
||||||
|
});
|
||||||
|
};
|
||||||
1566
package-lock.json
generated
1566
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -122,7 +122,6 @@
|
|||||||
"nanoid": "~3.3.4",
|
"nanoid": "~3.3.4",
|
||||||
"net-snmp": "^3.11.2",
|
"net-snmp": "^3.11.2",
|
||||||
"node-cloudflared-tunnel": "~1.0.9",
|
"node-cloudflared-tunnel": "~1.0.9",
|
||||||
"node-fetch-cache": "^5.1.0",
|
|
||||||
"node-radius-utils": "~1.2.0",
|
"node-radius-utils": "~1.2.0",
|
||||||
"nodemailer": "~7.0.12",
|
"nodemailer": "~7.0.12",
|
||||||
"nostr-tools": "^2.17.0",
|
"nostr-tools": "^2.17.0",
|
||||||
@ -207,7 +206,7 @@
|
|||||||
"stylelint-config-standard": "~25.0.0",
|
"stylelint-config-standard": "~25.0.0",
|
||||||
"terser": "~5.15.0",
|
"terser": "~5.15.0",
|
||||||
"test": "~3.3.0",
|
"test": "~3.3.0",
|
||||||
"testcontainers": "^10.13.1",
|
"testcontainers": "^11.5.0",
|
||||||
"typescript": "~4.4.4",
|
"typescript": "~4.4.4",
|
||||||
"v-pagination-3": "~0.1.7",
|
"v-pagination-3": "~0.1.7",
|
||||||
"vite": "~5.4.15",
|
"vite": "~5.4.15",
|
||||||
|
|||||||
@ -165,7 +165,7 @@ class Database {
|
|||||||
* Read the database config
|
* Read the database config
|
||||||
* @throws {Error} If the config is invalid
|
* @throws {Error} If the config is invalid
|
||||||
* @typedef {string|undefined} envString
|
* @typedef {string|undefined} envString
|
||||||
* @returns {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} Database config
|
* @returns {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString, socketPath:envString}} Database config
|
||||||
*/
|
*/
|
||||||
static readDBConfig() {
|
static readDBConfig() {
|
||||||
let dbConfig;
|
let dbConfig;
|
||||||
@ -185,7 +185,7 @@ class Database {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {string|undefined} envString
|
* @typedef {string|undefined} envString
|
||||||
* @param {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} dbConfig the database configuration that should be written
|
* @param {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString, socketPath:envString}} dbConfig the database configuration that should be written
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
static writeDBConfig(dbConfig) {
|
static writeDBConfig(dbConfig) {
|
||||||
@ -284,6 +284,7 @@ class Database {
|
|||||||
port: dbConfig.port,
|
port: dbConfig.port,
|
||||||
user: dbConfig.username,
|
user: dbConfig.username,
|
||||||
password: dbConfig.password,
|
password: dbConfig.password,
|
||||||
|
socketPath: dbConfig.socketPath,
|
||||||
...(dbConfig.ssl
|
...(dbConfig.ssl
|
||||||
? {
|
? {
|
||||||
ssl: {
|
ssl: {
|
||||||
@ -309,6 +310,7 @@ class Database {
|
|||||||
user: dbConfig.username,
|
user: dbConfig.username,
|
||||||
password: dbConfig.password,
|
password: dbConfig.password,
|
||||||
database: dbConfig.dbName,
|
database: dbConfig.dbName,
|
||||||
|
socketPath: dbConfig.socketPath,
|
||||||
timezone: "Z",
|
timezone: "Z",
|
||||||
typeCast: function (field, next) {
|
typeCast: function (field, next) {
|
||||||
if (field.type === "DATETIME") {
|
if (field.type === "DATETIME") {
|
||||||
|
|||||||
@ -4,33 +4,19 @@ const { log, TYPES_WITH_DOMAIN_EXPIRY_SUPPORT_VIA_FIELD } = require("../../src/u
|
|||||||
const { parse: parseTld } = require("tldts");
|
const { parse: parseTld } = require("tldts");
|
||||||
const { setting, setSetting } = require("../util-server");
|
const { setting, setSetting } = require("../util-server");
|
||||||
const { Notification } = require("../notification");
|
const { Notification } = require("../notification");
|
||||||
const { default: NodeFetchCache, MemoryCache } = require("node-fetch-cache");
|
|
||||||
const TranslatableError = require("../translatable-error");
|
const TranslatableError = require("../translatable-error");
|
||||||
const dayjs = require("dayjs");
|
const dayjs = require("dayjs");
|
||||||
|
|
||||||
const cachedFetch = process.env.NODE_ENV
|
// Load static RDAP DNS data from local file (auto-updated by CI)
|
||||||
? NodeFetchCache.create({
|
const rdapDnsData = require("./rdap-dns.json");
|
||||||
// cache for 8h
|
|
||||||
cache: new MemoryCache({ ttl: 1000 * 60 * 60 * 8 }),
|
|
||||||
})
|
|
||||||
: fetch;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the RDAP server for a given TLD
|
* Find the RDAP server for a given TLD
|
||||||
* @param {string} tld TLD
|
* @param {string} tld TLD
|
||||||
* @returns {Promise<string>} First RDAP server found
|
* @returns {string|null} First RDAP server found
|
||||||
*/
|
*/
|
||||||
async function getRdapServer(tld) {
|
function getRdapServer(tld) {
|
||||||
let rdapList;
|
const services = rdapDnsData["services"] ?? [];
|
||||||
try {
|
|
||||||
const res = await cachedFetch("https://data.iana.org/rdap/dns.json");
|
|
||||||
rdapList = await res.json();
|
|
||||||
} catch (error) {
|
|
||||||
log.debug("rdap", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const services = rdapList["services"] ?? [];
|
|
||||||
const rootTld = tld?.split(".").pop();
|
const rootTld = tld?.split(".").pop();
|
||||||
if (rootTld) {
|
if (rootTld) {
|
||||||
for (const [tlds, urls] of services) {
|
for (const [tlds, urls] of services) {
|
||||||
@ -50,7 +36,7 @@ async function getRdapServer(tld) {
|
|||||||
*/
|
*/
|
||||||
async function getRdapDomainExpiryDate(domain) {
|
async function getRdapDomainExpiryDate(domain) {
|
||||||
const tld = DomainExpiry.parseTld(domain).publicSuffix;
|
const tld = DomainExpiry.parseTld(domain).publicSuffix;
|
||||||
const rdapServer = await getRdapServer(tld);
|
const rdapServer = getRdapServer(tld);
|
||||||
if (rdapServer === null) {
|
if (rdapServer === null) {
|
||||||
log.warn("rdap", `No RDAP server found, TLD ${tld} not supported.`);
|
log.warn("rdap", `No RDAP server found, TLD ${tld} not supported.`);
|
||||||
return null;
|
return null;
|
||||||
@ -178,7 +164,7 @@ class DomainExpiry extends BeanModel {
|
|||||||
|
|
||||||
const publicSuffix = tld.publicSuffix;
|
const publicSuffix = tld.publicSuffix;
|
||||||
const rootTld = publicSuffix.split(".").pop();
|
const rootTld = publicSuffix.split(".").pop();
|
||||||
const rdap = await getRdapServer(publicSuffix);
|
const rdap = getRdapServer(publicSuffix);
|
||||||
if (!rdap) {
|
if (!rdap) {
|
||||||
throw new TranslatableError("domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint", {
|
throw new TranslatableError("domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint", {
|
||||||
publicSuffix,
|
publicSuffix,
|
||||||
|
|||||||
@ -1,9 +1,21 @@
|
|||||||
const { BeanModel } = require("redbean-node/dist/bean-model");
|
const { BeanModel } = require("redbean-node/dist/bean-model");
|
||||||
|
const { R } = require("redbean-node");
|
||||||
|
const dayjs = require("dayjs");
|
||||||
|
|
||||||
class Incident extends BeanModel {
|
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
|
* Return an object that ready to parse to JSON for public
|
||||||
* Only show necessary data to public
|
|
||||||
* @returns {object} Object ready to parse
|
* @returns {object} Object ready to parse
|
||||||
*/
|
*/
|
||||||
toPublicJSON() {
|
toPublicJSON() {
|
||||||
@ -12,9 +24,11 @@ class Incident extends BeanModel {
|
|||||||
style: this.style,
|
style: this.style,
|
||||||
title: this.title,
|
title: this.title,
|
||||||
content: this.content,
|
content: this.content,
|
||||||
pin: this.pin,
|
pin: !!this.pin,
|
||||||
createdDate: this.createdDate,
|
active: !!this.active,
|
||||||
lastUpdatedDate: this.lastUpdatedDate,
|
createdDate: this.created_date,
|
||||||
|
lastUpdatedDate: this.last_updated_date,
|
||||||
|
status_page_id: this.status_page_id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1517,8 +1517,6 @@ class Monitor extends BeanModel {
|
|||||||
|
|
||||||
let msg = `[${monitor.name}] [${text}] ${bean.msg}`;
|
let msg = `[${monitor.name}] [${text}] ${bean.msg}`;
|
||||||
|
|
||||||
for (let notification of notificationList) {
|
|
||||||
try {
|
|
||||||
const heartbeatJSON = await bean.toJSONAsync({ decodeResponse: true });
|
const heartbeatJSON = await bean.toJSONAsync({ decodeResponse: true });
|
||||||
const monitorData = [{ id: monitor.id, active: monitor.active, name: monitor.name }];
|
const monitorData = [{ id: monitor.id, active: monitor.active, name: monitor.name }];
|
||||||
const preloadData = await Monitor.preparePreloadData(monitorData);
|
const preloadData = await Monitor.preparePreloadData(monitorData);
|
||||||
@ -1535,6 +1533,30 @@ class Monitor extends BeanModel {
|
|||||||
.tz(heartbeatJSON["timezone"])
|
.tz(heartbeatJSON["timezone"])
|
||||||
.format(SQL_DATETIME_FORMAT);
|
.format(SQL_DATETIME_FORMAT);
|
||||||
|
|
||||||
|
// Calculate downtime tracking information when service comes back up
|
||||||
|
// This makes downtime information available to all notification providers
|
||||||
|
if (bean.status === UP && monitor.id) {
|
||||||
|
try {
|
||||||
|
const lastDownHeartbeat = await R.getRow(
|
||||||
|
"SELECT time FROM heartbeat WHERE monitor_id = ? AND status = ? ORDER BY time DESC LIMIT 1",
|
||||||
|
[monitor.id, DOWN]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lastDownHeartbeat && lastDownHeartbeat.time) {
|
||||||
|
heartbeatJSON["lastDownTime"] = lastDownHeartbeat.time;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If we can't calculate downtime, just continue without it
|
||||||
|
// Silently fail to avoid disrupting notification sending
|
||||||
|
log.debug(
|
||||||
|
"monitor",
|
||||||
|
`[${monitor.name}] Could not calculate downtime information: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let notification of notificationList) {
|
||||||
|
try {
|
||||||
await Notification.send(
|
await Notification.send(
|
||||||
JSON.parse(notification.config),
|
JSON.parse(notification.config),
|
||||||
msg,
|
msg,
|
||||||
@ -1736,6 +1758,55 @@ 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") {
|
if (this.type === "ping") {
|
||||||
// ping parameters validation
|
// ping parameters validation
|
||||||
if (this.packetSize && (this.packetSize < PING_PACKET_SIZE_MIN || this.packetSize > PING_PACKET_SIZE_MAX)) {
|
if (this.packetSize && (this.packetSize < PING_PACKET_SIZE_MIN || this.packetSize > PING_PACKET_SIZE_MAX)) {
|
||||||
|
|||||||
1194
server/model/rdap-dns.json
Normal file
1194
server/model/rdap-dns.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -7,8 +7,8 @@ const analytics = require("../analytics/analytics");
|
|||||||
const { marked } = require("marked");
|
const { marked } = require("marked");
|
||||||
const { Feed } = require("feed");
|
const { Feed } = require("feed");
|
||||||
const config = require("../config");
|
const config = require("../config");
|
||||||
const { setting } = require("../util-server");
|
|
||||||
|
|
||||||
|
const { setting } = require("../util-server");
|
||||||
const {
|
const {
|
||||||
STATUS_PAGE_ALL_DOWN,
|
STATUS_PAGE_ALL_DOWN,
|
||||||
STATUS_PAGE_ALL_UP,
|
STATUS_PAGE_ALL_UP,
|
||||||
@ -17,6 +17,7 @@ const {
|
|||||||
UP,
|
UP,
|
||||||
MAINTENANCE,
|
MAINTENANCE,
|
||||||
DOWN,
|
DOWN,
|
||||||
|
INCIDENT_PAGE_SIZE,
|
||||||
} = require("../../src/util");
|
} = require("../../src/util");
|
||||||
|
|
||||||
class StatusPage extends BeanModel {
|
class StatusPage extends BeanModel {
|
||||||
@ -307,12 +308,13 @@ class StatusPage extends BeanModel {
|
|||||||
static async getStatusPageData(statusPage) {
|
static async getStatusPageData(statusPage) {
|
||||||
const config = await statusPage.toPublicJSON();
|
const config = await statusPage.toPublicJSON();
|
||||||
|
|
||||||
// Incident
|
// All active incidents
|
||||||
let incident = await R.findOne("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [statusPage.id]);
|
let incidents = await R.find(
|
||||||
|
"incident",
|
||||||
if (incident) {
|
" pin = 1 AND active = 1 AND status_page_id = ? ORDER BY created_date DESC",
|
||||||
incident = incident.toPublicJSON();
|
[statusPage.id]
|
||||||
}
|
);
|
||||||
|
incidents = incidents.map((i) => i.toPublicJSON());
|
||||||
|
|
||||||
let maintenanceList = await StatusPage.getMaintenanceList(statusPage.id);
|
let maintenanceList = await StatusPage.getMaintenanceList(statusPage.id);
|
||||||
|
|
||||||
@ -330,7 +332,7 @@ class StatusPage extends BeanModel {
|
|||||||
// Response
|
// Response
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
incident,
|
incidents,
|
||||||
publicGroupList,
|
publicGroupList,
|
||||||
maintenanceList,
|
maintenanceList,
|
||||||
};
|
};
|
||||||
@ -499,6 +501,54 @@ 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
|
* Get list of maintenances
|
||||||
* @param {number} statusPageId ID of status page to get maintenance for
|
* @param {number} statusPageId ID of status page to get maintenance for
|
||||||
|
|||||||
@ -17,7 +17,22 @@ class SNMPMonitorType extends MonitorType {
|
|||||||
timeout: monitor.timeout * 1000,
|
timeout: monitor.timeout * 1000,
|
||||||
version: snmp.Version[monitor.snmpVersion],
|
version: snmp.Version[monitor.snmpVersion],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (monitor.snmpVersion === "3") {
|
||||||
|
if (!monitor.snmp_v3_username) {
|
||||||
|
throw new Error("SNMPv3 username is required");
|
||||||
|
}
|
||||||
|
// SNMPv3 currently defaults to noAuthNoPriv.
|
||||||
|
// Supporting authNoPriv / authPriv requires additional inputs
|
||||||
|
// (auth/priv protocols, passwords), validation, secure storage,
|
||||||
|
// and database migrations, which is intentionally left for
|
||||||
|
// a follow-up PR to keep this change scoped.
|
||||||
|
sessionOptions.securityLevel = snmp.SecurityLevel.noAuthNoPriv;
|
||||||
|
sessionOptions.username = monitor.snmp_v3_username;
|
||||||
|
session = snmp.createV3Session(monitor.hostname, monitor.snmp_v3_username, sessionOptions);
|
||||||
|
} else {
|
||||||
session = snmp.createSession(monitor.hostname, monitor.radiusPassword, sessionOptions);
|
session = snmp.createSession(monitor.hostname, monitor.radiusPassword, sessionOptions);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle errors during session creation
|
// Handle errors during session creation
|
||||||
session.on("error", (error) => {
|
session.on("error", (error) => {
|
||||||
|
|||||||
@ -56,6 +56,8 @@ class Discord extends NotificationProvider {
|
|||||||
// If heartbeatJSON is not null, we go into the normal alerting loop.
|
// If heartbeatJSON is not null, we go into the normal alerting loop.
|
||||||
let addess = this.extractAddress(monitorJSON);
|
let addess = this.extractAddress(monitorJSON);
|
||||||
if (heartbeatJSON["status"] === DOWN) {
|
if (heartbeatJSON["status"] === DOWN) {
|
||||||
|
const wentOfflineTimestamp = Math.floor(new Date(heartbeatJSON["time"]).getTime() / 1000);
|
||||||
|
|
||||||
let discorddowndata = {
|
let discorddowndata = {
|
||||||
username: discordDisplayName,
|
username: discordDisplayName,
|
||||||
embeds: [
|
embeds: [
|
||||||
@ -76,6 +78,11 @@ class Discord extends NotificationProvider {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
{
|
||||||
|
name: "Went Offline",
|
||||||
|
// F for full date/time
|
||||||
|
value: `<t:${wentOfflineTimestamp}:F>`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: `Time (${heartbeatJSON["timezone"]})`,
|
name: `Time (${heartbeatJSON["timezone"]})`,
|
||||||
value: heartbeatJSON["localDateTime"],
|
value: heartbeatJSON["localDateTime"],
|
||||||
@ -104,6 +111,14 @@ class Discord extends NotificationProvider {
|
|||||||
await axios.post(webhookUrl.toString(), discorddowndata, config);
|
await axios.post(webhookUrl.toString(), discorddowndata, config);
|
||||||
return okMsg;
|
return okMsg;
|
||||||
} else if (heartbeatJSON["status"] === UP) {
|
} else if (heartbeatJSON["status"] === UP) {
|
||||||
|
const backOnlineTimestamp = Math.floor(new Date(heartbeatJSON["time"]).getTime() / 1000);
|
||||||
|
let downtimeDuration = null;
|
||||||
|
let wentOfflineTimestamp = null;
|
||||||
|
if (heartbeatJSON["lastDownTime"]) {
|
||||||
|
wentOfflineTimestamp = Math.floor(new Date(heartbeatJSON["lastDownTime"]).getTime() / 1000);
|
||||||
|
downtimeDuration = this.formatDuration(backOnlineTimestamp - wentOfflineTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
let discordupdata = {
|
let discordupdata = {
|
||||||
username: discordDisplayName,
|
username: discordDisplayName,
|
||||||
embeds: [
|
embeds: [
|
||||||
@ -124,10 +139,23 @@ class Discord extends NotificationProvider {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(wentOfflineTimestamp
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: `Time (${heartbeatJSON["timezone"]})`,
|
name: "Went Offline",
|
||||||
value: heartbeatJSON["localDateTime"],
|
// F for full date/time
|
||||||
|
value: `<t:${wentOfflineTimestamp}:F>`,
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(downtimeDuration
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: "Downtime Duration",
|
||||||
|
value: downtimeDuration,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(heartbeatJSON["ping"] != null
|
...(heartbeatJSON["ping"] != null
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@ -162,6 +190,32 @@ class Discord extends NotificationProvider {
|
|||||||
this.throwGeneralAxiosError(error);
|
this.throwGeneralAxiosError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format duration as human-readable string (e.g., "1h 23m", "45m 30s")
|
||||||
|
* TODO: Update below to `Intl.DurationFormat("en", { style: "short" }).format(duration)` once we are on a newer node version
|
||||||
|
* @param {number} timeInSeconds The time in seconds to format a duration for
|
||||||
|
* @returns {string} The formatted duration
|
||||||
|
*/
|
||||||
|
formatDuration(timeInSeconds) {
|
||||||
|
const hours = Math.floor(timeInSeconds / 3600);
|
||||||
|
const minutes = Math.floor((timeInSeconds % 3600) / 60);
|
||||||
|
const seconds = timeInSeconds % 60;
|
||||||
|
|
||||||
|
const durationParts = [];
|
||||||
|
if (hours > 0) {
|
||||||
|
durationParts.push(`${hours}h`);
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
durationParts.push(`${minutes}m`);
|
||||||
|
}
|
||||||
|
if (seconds > 0 && hours === 0) {
|
||||||
|
// Only show seconds if less than an hour
|
||||||
|
durationParts.push(`${seconds}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return durationParts.length > 0 ? durationParts.join(" ") : "0s";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Discord;
|
module.exports = Discord;
|
||||||
|
|||||||
@ -57,6 +57,19 @@ class Ntfy extends NotificationProvider {
|
|||||||
status = "Up";
|
status = "Up";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include monitor's assigned tags
|
||||||
|
if (monitorJSON && monitorJSON.tags && Array.isArray(monitorJSON.tags)) {
|
||||||
|
const monitorTagNames = monitorJSON.tags.map((tag) => {
|
||||||
|
// Include value if it exists
|
||||||
|
if (tag.value) {
|
||||||
|
return `${tag.name}: ${tag.value}`;
|
||||||
|
}
|
||||||
|
return tag.name;
|
||||||
|
});
|
||||||
|
tags = tags.concat(monitorTagNames);
|
||||||
|
}
|
||||||
|
|
||||||
let data = {
|
let data = {
|
||||||
topic: notification.ntfytopic,
|
topic: notification.ntfytopic,
|
||||||
message: heartbeatJSON.msg,
|
message: heartbeatJSON.msg,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
const nodemailer = require("nodemailer");
|
const nodemailer = require("nodemailer");
|
||||||
const NotificationProvider = require("./notification-provider");
|
const NotificationProvider = require("./notification-provider");
|
||||||
|
const { log } = require("../../src/util");
|
||||||
|
|
||||||
class SMTP extends NotificationProvider {
|
class SMTP extends NotificationProvider {
|
||||||
name = "smtp";
|
name = "smtp";
|
||||||
@ -14,11 +15,25 @@ class SMTP extends NotificationProvider {
|
|||||||
host: notification.smtpHost,
|
host: notification.smtpHost,
|
||||||
port: notification.smtpPort,
|
port: notification.smtpPort,
|
||||||
secure: notification.smtpSecure,
|
secure: notification.smtpSecure,
|
||||||
tls: {
|
|
||||||
rejectUnauthorized: !notification.smtpIgnoreTLSError || false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 = {
|
||||||
|
rejectUnauthorized: !notification.smtpIgnoreTLSError || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Fix #1129
|
// Fix #1129
|
||||||
if (notification.smtpDkimDomain) {
|
if (notification.smtpDkimDomain) {
|
||||||
config.dkim = {
|
config.dkim = {
|
||||||
|
|||||||
@ -18,7 +18,7 @@ class WeCom extends NotificationProvider {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
config = this.getAxiosConfigWithProxy(config);
|
config = this.getAxiosConfigWithProxy(config);
|
||||||
let body = this.composeMessage(heartbeatJSON, msg);
|
let body = this.composeMessage(notification, heartbeatJSON, msg);
|
||||||
await axios.post(
|
await axios.post(
|
||||||
`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${notification.weComBotKey}`,
|
`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${notification.weComBotKey}`,
|
||||||
body,
|
body,
|
||||||
@ -32,11 +32,12 @@ class WeCom extends NotificationProvider {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the message to send
|
* Generate the message to send
|
||||||
|
* @param {object} notification Notification configuration
|
||||||
* @param {object} heartbeatJSON Heartbeat details (For Up/Down only)
|
* @param {object} heartbeatJSON Heartbeat details (For Up/Down only)
|
||||||
* @param {string} msg General message
|
* @param {string} msg General message
|
||||||
* @returns {object} Message
|
* @returns {object} Message
|
||||||
*/
|
*/
|
||||||
composeMessage(heartbeatJSON, msg) {
|
composeMessage(notification, heartbeatJSON, msg) {
|
||||||
let title = "UptimeKuma Message";
|
let title = "UptimeKuma Message";
|
||||||
if (msg != null && heartbeatJSON != null && heartbeatJSON["status"] === UP) {
|
if (msg != null && heartbeatJSON != null && heartbeatJSON["status"] === UP) {
|
||||||
title = "UptimeKuma Monitor Up";
|
title = "UptimeKuma Monitor Up";
|
||||||
@ -44,11 +45,26 @@ class WeCom extends NotificationProvider {
|
|||||||
if (msg != null && heartbeatJSON != null && heartbeatJSON["status"] === DOWN) {
|
if (msg != null && heartbeatJSON != null && heartbeatJSON["status"] === DOWN) {
|
||||||
title = "UptimeKuma Monitor Down";
|
title = "UptimeKuma Monitor Down";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let textObj = {
|
||||||
|
content: title + "\n" + msg,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle mentioned_mobile_list if configured
|
||||||
|
if (notification.weComMentionedMobileList?.trim()) {
|
||||||
|
let mentionedMobiles = notification.weComMentionedMobileList
|
||||||
|
.split(",")
|
||||||
|
.map((mobile) => mobile.trim())
|
||||||
|
.filter((mobile) => mobile.length > 0);
|
||||||
|
|
||||||
|
if (mentionedMobiles.length > 0) {
|
||||||
|
textObj.mentioned_mobile_list = mentionedMobiles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
msgtype: "text",
|
msgtype: "text",
|
||||||
text: {
|
text: textObj,
|
||||||
content: title + "\n" + msg,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -142,6 +142,30 @@ 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
|
// overall status-page status badge
|
||||||
router.get("/api/status-page/:slug/badge", cache("5 minutes"), async (request, response) => {
|
router.get("/api/status-page/:slug/badge", cache("5 minutes"), async (request, response) => {
|
||||||
allowDevAllOrigin(response);
|
allowDevAllOrigin(response);
|
||||||
|
|||||||
@ -102,6 +102,7 @@ class SetupDatabase {
|
|||||||
dbConfig.dbName = process.env.UPTIME_KUMA_DB_NAME;
|
dbConfig.dbName = process.env.UPTIME_KUMA_DB_NAME;
|
||||||
dbConfig.username = getEnvOrFile("UPTIME_KUMA_DB_USERNAME");
|
dbConfig.username = getEnvOrFile("UPTIME_KUMA_DB_USERNAME");
|
||||||
dbConfig.password = getEnvOrFile("UPTIME_KUMA_DB_PASSWORD");
|
dbConfig.password = getEnvOrFile("UPTIME_KUMA_DB_PASSWORD");
|
||||||
|
dbConfig.socketPath = process.env.UPTIME_KUMA_DB_SOCKET?.trim();
|
||||||
dbConfig.ssl = getEnvOrFile("UPTIME_KUMA_DB_SSL")?.toLowerCase() === "true";
|
dbConfig.ssl = getEnvOrFile("UPTIME_KUMA_DB_SSL")?.toLowerCase() === "true";
|
||||||
dbConfig.ca = getEnvOrFile("UPTIME_KUMA_DB_CA");
|
dbConfig.ca = getEnvOrFile("UPTIME_KUMA_DB_CA");
|
||||||
Database.writeDBConfig(dbConfig);
|
Database.writeDBConfig(dbConfig);
|
||||||
@ -160,6 +161,7 @@ class SetupDatabase {
|
|||||||
runningSetup: this.runningSetup,
|
runningSetup: this.runningSetup,
|
||||||
needSetup: this.needSetup,
|
needSetup: this.needSetup,
|
||||||
isEnabledEmbeddedMariaDB: this.isEnabledEmbeddedMariaDB(),
|
isEnabledEmbeddedMariaDB: this.isEnabledEmbeddedMariaDB(),
|
||||||
|
isEnabledMariaDBSocket: process.env.UPTIME_KUMA_DB_SOCKET?.trim().length > 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -202,6 +204,11 @@ class SetupDatabase {
|
|||||||
|
|
||||||
// External MariaDB
|
// External MariaDB
|
||||||
if (dbConfig.type === "mariadb") {
|
if (dbConfig.type === "mariadb") {
|
||||||
|
// If socketPath is provided and not empty, validate it
|
||||||
|
if (process.env.UPTIME_KUMA_DB_SOCKET?.trim().length > 0) {
|
||||||
|
dbConfig.socketPath = process.env.UPTIME_KUMA_DB_SOCKET.trim();
|
||||||
|
} else {
|
||||||
|
// socketPath not provided, hostname and port are required
|
||||||
if (!dbConfig.hostname) {
|
if (!dbConfig.hostname) {
|
||||||
response.status(400).json("Hostname is required");
|
response.status(400).json("Hostname is required");
|
||||||
this.runningSetup = false;
|
this.runningSetup = false;
|
||||||
@ -213,6 +220,7 @@ class SetupDatabase {
|
|||||||
this.runningSetup = false;
|
this.runningSetup = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!dbConfig.dbName) {
|
if (!dbConfig.dbName) {
|
||||||
response.status(400).json("Database name is required");
|
response.status(400).json("Database name is required");
|
||||||
@ -241,6 +249,7 @@ class SetupDatabase {
|
|||||||
user: dbConfig.username,
|
user: dbConfig.username,
|
||||||
password: dbConfig.password,
|
password: dbConfig.password,
|
||||||
database: dbConfig.dbName,
|
database: dbConfig.dbName,
|
||||||
|
socketPath: dbConfig.socketPath,
|
||||||
...(dbConfig.ssl
|
...(dbConfig.ssl
|
||||||
? {
|
? {
|
||||||
ssl: {
|
ssl: {
|
||||||
|
|||||||
@ -8,6 +8,21 @@ const apicache = require("../modules/apicache");
|
|||||||
const StatusPage = require("../model/status_page");
|
const StatusPage = require("../model/status_page");
|
||||||
const { UptimeKumaServer } = require("../uptime-kuma-server");
|
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
|
* Socket handlers for status page
|
||||||
* @param {Socket} socket Socket.io instance to add listeners on
|
* @param {Socket} socket Socket.io instance to add listeners on
|
||||||
@ -25,8 +40,6 @@ module.exports.statusPageSocketHandler = (socket) => {
|
|||||||
throw new Error("slug is not found");
|
throw new Error("slug is not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
await R.exec("UPDATE incident SET pin = 0 WHERE status_page_id = ? ", [statusPageID]);
|
|
||||||
|
|
||||||
let incidentBean;
|
let incidentBean;
|
||||||
|
|
||||||
if (incident.id) {
|
if (incident.id) {
|
||||||
@ -44,12 +57,13 @@ module.exports.statusPageSocketHandler = (socket) => {
|
|||||||
incidentBean.content = incident.content;
|
incidentBean.content = incident.content;
|
||||||
incidentBean.style = incident.style;
|
incidentBean.style = incident.style;
|
||||||
incidentBean.pin = true;
|
incidentBean.pin = true;
|
||||||
|
incidentBean.active = true;
|
||||||
incidentBean.status_page_id = statusPageID;
|
incidentBean.status_page_id = statusPageID;
|
||||||
|
|
||||||
if (incident.id) {
|
if (incident.id) {
|
||||||
incidentBean.lastUpdatedDate = R.isoDateTime(dayjs.utc());
|
incidentBean.last_updated_date = R.isoDateTime(dayjs.utc());
|
||||||
} else {
|
} else {
|
||||||
incidentBean.createdDate = R.isoDateTime(dayjs.utc());
|
incidentBean.created_date = R.isoDateTime(dayjs.utc());
|
||||||
}
|
}
|
||||||
|
|
||||||
await R.store(incidentBean);
|
await R.store(incidentBean);
|
||||||
@ -85,6 +99,171 @@ 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) => {
|
socket.on("getStatusPage", async (slug, callback) => {
|
||||||
try {
|
try {
|
||||||
checkLogin(socket);
|
checkLogin(socket);
|
||||||
|
|||||||
119
src/components/IncidentEditForm.vue
Normal file
119
src/components/IncidentEditForm.vue
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
<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>
|
||||||
154
src/components/IncidentHistory.vue
Normal file
154
src/components/IncidentHistory.vue
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
<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>
|
||||||
204
src/components/IncidentManageModal.vue
Normal file
204
src/components/IncidentManageModal.vue
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
<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>
|
||||||
@ -35,7 +35,13 @@
|
|||||||
:aria-label="selectAll ? $t('deselectAllMonitorsAria') : $t('selectAllMonitorsAria')"
|
:aria-label="selectAll ? $t('deselectAllMonitorsAria') : $t('selectAllMonitorsAria')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MonitorListFilter :filterState="filterState" @update-filter="updateFilter" />
|
<MonitorListFilter
|
||||||
|
:filterState="filterState"
|
||||||
|
:allCollapsed="allGroupsCollapsed"
|
||||||
|
:hasGroups="groupMonitors.length >= 2"
|
||||||
|
@update-filter="updateFilter"
|
||||||
|
@toggle-collapse-all="toggleCollapseAll"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -100,8 +106,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MonitorListItem
|
<MonitorListItem
|
||||||
v-for="(item, index) in sortedMonitorList"
|
v-for="item in sortedMonitorList"
|
||||||
:key="index"
|
:key="`${item.id}-${collapseKey}`"
|
||||||
:monitor="item"
|
:monitor="item"
|
||||||
:isSelectMode="selectMode"
|
:isSelectMode="selectMode"
|
||||||
:isSelected="isSelected"
|
:isSelected="isSelected"
|
||||||
@ -154,6 +160,7 @@ export default {
|
|||||||
active: null,
|
active: null,
|
||||||
tags: null,
|
tags: null,
|
||||||
},
|
},
|
||||||
|
collapseKey: 0,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -229,6 +236,38 @@ export default {
|
|||||||
this.searchText !== ""
|
this.searchText !== ""
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all group monitors at root level that have children
|
||||||
|
* @returns {Array} Array of group monitors with children
|
||||||
|
*/
|
||||||
|
groupMonitors() {
|
||||||
|
const monitors = Object.values(this.$root.monitorList);
|
||||||
|
return monitors.filter(
|
||||||
|
(m) => m.type === "group" && m.parent === null && monitors.some((child) => child.parent === m.id)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if all groups are collapsed.
|
||||||
|
* Note: collapseKey is included to force re-computation when toggleCollapseAll()
|
||||||
|
* updates localStorage, since Vue cannot detect localStorage changes.
|
||||||
|
* @returns {boolean} True if all groups are collapsed
|
||||||
|
*/
|
||||||
|
allGroupsCollapsed() {
|
||||||
|
// collapseKey forces this computed to re-evaluate after localStorage updates
|
||||||
|
if (this.collapseKey < 0 || this.groupMonitors.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = window.localStorage.getItem("monitorCollapsed");
|
||||||
|
if (storage === null) {
|
||||||
|
return true; // Default is collapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageObject = JSON.parse(storage);
|
||||||
|
return this.groupMonitors.every((group) => storageObject[`monitor_${group.id}`] !== false);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
searchText() {
|
searchText() {
|
||||||
@ -303,6 +342,26 @@ export default {
|
|||||||
updateFilter(newFilter) {
|
updateFilter(newFilter) {
|
||||||
this.filterState = newFilter;
|
this.filterState = newFilter;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Toggle collapse state for all group monitors
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
toggleCollapseAll() {
|
||||||
|
const shouldCollapse = !this.allGroupsCollapsed;
|
||||||
|
|
||||||
|
let storageObject = {};
|
||||||
|
const storage = window.localStorage.getItem("monitorCollapsed");
|
||||||
|
if (storage !== null) {
|
||||||
|
storageObject = JSON.parse(storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.groupMonitors.forEach((group) => {
|
||||||
|
storageObject[`monitor_${group.id}`] = shouldCollapse;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.localStorage.setItem("monitorCollapsed", JSON.stringify(storageObject));
|
||||||
|
this.collapseKey++;
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Deselect a monitor
|
* Deselect a monitor
|
||||||
* @param {number} id ID of monitor
|
* @param {number} id ID of monitor
|
||||||
@ -731,6 +790,7 @@ export default {
|
|||||||
.search-input {
|
.search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-right: 30px;
|
padding-right: 30px;
|
||||||
|
transition: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.monitor-item {
|
.monitor-item {
|
||||||
|
|||||||
@ -137,6 +137,15 @@
|
|||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</MonitorListFilterDropdown>
|
</MonitorListFilterDropdown>
|
||||||
|
<button
|
||||||
|
v-if="hasGroups"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-normal btn-collapse-all"
|
||||||
|
:title="allCollapsed ? $t('Expand All Groups') : $t('Collapse All Groups')"
|
||||||
|
@click="$emit('toggle-collapse-all')"
|
||||||
|
>
|
||||||
|
<font-awesome-icon :icon="allCollapsed ? 'folder' : 'folder-open'" fixed-width />
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -155,8 +164,16 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
allCollapsed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
},
|
},
|
||||||
emits: ["updateFilter"],
|
hasGroups: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ["updateFilter", "toggle-collapse-all"],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
tagsList: [],
|
tagsList: [],
|
||||||
@ -322,4 +339,8 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-collapse-all {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -56,6 +56,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="mb-3">
|
||||||
<label for="username" class="form-label">{{ $t("Username") }}</label>
|
<label for="username" class="form-label">{{ $t("Username") }}</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@ -23,4 +23,16 @@
|
|||||||
</a>
|
</a>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="wecom-mentioned-mobile-list" class="form-label">{{ $t("WeCom Mentioned Mobile List") }}</label>
|
||||||
|
<input
|
||||||
|
id="wecom-mentioned-mobile-list"
|
||||||
|
v-model="$parent.notification.weComMentionedMobileList"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="13800001111,13900002222,@all"
|
||||||
|
/>
|
||||||
|
<p class="form-text">{{ $t("WeCom Mentioned Mobile List Description") }}</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -53,6 +53,8 @@ import {
|
|||||||
faInfoCircle,
|
faInfoCircle,
|
||||||
faClone,
|
faClone,
|
||||||
faCertificate,
|
faCertificate,
|
||||||
|
faFolder,
|
||||||
|
faFolderOpen,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
library.add(
|
library.add(
|
||||||
@ -103,6 +105,8 @@ library.add(
|
|||||||
faInfoCircle,
|
faInfoCircle,
|
||||||
faClone,
|
faClone,
|
||||||
faCertificate,
|
faCertificate,
|
||||||
|
faFolder,
|
||||||
|
faFolderOpen,
|
||||||
);
|
);
|
||||||
|
|
||||||
export { FontAwesomeIcon };
|
export { FontAwesomeIcon };
|
||||||
|
|||||||
@ -46,6 +46,9 @@
|
|||||||
"Status": "Status",
|
"Status": "Status",
|
||||||
"DateTime": "DateTime",
|
"DateTime": "DateTime",
|
||||||
"Message": "Message",
|
"Message": "Message",
|
||||||
|
"No incidents recorded": "No incidents recorded",
|
||||||
|
"Load More": "Load More",
|
||||||
|
"Loading...": "Loading...",
|
||||||
"No important events": "No important events",
|
"No important events": "No important events",
|
||||||
"Resume": "Resume",
|
"Resume": "Resume",
|
||||||
"Edit": "Edit",
|
"Edit": "Edit",
|
||||||
@ -62,6 +65,7 @@
|
|||||||
"minuteShort": "{n} min | {n} min",
|
"minuteShort": "{n} min | {n} min",
|
||||||
"years": "{n} year | {n} years",
|
"years": "{n} year | {n} years",
|
||||||
"Response": "Response",
|
"Response": "Response",
|
||||||
|
"Pin this incident": "Pin this incident",
|
||||||
"Ping": "Ping",
|
"Ping": "Ping",
|
||||||
"Monitor Type": "Monitor Type",
|
"Monitor Type": "Monitor Type",
|
||||||
"Keyword": "Keyword",
|
"Keyword": "Keyword",
|
||||||
@ -167,6 +171,10 @@
|
|||||||
"Last Result": "Last Result",
|
"Last Result": "Last Result",
|
||||||
"Create your admin account": "Create your admin account",
|
"Create your admin account": "Create your admin account",
|
||||||
"Repeat Password": "Repeat Password",
|
"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",
|
"Import Backup": "Import Backup",
|
||||||
"Export Backup": "Export Backup",
|
"Export Backup": "Export Backup",
|
||||||
"Export": "Export",
|
"Export": "Export",
|
||||||
@ -223,6 +231,7 @@
|
|||||||
"Blue": "Blue",
|
"Blue": "Blue",
|
||||||
"Indigo": "Indigo",
|
"Indigo": "Indigo",
|
||||||
"Purple": "Purple",
|
"Purple": "Purple",
|
||||||
|
"Pinned incidents are shown prominently on the status page": "Pinned incidents are shown prominently on the status page",
|
||||||
"Pink": "Pink",
|
"Pink": "Pink",
|
||||||
"Custom": "Custom",
|
"Custom": "Custom",
|
||||||
"Search...": "Search…",
|
"Search...": "Search…",
|
||||||
@ -238,6 +247,7 @@
|
|||||||
"Degraded Service": "Degraded Service",
|
"Degraded Service": "Degraded Service",
|
||||||
"Add Group": "Add Group",
|
"Add Group": "Add Group",
|
||||||
"Add a monitor": "Add a monitor",
|
"Add a monitor": "Add a monitor",
|
||||||
|
"Edit Incident": "Edit Incident",
|
||||||
"Edit Status Page": "Edit Status Page",
|
"Edit Status Page": "Edit Status Page",
|
||||||
"Go to Dashboard": "Go to Dashboard",
|
"Go to Dashboard": "Go to Dashboard",
|
||||||
"Status Page": "Status Page",
|
"Status Page": "Status Page",
|
||||||
@ -296,6 +306,8 @@
|
|||||||
"successKeyword": "Success Keyword",
|
"successKeyword": "Success Keyword",
|
||||||
"successKeywordExplanation": "MQTT Keyword that will be considered as success",
|
"successKeywordExplanation": "MQTT Keyword that will be considered as success",
|
||||||
"recent": "Recent",
|
"recent": "Recent",
|
||||||
|
"Resolve": "Resolve",
|
||||||
|
"Resolved": "Resolved",
|
||||||
"Reset Token": "Reset Token",
|
"Reset Token": "Reset Token",
|
||||||
"Done": "Done",
|
"Done": "Done",
|
||||||
"Info": "Info",
|
"Info": "Info",
|
||||||
@ -349,6 +361,7 @@
|
|||||||
"Customize": "Customize",
|
"Customize": "Customize",
|
||||||
"Custom Footer": "Custom Footer",
|
"Custom Footer": "Custom Footer",
|
||||||
"Custom CSS": "Custom CSS",
|
"Custom CSS": "Custom CSS",
|
||||||
|
"deleteIncidentMsg": "Are you sure you want to delete this incident?",
|
||||||
"deleteStatusPageMsg": "Are you sure want to delete this status page?",
|
"deleteStatusPageMsg": "Are you sure want to delete this status page?",
|
||||||
"Proxies": "Proxies",
|
"Proxies": "Proxies",
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
@ -371,6 +384,7 @@
|
|||||||
"Stop": "Stop",
|
"Stop": "Stop",
|
||||||
"Add New Status Page": "Add New Status Page",
|
"Add New Status Page": "Add New Status Page",
|
||||||
"Slug": "Slug",
|
"Slug": "Slug",
|
||||||
|
"slug is not found": "Slug is not found",
|
||||||
"Accept characters:": "Accept characters:",
|
"Accept characters:": "Accept characters:",
|
||||||
"startOrEndWithOnly": "Start or end with {0} only",
|
"startOrEndWithOnly": "Start or end with {0} only",
|
||||||
"No consecutive dashes": "No consecutive dashes",
|
"No consecutive dashes": "No consecutive dashes",
|
||||||
@ -394,6 +408,8 @@
|
|||||||
"Trust Proxy": "Trust Proxy",
|
"Trust Proxy": "Trust Proxy",
|
||||||
"Other Software": "Other Software",
|
"Other Software": "Other Software",
|
||||||
"For example: nginx, Apache and Traefik.": "For example: nginx, Apache and Traefik.",
|
"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",
|
"Please read": "Please read",
|
||||||
"Subject:": "Subject:",
|
"Subject:": "Subject:",
|
||||||
"Valid To:": "Valid To:",
|
"Valid To:": "Valid To:",
|
||||||
@ -585,6 +601,8 @@
|
|||||||
"secureOptionNone": "None / STARTTLS (25, 587)",
|
"secureOptionNone": "None / STARTTLS (25, 587)",
|
||||||
"secureOptionTLS": "TLS (465)",
|
"secureOptionTLS": "TLS (465)",
|
||||||
"Ignore TLS Error": "Ignore TLS Error",
|
"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",
|
"From Email": "From Email",
|
||||||
"emailCustomisableContent": "Customisable content",
|
"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:",
|
"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:",
|
||||||
@ -793,6 +811,8 @@
|
|||||||
"Retry": "Retry",
|
"Retry": "Retry",
|
||||||
"Topic": "Topic",
|
"Topic": "Topic",
|
||||||
"WeCom Bot Key": "WeCom Bot Key",
|
"WeCom Bot Key": "WeCom Bot Key",
|
||||||
|
"WeCom Mentioned Mobile List": "WeCom Mentioned Mobile List",
|
||||||
|
"WeCom Mentioned Mobile List Description": "Enter phone numbers to mention. Separate multiple numbers with commas. Use {'@'}all to mention everyone.",
|
||||||
"Setup Proxy": "Set Up Proxy",
|
"Setup Proxy": "Set Up Proxy",
|
||||||
"Proxy Protocol": "Proxy Protocol",
|
"Proxy Protocol": "Proxy Protocol",
|
||||||
"Proxy Server": "Proxy Server",
|
"Proxy Server": "Proxy Server",
|
||||||
@ -1109,6 +1129,7 @@
|
|||||||
"snmpCommunityStringHelptext": "This string functions as a password to authenticate and control access to SNMP-enabled devices. Match it with your SNMP device's configuration.",
|
"snmpCommunityStringHelptext": "This string functions as a password to authenticate and control access to SNMP-enabled devices. Match it with your SNMP device's configuration.",
|
||||||
"OID (Object Identifier)": "OID (Object Identifier)",
|
"OID (Object Identifier)": "OID (Object Identifier)",
|
||||||
"snmpOIDHelptext": "Enter the OID for the sensor or status you want to monitor. Use network management tools like MIB browsers or SNMP software if you're unsure about the OID.",
|
"snmpOIDHelptext": "Enter the OID for the sensor or status you want to monitor. Use network management tools like MIB browsers or SNMP software if you're unsure about the OID.",
|
||||||
|
"snmpV3Username": "SNMPv3 Username",
|
||||||
"Condition": "Condition",
|
"Condition": "Condition",
|
||||||
"SNMP Version": "SNMP Version",
|
"SNMP Version": "SNMP Version",
|
||||||
"Please enter a valid OID.": "Please enter a valid OID.",
|
"Please enter a valid OID.": "Please enter a valid OID.",
|
||||||
@ -1365,5 +1386,8 @@
|
|||||||
"Expected TLS Alert": "Expected TLS Alert",
|
"Expected TLS Alert": "Expected TLS Alert",
|
||||||
"None (Successful Connection)": "None (Successful Connection)",
|
"None (Successful Connection)": "None (Successful Connection)",
|
||||||
"expectedTlsAlertDescription": "Select the TLS alert you expect the server to return. Use {code} to verify mTLS endpoints reject connections without client certificates. See {link} for details.",
|
"expectedTlsAlertDescription": "Select the TLS alert you expect the server to return. Use {code} to verify mTLS endpoints reject connections without client certificates. See {link} for details.",
|
||||||
"TLS Alert Spec": "RFC 8446"
|
"TLS Alert Spec": "RFC 8446",
|
||||||
|
"mariadbSocketPathDetectedHelptext": "Connecting to the database as specified via the {0} environment variable.",
|
||||||
|
"Expand All Groups": "Expand All Groups",
|
||||||
|
"Collapse All Groups": "Collapse All Groups"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div v-if="!$root.isMobile" class="col-12 col-md-5 col-xl-4">
|
<div v-if="!$root.isMobile" class="col-12 col-md-5 col-xl-4 ps-0">
|
||||||
<div>
|
<div>
|
||||||
<router-link to="/add" class="btn btn-primary mb-3">
|
<router-link to="/add" class="btn btn-primary mb-3">
|
||||||
<font-awesome-icon icon="plus" />
|
<font-awesome-icon icon="plus" />
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
{{ $t("Quick Stats") }}
|
{{ $t("Quick Stats") }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="shadow-box big-padding text-center mb-4">
|
<div class="shadow-box big-padding text-center mb-3">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3>{{ $t("Up") }}</h3>
|
<h3>{{ $t("Up") }}</h3>
|
||||||
|
|||||||
@ -511,8 +511,23 @@
|
|||||||
<select id="snmp_version" v-model="monitor.snmpVersion" class="form-select">
|
<select id="snmp_version" v-model="monitor.snmpVersion" class="form-select">
|
||||||
<option value="1">SNMPv1</option>
|
<option value="1">SNMPv1</option>
|
||||||
<option value="2c">SNMPv2c</option>
|
<option value="2c">SNMPv2c</option>
|
||||||
|
<option value="3">SNMPv3</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="monitor.type === 'snmp' && monitor.snmpVersion === '3'" class="my-3">
|
||||||
|
<label for="snmp_v3_username" class="form-label">
|
||||||
|
{{ $t("snmpV3Username") }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="snmp_v3_username"
|
||||||
|
v-model="monitor.snmpV3Username"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="SNMPv3 username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="monitor.type === 'smtp'" class="my-3">
|
<div v-if="monitor.type === 'smtp'" class="my-3">
|
||||||
<label for="smtp_security" class="form-label">{{ $t("SMTP Security") }}</label>
|
<label for="smtp_security" class="form-label">{{ $t("SMTP Security") }}</label>
|
||||||
|
|||||||
@ -79,7 +79,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="dbConfig.type === 'mariadb'">
|
<template v-if="dbConfig.type === 'mariadb'">
|
||||||
<div class="form-floating mt-3 short">
|
<div v-if="!isProvidedMariaDBSocket" class="form-floating mt-3 short">
|
||||||
<input
|
<input
|
||||||
id="floatingInput"
|
id="floatingInput"
|
||||||
v-model="dbConfig.hostname"
|
v-model="dbConfig.hostname"
|
||||||
@ -90,11 +90,19 @@
|
|||||||
<label for="floatingInput">{{ $t("Hostname") }}</label>
|
<label for="floatingInput">{{ $t("Hostname") }}</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-floating mt-3 short">
|
<div v-if="!isProvidedMariaDBSocket" class="form-floating mt-3 short">
|
||||||
<input id="floatingInput" v-model="dbConfig.port" type="text" class="form-control" required />
|
<input id="floatingInput" v-model="dbConfig.port" type="text" class="form-control" required />
|
||||||
<label for="floatingInput">{{ $t("Port") }}</label>
|
<label for="floatingInput">{{ $t("Port") }}</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isProvidedMariaDBSocket" class="mt-1 short text-start">
|
||||||
|
<i18n-t keypath="mariadbSocketPathDetectedHelptext" tag="div" class="form-text">
|
||||||
|
<code>UPTIME_KUMA_DB_SOCKET</code>
|
||||||
|
</i18n-t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr v-if="isProvidedMariaDBSocket" class="mt-3 mb-2 short" />
|
||||||
|
|
||||||
<div class="form-floating mt-3 short">
|
<div class="form-floating mt-3 short">
|
||||||
<input
|
<input
|
||||||
id="floatingInput"
|
id="floatingInput"
|
||||||
@ -198,6 +206,9 @@ export default {
|
|||||||
disabledButton() {
|
disabledButton() {
|
||||||
return this.dbConfig.type === undefined || this.info.runningSetup;
|
return this.dbConfig.type === undefined || this.info.runningSetup;
|
||||||
},
|
},
|
||||||
|
isProvidedMariaDBSocket() {
|
||||||
|
return this.info.isEnabledMariaDBSocket;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
let res = await axios.get("/setup-database-info");
|
let res = await axios.get("/setup-database-info");
|
||||||
|
|||||||
@ -297,42 +297,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Incident -->
|
<!-- Incident Edit Form -->
|
||||||
<div
|
<IncidentEditForm
|
||||||
v-if="incident !== null"
|
v-if="
|
||||||
class="shadow-box alert mb-4 p-4 incident"
|
editIncidentMode &&
|
||||||
role="alert"
|
incident !== null &&
|
||||||
:class="incidentClass"
|
(!incident.id || !activeIncidents.some((i) => i.id === incident.id))
|
||||||
data-testid="incident"
|
"
|
||||||
>
|
v-model="incident"
|
||||||
<strong v-if="editIncidentMode">{{ $t("Title") }}:</strong>
|
@post="postIncident"
|
||||||
<Editable
|
@cancel="cancelIncident"
|
||||||
v-model="incident.title"
|
|
||||||
tag="h4"
|
|
||||||
:contenteditable="editIncidentMode"
|
|
||||||
:noNL="true"
|
|
||||||
class="alert-heading"
|
|
||||||
data-testid="incident-title"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<strong v-if="editIncidentMode">{{ $t("Content") }}:</strong>
|
<!-- Active Pinned Incidents -->
|
||||||
<Editable
|
<template v-for="activeIncident in activeIncidents" :key="activeIncident.id">
|
||||||
v-if="editIncidentMode"
|
<!-- Edit mode for this specific incident -->
|
||||||
v-model="incident.content"
|
<IncidentEditForm
|
||||||
tag="div"
|
v-if="editIncidentMode && incident !== null && incident.id === activeIncident.id"
|
||||||
:contenteditable="editIncidentMode"
|
v-model="incident"
|
||||||
class="content"
|
@post="postIncident"
|
||||||
data-testid="incident-content-editable"
|
@cancel="cancelIncident"
|
||||||
/>
|
/>
|
||||||
<div v-if="editIncidentMode" class="form-text">
|
|
||||||
{{ $t("markdownSupported") }}
|
<!-- Display mode for this incident -->
|
||||||
</div>
|
<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 -->
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
<div
|
<div
|
||||||
v-if="!editIncidentMode"
|
|
||||||
class="content"
|
class="content"
|
||||||
data-testid="incident-content"
|
data-testid="incident-content"
|
||||||
v-html="incidentHTML"
|
v-html="getIncidentHTML(activeIncident.content)"
|
||||||
></div>
|
></div>
|
||||||
<!-- eslint-enable vue/no-v-html -->
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
|
|
||||||
@ -340,88 +340,40 @@
|
|||||||
<div class="date mt-3">
|
<div class="date mt-3">
|
||||||
{{
|
{{
|
||||||
$t("dateCreatedAtFromNow", {
|
$t("dateCreatedAtFromNow", {
|
||||||
date: $root.datetime(incident.createdDate),
|
date: $root.datetime(activeIncident.createdDate),
|
||||||
fromNow: dateFromNow(incident.createdDate),
|
fromNow: dateFromNow(activeIncident.createdDate),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
<br />
|
<br />
|
||||||
<span v-if="incident.lastUpdatedDate">
|
<span v-if="activeIncident.lastUpdatedDate">
|
||||||
{{
|
{{
|
||||||
$t("lastUpdatedAtFromNow", {
|
$t("lastUpdatedAtFromNow", {
|
||||||
date: $root.datetime(incident.lastUpdatedDate),
|
date: $root.datetime(activeIncident.lastUpdatedDate),
|
||||||
fromNow: dateFromNow(incident.lastUpdatedDate),
|
fromNow: dateFromNow(activeIncident.lastUpdatedDate),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="editMode" class="mt-3">
|
<div v-if="editMode" class="mt-3">
|
||||||
<button
|
<button class="btn btn-light me-2" @click="resolveIncident(activeIncident)">
|
||||||
v-if="editIncidentMode"
|
<font-awesome-icon icon="check" />
|
||||||
class="btn btn-light me-2"
|
{{ $t("Resolve") }}
|
||||||
data-testid="post-incident-button"
|
|
||||||
@click="postIncident"
|
|
||||||
>
|
|
||||||
<font-awesome-icon icon="bullhorn" />
|
|
||||||
{{ $t("Post") }}
|
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-light me-2" @click="editIncident(activeIncident)">
|
||||||
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="editIncident">
|
|
||||||
<font-awesome-icon icon="edit" />
|
<font-awesome-icon icon="edit" />
|
||||||
{{ $t("Edit") }}
|
{{ $t("Edit") }}
|
||||||
</button>
|
</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
|
<button
|
||||||
id="dropdownMenuButton1"
|
class="btn btn-light me-2"
|
||||||
class="btn btn-secondary dropdown-toggle"
|
@click="$refs.incidentManageModal.showDelete(activeIncident)"
|
||||||
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>
|
|
||||||
|
|
||||||
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="unpinIncident">
|
|
||||||
<font-awesome-icon icon="unlink" />
|
<font-awesome-icon icon="unlink" />
|
||||||
{{ $t("Delete") }}
|
{{ $t("Delete") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Overall Status -->
|
<!-- Overall Status -->
|
||||||
<div class="shadow-box list p-4 overall-status mb-4">
|
<div class="shadow-box list p-4 overall-status mb-4">
|
||||||
@ -544,6 +496,56 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<footer class="mt-5 mb-4">
|
||||||
<div class="custom-footer-text text-start">
|
<div class="custom-footer-text text-start">
|
||||||
<strong v-if="enableEditMode">{{ $t("Custom Footer") }}:</strong>
|
<strong v-if="enableEditMode">{{ $t("Custom Footer") }}:</strong>
|
||||||
@ -617,6 +619,9 @@ import DOMPurify from "dompurify";
|
|||||||
import Confirm from "../components/Confirm.vue";
|
import Confirm from "../components/Confirm.vue";
|
||||||
import PublicGroupList from "../components/PublicGroupList.vue";
|
import PublicGroupList from "../components/PublicGroupList.vue";
|
||||||
import MaintenanceTime from "../components/MaintenanceTime.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 { getResBaseURL } from "../util-frontend";
|
||||||
import {
|
import {
|
||||||
STATUS_PAGE_ALL_DOWN,
|
STATUS_PAGE_ALL_DOWN,
|
||||||
@ -650,6 +655,9 @@ export default {
|
|||||||
MaintenanceTime,
|
MaintenanceTime,
|
||||||
Tag,
|
Tag,
|
||||||
VueMultiselect,
|
VueMultiselect,
|
||||||
|
IncidentHistory,
|
||||||
|
IncidentManageModal,
|
||||||
|
IncidentEditForm,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Leave Page for vue route change
|
// Leave Page for vue route change
|
||||||
@ -695,6 +703,10 @@ export default {
|
|||||||
updateCountdown: null,
|
updateCountdown: null,
|
||||||
updateCountdownText: null,
|
updateCountdownText: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
|
incidentHistory: [],
|
||||||
|
incidentHistoryLoading: false,
|
||||||
|
incidentHistoryNextCursor: null,
|
||||||
|
incidentHistoryHasMore: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -820,7 +832,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
incidentHTML() {
|
incidentHTML() {
|
||||||
if (this.incident.content != null) {
|
if (this.incident && this.incident.content != null) {
|
||||||
return DOMPurify.sanitize(marked(this.incident.content));
|
return DOMPurify.sanitize(marked(this.incident.content));
|
||||||
} else {
|
} else {
|
||||||
return "";
|
return "";
|
||||||
@ -846,6 +858,40 @@ export default {
|
|||||||
lastUpdateTimeDisplay() {
|
lastUpdateTimeDisplay() {
|
||||||
return this.$root.datetime(this.lastUpdateTime);
|
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: {
|
watch: {
|
||||||
/**
|
/**
|
||||||
@ -948,6 +994,18 @@ export default {
|
|||||||
this.imgDataUrl = this.config.icon;
|
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.incident = res.data.incident;
|
||||||
this.maintenanceList = res.data.maintenanceList;
|
this.maintenanceList = res.data.maintenanceList;
|
||||||
this.$root.publicGroupList = res.data.publicGroupList;
|
this.$root.publicGroupList = res.data.publicGroupList;
|
||||||
@ -972,6 +1030,7 @@ export default {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.updateHeartbeatList();
|
this.updateHeartbeatList();
|
||||||
|
this.loadIncidentHistory();
|
||||||
|
|
||||||
// Go to edit page if ?edit present
|
// Go to edit page if ?edit present
|
||||||
// null means ?edit present, but no value
|
// null means ?edit present, but no value
|
||||||
@ -1244,7 +1303,8 @@ export default {
|
|||||||
this.$root.getSocket().emit("postIncident", this.slug, this.incident, (res) => {
|
this.$root.getSocket().emit("postIncident", this.slug, this.incident, (res) => {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
this.enableEditIncidentMode = false;
|
this.enableEditIncidentMode = false;
|
||||||
this.incident = res.incident;
|
this.incident = null;
|
||||||
|
this.loadIncidentHistory();
|
||||||
} else {
|
} else {
|
||||||
this.$root.toastError(res.msg);
|
this.$root.toastError(res.msg);
|
||||||
}
|
}
|
||||||
@ -1252,12 +1312,14 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Click Edit Button
|
* Edit an incident inline
|
||||||
|
* @param {object} incident - The incident to edit
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
editIncident() {
|
editIncident(incident) {
|
||||||
|
this.previousIncident = this.incident;
|
||||||
|
this.incident = { ...incident };
|
||||||
this.enableEditIncidentMode = true;
|
this.enableEditIncidentMode = true;
|
||||||
this.previousIncident = Object.assign({}, this.incident);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1283,6 +1345,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
|
* Get the relative time difference of a date from now
|
||||||
* @param {any} date Date to get time difference
|
* @param {any} date Date to get time difference
|
||||||
@ -1313,6 +1387,105 @@ export default {
|
|||||||
return "";
|
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>
|
</script>
|
||||||
@ -1431,12 +1604,14 @@ footer {
|
|||||||
|
|
||||||
/* Reset button placed at top-left of the logo */
|
/* Reset button placed at top-left of the logo */
|
||||||
.reset-top-left {
|
.reset-top-left {
|
||||||
position: absolute;
|
transition:
|
||||||
top: 0;
|
transform $easing-in 0.18s,
|
||||||
left: -15px;
|
box-shadow $easing-in 0.18s,
|
||||||
z-index: 2;
|
background-color $easing-in 0.18s;
|
||||||
width: 20px;
|
font-size: 18px;
|
||||||
height: 20px;
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -1445,11 +1620,6 @@ footer {
|
|||||||
border: none;
|
border: none;
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||||
cursor: pointer;
|
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;
|
transform-origin: center;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@ -1586,4 +1756,28 @@ footer {
|
|||||||
.refresh-info {
|
.refresh-info {
|
||||||
opacity: 0.7;
|
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>
|
</style>
|
||||||
|
|||||||
@ -10,8 +10,8 @@
|
|||||||
*/
|
*/
|
||||||
var _a;
|
var _a;
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
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.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 = 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;
|
||||||
const dayjs_1 = require("dayjs");
|
const dayjs_1 = require("dayjs");
|
||||||
const jsonata = require("jsonata");
|
const jsonata = require("jsonata");
|
||||||
exports.isDev = process.env.NODE_ENV === "development";
|
exports.isDev = process.env.NODE_ENV === "development";
|
||||||
@ -31,6 +31,7 @@ exports.SQL_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
|
|||||||
exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = "YYYY-MM-DD HH:mm";
|
exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = "YYYY-MM-DD HH:mm";
|
||||||
exports.MAX_INTERVAL_SECOND = 2073600;
|
exports.MAX_INTERVAL_SECOND = 2073600;
|
||||||
exports.MIN_INTERVAL_SECOND = 1;
|
exports.MIN_INTERVAL_SECOND = 1;
|
||||||
|
exports.INCIDENT_PAGE_SIZE = 10;
|
||||||
exports.PING_PACKET_SIZE_MIN = 1;
|
exports.PING_PACKET_SIZE_MIN = 1;
|
||||||
exports.PING_PACKET_SIZE_MAX = 65500;
|
exports.PING_PACKET_SIZE_MAX = 65500;
|
||||||
exports.PING_PACKET_SIZE_DEFAULT = 56;
|
exports.PING_PACKET_SIZE_DEFAULT = 56;
|
||||||
|
|||||||
@ -46,6 +46,8 @@ export const SQL_DATETIME_FORMAT_WITHOUT_SECOND = "YYYY-MM-DD HH:mm";
|
|||||||
export const MAX_INTERVAL_SECOND = 2073600; // 24 days
|
export const MAX_INTERVAL_SECOND = 2073600; // 24 days
|
||||||
export const MIN_INTERVAL_SECOND = 1; // 1 second
|
export const MIN_INTERVAL_SECOND = 1; // 1 second
|
||||||
|
|
||||||
|
export const INCIDENT_PAGE_SIZE = 10;
|
||||||
|
|
||||||
// Packet Size limits
|
// Packet Size limits
|
||||||
export const PING_PACKET_SIZE_MIN = 1;
|
export const PING_PACKET_SIZE_MIN = 1;
|
||||||
export const PING_PACKET_SIZE_MAX = 65500;
|
export const PING_PACKET_SIZE_MAX = 65500;
|
||||||
|
|||||||
128
test/backend-test/test-snmp.js
Normal file
128
test/backend-test/test-snmp.js
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
const { describe, test } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { GenericContainer } = require("testcontainers");
|
||||||
|
const { SNMPMonitorType } = require("../../server/monitor-types/snmp");
|
||||||
|
const { UP } = require("../../src/util");
|
||||||
|
const snmp = require("net-snmp");
|
||||||
|
|
||||||
|
describe("SNMPMonitorType", () => {
|
||||||
|
test(
|
||||||
|
"check() sets heartbeat to UP when SNMP agent responds",
|
||||||
|
{
|
||||||
|
skip: !!process.env.CI && (process.platform !== "linux" || process.arch !== "x64"),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const container = await new GenericContainer("polinux/snmpd").withExposedPorts("161/udp").start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the mapped UDP port
|
||||||
|
const hostPort = container.getMappedPort("161/udp");
|
||||||
|
const hostIp = container.getHost();
|
||||||
|
|
||||||
|
// UDP service small wait to ensure snmpd is ready inside container
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
|
||||||
|
const monitor = {
|
||||||
|
type: "snmp",
|
||||||
|
hostname: hostIp,
|
||||||
|
port: hostPort,
|
||||||
|
snmpVersion: "2c",
|
||||||
|
radiusPassword: "public",
|
||||||
|
snmpOid: "1.3.6.1.2.1.1.1.0",
|
||||||
|
timeout: 5,
|
||||||
|
maxretries: 1,
|
||||||
|
jsonPath: "$",
|
||||||
|
jsonPathOperator: "!=",
|
||||||
|
expectedValue: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const snmpMonitor = new SNMPMonitorType();
|
||||||
|
const heartbeat = {};
|
||||||
|
|
||||||
|
await snmpMonitor.check(monitor, heartbeat);
|
||||||
|
|
||||||
|
assert.strictEqual(heartbeat.status, UP);
|
||||||
|
assert.match(heartbeat.msg, /JSON query passes/);
|
||||||
|
} finally {
|
||||||
|
await container.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"check() throws when SNMP agent does not respond",
|
||||||
|
{
|
||||||
|
skip: !!process.env.CI && (process.platform !== "linux" || process.arch !== "x64"),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const monitor = {
|
||||||
|
type: "snmp",
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 65530, // Assuming no SNMP agent is running here
|
||||||
|
snmpVersion: "2c",
|
||||||
|
radiusPassword: "public",
|
||||||
|
snmpOid: "1.3.6.1.2.1.1.1.0",
|
||||||
|
timeout: 1,
|
||||||
|
maxretries: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const snmpMonitor = new SNMPMonitorType();
|
||||||
|
const heartbeat = {};
|
||||||
|
|
||||||
|
await assert.rejects(() => snmpMonitor.check(monitor, heartbeat), /timeout|RequestTimedOutError/i);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test("check() uses SNMPv3 noAuthNoPriv session when version is 3", async () => {
|
||||||
|
const originalCreateV3Session = snmp.createV3Session;
|
||||||
|
const originalCreateSession = snmp.createSession;
|
||||||
|
|
||||||
|
let createV3Called = false;
|
||||||
|
let createSessionCalled = false;
|
||||||
|
let receivedOptions = null;
|
||||||
|
|
||||||
|
// Stub createV3Session
|
||||||
|
snmp.createV3Session = function (_host, _username, options) {
|
||||||
|
createV3Called = true;
|
||||||
|
receivedOptions = options;
|
||||||
|
|
||||||
|
return {
|
||||||
|
on: () => {},
|
||||||
|
close: () => {},
|
||||||
|
// Stop execution after session creation to avoid real network I/O.
|
||||||
|
get: (_oids, cb) => cb(new Error("stop test here")),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stub createSession
|
||||||
|
snmp.createSession = function () {
|
||||||
|
createSessionCalled = true;
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const monitor = {
|
||||||
|
type: "snmp",
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 161,
|
||||||
|
timeout: 5,
|
||||||
|
maxretries: 1,
|
||||||
|
snmpVersion: "3",
|
||||||
|
snmp_v3_username: "testuser",
|
||||||
|
snmpOid: "1.3.6.1.2.1.1.1.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const snmpMonitor = new SNMPMonitorType();
|
||||||
|
const heartbeat = {};
|
||||||
|
|
||||||
|
await assert.rejects(() => snmpMonitor.check(monitor, heartbeat), /stop test here/);
|
||||||
|
|
||||||
|
// Assertions
|
||||||
|
assert.strictEqual(createV3Called, true);
|
||||||
|
assert.strictEqual(createSessionCalled, false);
|
||||||
|
assert.strictEqual(receivedOptions.securityLevel, snmp.SecurityLevel.noAuthNoPriv);
|
||||||
|
|
||||||
|
// Restore originals
|
||||||
|
snmp.createV3Session = originalCreateV3Session;
|
||||||
|
snmp.createSession = originalCreateSession;
|
||||||
|
});
|
||||||
|
});
|
||||||
150
test/e2e/specs/incident-history.spec.js
Normal file
150
test/e2e/specs/incident-history.spec.js
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
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