Compare commits

...

6 Commits

Author SHA1 Message Date
ryana
9169a647cb
feat: implement incident history (#6469)
Co-authored-by: Frank Elsinga <frank@elsinga.de>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-01-20 07:03:12 +01:00
Dharun Ashokkumar
b638ae48ef
fix: add option to disable STARTTLS for SMTP servers without TLS support (#6770)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2026-01-19 23:32:06 +00:00
Dharun Ashokkumar
f8d494a03d
fix: json parse crashes in monitor model with safe parsing (#6767)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-01-19 21:24:39 +01:00
Copilot
f8652c27af
fix(domain_expiry): include static RDAP DNS data with auto-updates instead of dynamically requesting it (#6769)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: CommanderStorm <26258709+CommanderStorm@users.noreply.github.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2026-01-19 17:16:24 +00:00
Epifeny
8bf929a958
feat(ntfy): include tag values in ntfy tags array (#6766)
Co-authored-by: epifeny <epifeny@users.noreply.github.com>
2026-01-19 13:23:09 +01:00
Epifeny
52737a1299
feat(ntfy): Add monitor metadata to notification messages for automation (#6762)
Co-authored-by: epifeny <epifeny@users.noreply.github.com>
2026-01-19 08:22:03 +01:00
21 changed files with 2568 additions and 459 deletions

View File

@ -30,6 +30,10 @@ jobs:
- name: Install dependencies
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
run: npm run lint-fix:js
continue-on-error: true

292
package-lock.json generated
View File

@ -60,7 +60,6 @@
"nanoid": "~3.3.4",
"net-snmp": "^3.11.2",
"node-cloudflared-tunnel": "~1.0.9",
"node-fetch-cache": "^5.1.0",
"node-radius-utils": "~1.2.0",
"nodemailer": "~7.0.12",
"nostr-tools": "^2.17.0",
@ -3655,6 +3654,7 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "20 || >=22"
@ -3664,6 +3664,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
@ -4027,18 +4028,6 @@
"node": ">= 8"
}
},
"node_modules/@npmcli/fs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz",
"integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==",
"license": "ISC",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/@npmcli/move-file": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz",
@ -8024,85 +8013,6 @@
"node": ">= 0.8"
}
},
"node_modules/cacache": {
"version": "20.0.3",
"resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz",
"integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==",
"license": "ISC",
"dependencies": {
"@npmcli/fs": "^5.0.0",
"fs-minipass": "^3.0.0",
"glob": "^13.0.0",
"lru-cache": "^11.1.0",
"minipass": "^7.0.3",
"minipass-collect": "^2.0.1",
"minipass-flush": "^1.0.5",
"minipass-pipeline": "^1.2.4",
"p-map": "^7.0.2",
"ssri": "^13.0.0",
"unique-filename": "^5.0.0"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/cacache/node_modules/glob": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz",
"integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"path-scurry": "^2.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/cacache/node_modules/lru-cache": {
"version": "11.2.4",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/cacache/node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/cacache/node_modules/path-scurry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/cacheable-lookup": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
@ -9092,15 +9002,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@ -10774,29 +10675,6 @@
"node": ">=0.4.0"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@ -11007,27 +10885,6 @@
"node": ">= 14.17"
}
},
"node_modules/formdata-node": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-6.0.3.tgz",
"integrity": "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==",
"license": "MIT",
"engines": {
"node": ">= 18"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -11129,18 +10986,6 @@
"node": ">= 10.0.0"
}
},
"node_modules/fs-minipass": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz",
"integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==",
"license": "ISC",
"dependencies": {
"minipass": "^7.0.3"
},
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -12020,6 +11865,7 @@
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.8.19"
@ -13220,12 +13066,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/locko": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/locko/-/locko-1.1.0.tgz",
"integrity": "sha512-pYB2dzRY93fJkg2RIl41AMNgTQftEjyTK9vlPrGOJvuGQsOjb267VJBw15BjiN3RBd1oBoKkOu9E2dRdFKIfAA==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@ -13873,18 +13713,6 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minipass-collect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz",
"integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==",
"license": "ISC",
"dependencies": {
"minipass": "^7.0.3"
},
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minipass-fetch": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz",
@ -13921,6 +13749,7 @@
"resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz",
"integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==",
"license": "ISC",
"optional": true,
"dependencies": {
"minipass": "^3.0.0"
},
@ -13933,6 +13762,7 @@
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
@ -13945,6 +13775,7 @@
"resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz",
"integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==",
"license": "ISC",
"optional": true,
"dependencies": {
"minipass": "^3.0.0"
},
@ -13957,6 +13788,7 @@
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
@ -14329,26 +14161,6 @@
"command-exists": "^1.2.9"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@ -14369,39 +14181,6 @@
}
}
},
"node_modules/node-fetch-cache": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/node-fetch-cache/-/node-fetch-cache-5.1.0.tgz",
"integrity": "sha512-4j3rRHNGIKGX7VzXSrBT0bh7+wFuyJv1DxCfCLDHsnDahJWoD9lXe3BzL3BJg/GEIJiM7KIvqVs3byW1GFtRsQ==",
"license": "MIT",
"dependencies": {
"cacache": "^20.0.1",
"formdata-node": "^6.0.3",
"locko": "^1.1.0",
"node-fetch": "3.3.2"
},
"engines": {
"node": ">=18.19.0"
}
},
"node_modules/node-fetch-cache/node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@ -14878,18 +14657,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-map": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
"integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@ -17447,18 +17214,6 @@
"nan": "^2.23.0"
}
},
"node_modules/ssri": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz",
"integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==",
"license": "ISC",
"dependencies": {
"minipass": "^7.0.3"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@ -18824,30 +18579,6 @@
"node": ">=4"
}
},
"node_modules/unique-filename": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz",
"integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==",
"license": "ISC",
"dependencies": {
"unique-slug": "^6.0.0"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/unique-slug": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz",
"integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==",
"license": "ISC",
"dependencies": {
"imurmurhash": "^0.1.4"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/unique-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
@ -19499,15 +19230,6 @@
"node": ">= 16"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",

View File

@ -122,7 +122,6 @@
"nanoid": "~3.3.4",
"net-snmp": "^3.11.2",
"node-cloudflared-tunnel": "~1.0.9",
"node-fetch-cache": "^5.1.0",
"node-radius-utils": "~1.2.0",
"nodemailer": "~7.0.12",
"nostr-tools": "^2.17.0",

View File

@ -4,33 +4,19 @@ const { log, TYPES_WITH_DOMAIN_EXPIRY_SUPPORT_VIA_FIELD } = require("../../src/u
const { parse: parseTld } = require("tldts");
const { setting, setSetting } = require("../util-server");
const { Notification } = require("../notification");
const { default: NodeFetchCache, MemoryCache } = require("node-fetch-cache");
const TranslatableError = require("../translatable-error");
const dayjs = require("dayjs");
const cachedFetch = process.env.NODE_ENV
? NodeFetchCache.create({
// cache for 8h
cache: new MemoryCache({ ttl: 1000 * 60 * 60 * 8 }),
})
: fetch;
// Load static RDAP DNS data from local file (auto-updated by CI)
const rdapDnsData = require("./rdap-dns.json");
/**
* Find the RDAP server for a given TLD
* @param {string} tld TLD
* @returns {Promise<string>} First RDAP server found
* @returns {string|null} First RDAP server found
*/
async function getRdapServer(tld) {
let rdapList;
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"] ?? [];
function getRdapServer(tld) {
const services = rdapDnsData["services"] ?? [];
const rootTld = tld?.split(".").pop();
if (rootTld) {
for (const [tlds, urls] of services) {
@ -50,7 +36,7 @@ async function getRdapServer(tld) {
*/
async function getRdapDomainExpiryDate(domain) {
const tld = DomainExpiry.parseTld(domain).publicSuffix;
const rdapServer = await getRdapServer(tld);
const rdapServer = getRdapServer(tld);
if (rdapServer === null) {
log.warn("rdap", `No RDAP server found, TLD ${tld} not supported.`);
return null;
@ -178,7 +164,7 @@ class DomainExpiry extends BeanModel {
const publicSuffix = tld.publicSuffix;
const rootTld = publicSuffix.split(".").pop();
const rdap = await getRdapServer(publicSuffix);
const rdap = getRdapServer(publicSuffix);
if (!rdap) {
throw new TranslatableError("domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint", {
publicSuffix,

View File

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

View File

@ -1746,6 +1746,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") {
// ping parameters validation
if (this.packetSize && (this.packetSize < PING_PACKET_SIZE_MIN || this.packetSize > PING_PACKET_SIZE_MAX)) {

1194
server/model/rdap-dns.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -7,8 +7,8 @@ const analytics = require("../analytics/analytics");
const { marked } = require("marked");
const { Feed } = require("feed");
const config = require("../config");
const { setting } = require("../util-server");
const { setting } = require("../util-server");
const {
STATUS_PAGE_ALL_DOWN,
STATUS_PAGE_ALL_UP,
@ -17,6 +17,7 @@ const {
UP,
MAINTENANCE,
DOWN,
INCIDENT_PAGE_SIZE,
} = require("../../src/util");
class StatusPage extends BeanModel {
@ -307,12 +308,13 @@ class StatusPage extends BeanModel {
static async getStatusPageData(statusPage) {
const config = await statusPage.toPublicJSON();
// Incident
let incident = await R.findOne("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [statusPage.id]);
if (incident) {
incident = incident.toPublicJSON();
}
// All active incidents
let incidents = await R.find(
"incident",
" pin = 1 AND active = 1 AND status_page_id = ? ORDER BY created_date DESC",
[statusPage.id]
);
incidents = incidents.map((i) => i.toPublicJSON());
let maintenanceList = await StatusPage.getMaintenanceList(statusPage.id);
@ -330,7 +332,7 @@ class StatusPage extends BeanModel {
// Response
return {
config,
incident,
incidents,
publicGroupList,
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
* @param {number} statusPageId ID of status page to get maintenance for

View File

@ -57,6 +57,19 @@ class Ntfy extends NotificationProvider {
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 = {
topic: notification.ntfytopic,
message: heartbeatJSON.msg,

View File

@ -1,5 +1,6 @@
const nodemailer = require("nodemailer");
const NotificationProvider = require("./notification-provider");
const { log } = require("../../src/util");
class SMTP extends NotificationProvider {
name = "smtp";
@ -14,11 +15,25 @@ class SMTP extends NotificationProvider {
host: notification.smtpHost,
port: notification.smtpPort,
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
if (notification.smtpDkimDomain) {
config.dkim = {

View File

@ -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
router.get("/api/status-page/:slug/badge", cache("5 minutes"), async (request, response) => {
allowDevAllOrigin(response);

View File

@ -8,6 +8,21 @@ const apicache = require("../modules/apicache");
const StatusPage = require("../model/status_page");
const { UptimeKumaServer } = require("../uptime-kuma-server");
/**
* Validates incident data
* @param {object} incident - The incident object
* @returns {void}
* @throws {Error} If validation fails
*/
function validateIncident(incident) {
if (!incident.title || incident.title.trim() === "") {
throw new Error("Please input title");
}
if (!incident.content || incident.content.trim() === "") {
throw new Error("Please input content");
}
}
/**
* Socket handlers for status page
* @param {Socket} socket Socket.io instance to add listeners on
@ -25,8 +40,6 @@ module.exports.statusPageSocketHandler = (socket) => {
throw new Error("slug is not found");
}
await R.exec("UPDATE incident SET pin = 0 WHERE status_page_id = ? ", [statusPageID]);
let incidentBean;
if (incident.id) {
@ -44,12 +57,13 @@ module.exports.statusPageSocketHandler = (socket) => {
incidentBean.content = incident.content;
incidentBean.style = incident.style;
incidentBean.pin = true;
incidentBean.active = true;
incidentBean.status_page_id = statusPageID;
if (incident.id) {
incidentBean.lastUpdatedDate = R.isoDateTime(dayjs.utc());
incidentBean.last_updated_date = R.isoDateTime(dayjs.utc());
} else {
incidentBean.createdDate = R.isoDateTime(dayjs.utc());
incidentBean.created_date = R.isoDateTime(dayjs.utc());
}
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) => {
try {
checkLogin(socket);

View 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>

View 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>

View 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>

View File

@ -56,6 +56,24 @@
</div>
</div>
<div v-if="!$parent.notification.smtpSecure" class="mb-3">
<div class="form-check">
<input
id="ignore-starttls"
v-model="$parent.notification.smtpIgnoreSTARTTLS"
class="form-check-input"
type="checkbox"
value=""
/>
<label class="form-check-label" for="ignore-starttls">
{{ $t("Disable STARTTLS") }}
</label>
</div>
<div class="form-text">
{{ $t("disableSTARTTLSDescription") }}
</div>
</div>
<div class="mb-3">
<label for="username" class="form-label">{{ $t("Username") }}</label>
<input

View File

@ -46,6 +46,9 @@
"Status": "Status",
"DateTime": "DateTime",
"Message": "Message",
"No incidents recorded": "No incidents recorded",
"Load More": "Load More",
"Loading...": "Loading...",
"No important events": "No important events",
"Resume": "Resume",
"Edit": "Edit",
@ -62,6 +65,7 @@
"minuteShort": "{n} min | {n} min",
"years": "{n} year | {n} years",
"Response": "Response",
"Pin this incident": "Pin this incident",
"Ping": "Ping",
"Monitor Type": "Monitor Type",
"Keyword": "Keyword",
@ -167,6 +171,10 @@
"Last Result": "Last Result",
"Create your admin account": "Create your admin account",
"Repeat Password": "Repeat Password",
"Incident description": "Incident description",
"Incident not found or access denied": "Incident not found or access denied",
"Past Incidents": "Past Incidents",
"Incident title": "Incident title",
"Import Backup": "Import Backup",
"Export Backup": "Export Backup",
"Export": "Export",
@ -223,6 +231,7 @@
"Blue": "Blue",
"Indigo": "Indigo",
"Purple": "Purple",
"Pinned incidents are shown prominently on the status page": "Pinned incidents are shown prominently on the status page",
"Pink": "Pink",
"Custom": "Custom",
"Search...": "Search…",
@ -238,6 +247,7 @@
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Incident": "Edit Incident",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
"Status Page": "Status Page",
@ -296,6 +306,8 @@
"successKeyword": "Success Keyword",
"successKeywordExplanation": "MQTT Keyword that will be considered as success",
"recent": "Recent",
"Resolve": "Resolve",
"Resolved": "Resolved",
"Reset Token": "Reset Token",
"Done": "Done",
"Info": "Info",
@ -349,6 +361,7 @@
"Customize": "Customize",
"Custom Footer": "Custom Footer",
"Custom CSS": "Custom CSS",
"deleteIncidentMsg": "Are you sure you want to delete this incident?",
"deleteStatusPageMsg": "Are you sure want to delete this status page?",
"Proxies": "Proxies",
"default": "Default",
@ -371,6 +384,7 @@
"Stop": "Stop",
"Add New Status Page": "Add New Status Page",
"Slug": "Slug",
"slug is not found": "Slug is not found",
"Accept characters:": "Accept characters:",
"startOrEndWithOnly": "Start or end with {0} only",
"No consecutive dashes": "No consecutive dashes",
@ -394,6 +408,8 @@
"Trust Proxy": "Trust Proxy",
"Other Software": "Other Software",
"For example: nginx, Apache and Traefik.": "For example: nginx, Apache and Traefik.",
"Please input content": "Please input content",
"Please input title": "Please input title",
"Please read": "Please read",
"Subject:": "Subject:",
"Valid To:": "Valid To:",
@ -585,6 +601,8 @@
"secureOptionNone": "None / STARTTLS (25, 587)",
"secureOptionTLS": "TLS (465)",
"Ignore TLS Error": "Ignore TLS Error",
"Disable STARTTLS": "Disable STARTTLS",
"disableSTARTTLSDescription": "Enable this option for SMTP servers that do not support STARTTLS. This will send emails over an unencrypted connection.",
"From Email": "From Email",
"emailCustomisableContent": "Customisable content",
"smtpLiquidIntroduction": "The following two fields are templatable via the Liquid templating Language. Please refer to the {0} for usage instructions. These are the available variables:",

View File

@ -297,131 +297,83 @@
</div>
</div>
<!-- Incident -->
<div
v-if="incident !== null"
class="shadow-box alert mb-4 p-4 incident"
role="alert"
:class="incidentClass"
data-testid="incident"
>
<strong v-if="editIncidentMode">{{ $t("Title") }}:</strong>
<Editable
v-model="incident.title"
tag="h4"
:contenteditable="editIncidentMode"
:noNL="true"
class="alert-heading"
data-testid="incident-title"
<!-- Incident Edit Form -->
<IncidentEditForm
v-if="
editIncidentMode &&
incident !== null &&
(!incident.id || !activeIncidents.some((i) => i.id === incident.id))
"
v-model="incident"
@post="postIncident"
@cancel="cancelIncident"
/>
<!-- Active Pinned Incidents -->
<template v-for="activeIncident in activeIncidents" :key="activeIncident.id">
<!-- Edit mode for this specific incident -->
<IncidentEditForm
v-if="editIncidentMode && incident !== null && incident.id === activeIncident.id"
v-model="incident"
@post="postIncident"
@cancel="cancelIncident"
/>
<strong v-if="editIncidentMode">{{ $t("Content") }}:</strong>
<Editable
v-if="editIncidentMode"
v-model="incident.content"
tag="div"
:contenteditable="editIncidentMode"
class="content"
data-testid="incident-content-editable"
/>
<div v-if="editIncidentMode" class="form-text">
{{ $t("markdownSupported") }}
</div>
<!-- eslint-disable vue/no-v-html-->
<!-- Display mode for this incident -->
<div
v-if="!editIncidentMode"
class="content"
data-testid="incident-content"
v-html="incidentHTML"
></div>
<!-- eslint-enable vue/no-v-html-->
v-else
class="shadow-box alert mb-4 p-4 incident"
role="alert"
:class="'bg-' + activeIncident.style"
data-testid="incident"
>
<h4 class="alert-heading" data-testid="incident-title">{{ activeIncident.title }}</h4>
<!-- eslint-disable vue/no-v-html -->
<div
class="content"
data-testid="incident-content"
v-html="getIncidentHTML(activeIncident.content)"
></div>
<!-- eslint-enable vue/no-v-html -->
<!-- Incident Date -->
<div class="date mt-3">
{{
$t("dateCreatedAtFromNow", {
date: $root.datetime(incident.createdDate),
fromNow: dateFromNow(incident.createdDate),
})
}}
<br />
<span v-if="incident.lastUpdatedDate">
<!-- Incident Date -->
<div class="date mt-3">
{{
$t("lastUpdatedAtFromNow", {
date: $root.datetime(incident.lastUpdatedDate),
fromNow: dateFromNow(incident.lastUpdatedDate),
$t("dateCreatedAtFromNow", {
date: $root.datetime(activeIncident.createdDate),
fromNow: dateFromNow(activeIncident.createdDate),
})
}}
</span>
</div>
<div v-if="editMode" class="mt-3">
<button
v-if="editIncidentMode"
class="btn btn-light me-2"
data-testid="post-incident-button"
@click="postIncident"
>
<font-awesome-icon icon="bullhorn" />
{{ $t("Post") }}
</button>
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="editIncident">
<font-awesome-icon icon="edit" />
{{ $t("Edit") }}
</button>
<button v-if="editIncidentMode" class="btn btn-light me-2" @click="cancelIncident">
<font-awesome-icon icon="times" />
{{ $t("Cancel") }}
</button>
<div v-if="editIncidentMode" class="dropdown d-inline-block me-2">
<button
id="dropdownMenuButton1"
class="btn btn-secondary dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
{{ $t("Style") }}: {{ $t(incident.style) }}
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
<li>
<a class="dropdown-item" href="#" @click="incident.style = 'info'">{{ $t("info") }}</a>
</li>
<li>
<a class="dropdown-item" href="#" @click="incident.style = 'warning'">
{{ $t("warning") }}
</a>
</li>
<li>
<a class="dropdown-item" href="#" @click="incident.style = 'danger'">
{{ $t("danger") }}
</a>
</li>
<li>
<a class="dropdown-item" href="#" @click="incident.style = 'primary'">
{{ $t("primary") }}
</a>
</li>
<li>
<a class="dropdown-item" href="#" @click="incident.style = 'light'">
{{ $t("light") }}
</a>
</li>
<li>
<a class="dropdown-item" href="#" @click="incident.style = 'dark'">{{ $t("dark") }}</a>
</li>
</ul>
<br />
<span v-if="activeIncident.lastUpdatedDate">
{{
$t("lastUpdatedAtFromNow", {
date: $root.datetime(activeIncident.lastUpdatedDate),
fromNow: dateFromNow(activeIncident.lastUpdatedDate),
})
}}
</span>
</div>
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="unpinIncident">
<font-awesome-icon icon="unlink" />
{{ $t("Delete") }}
</button>
<div v-if="editMode" class="mt-3">
<button class="btn btn-light me-2" @click="resolveIncident(activeIncident)">
<font-awesome-icon icon="check" />
{{ $t("Resolve") }}
</button>
<button class="btn btn-light me-2" @click="editIncident(activeIncident)">
<font-awesome-icon icon="edit" />
{{ $t("Edit") }}
</button>
<button
class="btn btn-light me-2"
@click="$refs.incidentManageModal.showDelete(activeIncident)"
>
<font-awesome-icon icon="unlink" />
{{ $t("Delete") }}
</button>
</div>
</div>
</div>
</template>
<!-- Overall Status -->
<div class="shadow-box list p-4 overall-status mb-4">
@ -544,6 +496,56 @@
/>
</div>
<!-- Past Incidents -->
<div v-if="pastIncidentCount > 0" class="past-incidents-section mb-4">
<h2 class="past-incidents-title mb-3">
{{ $t("Past Incidents") }}
</h2>
<div class="past-incidents-content">
<div
v-for="(dateGroup, dateKey) in groupedIncidentHistory"
:key="dateKey"
class="incident-date-group mb-4"
>
<h4 class="incident-date-header">{{ dateKey }}</h4>
<div class="shadow-box incident-list-box">
<IncidentHistory
:incidents="dateGroup"
:edit-mode="enableEditMode"
:loading="incidentHistoryLoading"
@edit-incident="$refs.incidentManageModal.showEdit($event)"
@delete-incident="$refs.incidentManageModal.showDelete($event)"
@resolve-incident="resolveIncident"
/>
</div>
</div>
<div v-if="incidentHistoryHasMore" class="load-more-controls d-flex justify-content-center mt-3">
<button
class="btn btn-outline-secondary btn-sm"
:disabled="incidentHistoryLoading"
@click="loadMoreIncidentHistory"
>
<span
v-if="incidentHistoryLoading"
class="spinner-border spinner-border-sm me-1"
role="status"
></span>
{{ $t("Load More") }}
</button>
</div>
</div>
</div>
<!-- Incident Manage Modal -->
<IncidentManageModal
v-if="enableEditMode"
ref="incidentManageModal"
:slug="slug"
@incident-updated="loadIncidentHistory"
/>
<footer class="mt-5 mb-4">
<div class="custom-footer-text text-start">
<strong v-if="enableEditMode">{{ $t("Custom Footer") }}:</strong>
@ -617,6 +619,9 @@ import DOMPurify from "dompurify";
import Confirm from "../components/Confirm.vue";
import PublicGroupList from "../components/PublicGroupList.vue";
import MaintenanceTime from "../components/MaintenanceTime.vue";
import IncidentHistory from "../components/IncidentHistory.vue";
import IncidentManageModal from "../components/IncidentManageModal.vue";
import IncidentEditForm from "../components/IncidentEditForm.vue";
import { getResBaseURL } from "../util-frontend";
import {
STATUS_PAGE_ALL_DOWN,
@ -650,6 +655,9 @@ export default {
MaintenanceTime,
Tag,
VueMultiselect,
IncidentHistory,
IncidentManageModal,
IncidentEditForm,
},
// Leave Page for vue route change
@ -695,6 +703,10 @@ export default {
updateCountdown: null,
updateCountdownText: null,
loading: true,
incidentHistory: [],
incidentHistoryLoading: false,
incidentHistoryNextCursor: null,
incidentHistoryHasMore: false,
};
},
computed: {
@ -820,7 +832,7 @@ export default {
},
incidentHTML() {
if (this.incident.content != null) {
if (this.incident && this.incident.content != null) {
return DOMPurify.sanitize(marked(this.incident.content));
} else {
return "";
@ -846,6 +858,40 @@ export default {
lastUpdateTimeDisplay() {
return this.$root.datetime(this.lastUpdateTime);
},
/**
* Get all active pinned incidents for display at the top
* @returns {object[]} List of active pinned incidents
*/
activeIncidents() {
return this.incidentHistory.filter((i) => i.active && i.pin);
},
/**
* Count of past incidents (non-active or unpinned)
* @returns {number} Number of past incidents
*/
pastIncidentCount() {
return this.incidentHistory.filter((i) => !(i.active && i.pin)).length;
},
/**
* Group past incidents (non-active or unpinned) by date for display
* Active+pinned incidents are shown separately at the top, not in this section
* @returns {object} Incidents grouped by date string
*/
groupedIncidentHistory() {
const groups = {};
const pastIncidents = this.incidentHistory.filter((i) => !(i.active && i.pin));
for (const incident of pastIncidents) {
const dateKey = this.formatDateKey(incident.createdDate);
if (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey].push(incident);
}
return groups;
},
},
watch: {
/**
@ -948,6 +994,18 @@ export default {
this.imgDataUrl = this.config.icon;
}
this.maintenanceList = res.data.maintenanceList;
this.$root.publicGroupList = res.data.publicGroupList;
this.loading = false;
feedInterval = setInterval(
() => {
this.updateHeartbeatList();
},
Math.max(5, this.config.autoRefreshInterval) * 1000
);
this.incident = res.data.incident;
this.maintenanceList = res.data.maintenanceList;
this.$root.publicGroupList = res.data.publicGroupList;
@ -972,6 +1030,7 @@ export default {
});
this.updateHeartbeatList();
this.loadIncidentHistory();
// Go to edit page if ?edit present
// null means ?edit present, but no value
@ -1244,7 +1303,8 @@ export default {
this.$root.getSocket().emit("postIncident", this.slug, this.incident, (res) => {
if (res.ok) {
this.enableEditIncidentMode = false;
this.incident = res.incident;
this.incident = null;
this.loadIncidentHistory();
} else {
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}
*/
editIncident() {
editIncident(incident) {
this.previousIncident = this.incident;
this.incident = { ...incident };
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
* @param {any} date Date to get time difference
@ -1313,6 +1387,105 @@ export default {
return "";
}
},
/**
* Load incident history for the status page
* @returns {void}
*/
loadIncidentHistory() {
this.loadIncidentHistoryWithCursor(null);
},
/**
* Load incident history using cursor-based pagination
* @param {string|null} cursor - Cursor for pagination (created_date of last item)
* @param {boolean} append - Whether to append to existing list
* @returns {void}
*/
loadIncidentHistoryWithCursor(cursor, append = false) {
this.incidentHistoryLoading = true;
if (this.enableEditMode) {
this.$root.getSocket().emit("getIncidentHistory", this.slug, cursor, (res) => {
this.incidentHistoryLoading = false;
if (res.ok) {
if (append) {
this.incidentHistory = [...this.incidentHistory, ...res.incidents];
} else {
this.incidentHistory = res.incidents;
}
this.incidentHistoryNextCursor = res.nextCursor;
this.incidentHistoryHasMore = res.hasMore;
} else {
console.error("Failed to load incident history:", res.msg);
this.$root.toastError(res.msg);
}
});
} else {
const url = cursor
? `/api/status-page/${this.slug}/incident-history?cursor=${encodeURIComponent(cursor)}`
: `/api/status-page/${this.slug}/incident-history`;
axios
.get(url)
.then((res) => {
this.incidentHistoryLoading = false;
if (res.data.ok) {
if (append) {
this.incidentHistory = [...this.incidentHistory, ...res.data.incidents];
} else {
this.incidentHistory = res.data.incidents;
}
this.incidentHistoryNextCursor = res.data.nextCursor;
this.incidentHistoryHasMore = res.data.hasMore;
}
})
.catch((error) => {
this.incidentHistoryLoading = false;
console.error("Failed to load incident history:", error);
});
}
},
/**
* Load more incident history using cursor-based pagination
* @returns {void}
*/
loadMoreIncidentHistory() {
if (this.incidentHistoryHasMore && this.incidentHistoryNextCursor) {
this.loadIncidentHistoryWithCursor(this.incidentHistoryNextCursor, true);
}
},
/**
* Format date key for grouping (e.g., "December 8, 2025")
* @param {string} dateStr - ISO date string
* @returns {string} Formatted date key
*/
formatDateKey(dateStr) {
if (!dateStr) {
return this.$t("Unknown");
}
const date = new Date(dateStr);
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
},
/**
* Resolve an incident
* @param {object} incident - The incident to resolve
* @returns {void}
*/
resolveIncident(incident) {
this.$root.getSocket().emit("resolveIncident", this.slug, incident.id, (res) => {
this.$root.toastRes(res);
if (res.ok) {
this.loadIncidentHistory();
}
});
},
},
};
</script>
@ -1431,12 +1604,14 @@ footer {
/* Reset button placed at top-left of the logo */
.reset-top-left {
position: absolute;
top: 0;
left: -15px;
z-index: 2;
width: 20px;
height: 20px;
transition:
transform $easing-in 0.18s,
box-shadow $easing-in 0.18s,
background-color $easing-in 0.18s;
font-size: 18px;
width: 18px;
height: 18px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
@ -1445,11 +1620,6 @@ footer {
border: none;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
cursor: pointer;
padding: 0;
transition:
transform $easing-in 0.18s,
box-shadow $easing-in 0.18s,
background-color $easing-in 0.18s;
transform-origin: center;
&:hover {
@ -1586,4 +1756,28 @@ footer {
.refresh-info {
opacity: 0.7;
}
.past-incidents-title {
font-size: 26px;
font-weight: normal;
}
.past-incidents-section {
.past-incidents-content {
padding: 0;
}
}
.incident-date-group {
.incident-date-header {
font-size: 1rem;
font-weight: normal;
color: var(--bs-secondary);
margin-bottom: 0.75rem;
}
.incident-list-box {
padding: 0;
}
}
</style>

View File

@ -10,8 +10,8 @@
*/
var _a;
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.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.CONSOLE_STYLE_FgLightBlue = exports.CONSOLE_STYLE_FgLightGreen = exports.CONSOLE_STYLE_FgOrange = exports.CONSOLE_STYLE_FgGray = exports.CONSOLE_STYLE_FgWhite = exports.CONSOLE_STYLE_FgCyan = exports.CONSOLE_STYLE_FgMagenta = exports.CONSOLE_STYLE_FgBlue = exports.CONSOLE_STYLE_FgYellow = exports.CONSOLE_STYLE_FgGreen = exports.CONSOLE_STYLE_FgRed = exports.CONSOLE_STYLE_FgBlack = exports.CONSOLE_STYLE_Hidden = exports.CONSOLE_STYLE_Reverse = exports.CONSOLE_STYLE_Blink = exports.CONSOLE_STYLE_Underscore = exports.CONSOLE_STYLE_Dim = exports.CONSOLE_STYLE_Bright = exports.CONSOLE_STYLE_Reset = exports.RESPONSE_BODY_LENGTH_MAX = exports.RESPONSE_BODY_LENGTH_DEFAULT = exports.PING_PER_REQUEST_TIMEOUT_DEFAULT = exports.PING_PER_REQUEST_TIMEOUT_MAX = exports.PING_PER_REQUEST_TIMEOUT_MIN = exports.PING_COUNT_DEFAULT = exports.PING_COUNT_MAX = exports.PING_COUNT_MIN = exports.PING_GLOBAL_TIMEOUT_DEFAULT = exports.PING_GLOBAL_TIMEOUT_MAX = exports.PING_GLOBAL_TIMEOUT_MIN = exports.PING_PACKET_SIZE_DEFAULT = exports.PING_PACKET_SIZE_MAX = exports.PING_PACKET_SIZE_MIN = exports.INCIDENT_PAGE_SIZE = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isNode = exports.isDev = void 0;
exports.TYPES_WITH_DOMAIN_EXPIRY_SUPPORT_VIA_FIELD = exports.evaluateJsonQuery = exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.badgeConstants = exports.CONSOLE_STYLE_BgGray = exports.CONSOLE_STYLE_BgWhite = exports.CONSOLE_STYLE_BgCyan = exports.CONSOLE_STYLE_BgMagenta = exports.CONSOLE_STYLE_BgBlue = exports.CONSOLE_STYLE_BgYellow = exports.CONSOLE_STYLE_BgGreen = exports.CONSOLE_STYLE_BgRed = exports.CONSOLE_STYLE_BgBlack = exports.CONSOLE_STYLE_FgPink = exports.CONSOLE_STYLE_FgBrown = exports.CONSOLE_STYLE_FgViolet = void 0;
const dayjs_1 = require("dayjs");
const jsonata = require("jsonata");
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.MAX_INTERVAL_SECOND = 2073600;
exports.MIN_INTERVAL_SECOND = 1;
exports.INCIDENT_PAGE_SIZE = 10;
exports.PING_PACKET_SIZE_MIN = 1;
exports.PING_PACKET_SIZE_MAX = 65500;
exports.PING_PACKET_SIZE_DEFAULT = 56;

View File

@ -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 MIN_INTERVAL_SECOND = 1; // 1 second
export const INCIDENT_PAGE_SIZE = 10;
// Packet Size limits
export const PING_PACKET_SIZE_MIN = 1;
export const PING_PACKET_SIZE_MAX = 65500;

View 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);
}
});
});