Compare commits

...

37 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
Diego
a86789be6c
feat: Add path to socket for external mariadb database (#6670)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2026-01-18 16:44:43 +01:00
Mohan
999c09d818
feat: Add enhanced Discord webhook alerts with timestamps and downtime (#6745)
Co-authored-by: SID <158349177+0xsid0703@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2026-01-18 14:56:12 +01:00
Dorian Grasset
bf9b734f6c
feat(dashboard): add expand/collapse all groups button (#6743)
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2026-01-18 10:49:45 +00:00
ChenJinBo
a0d73aba1a
feat(notification): add WeCom mentioned mobile list support" (#6758)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2026-01-18 05:06:42 +00:00
dipok
30ee8cec1f
feat(snmp): add SNMPv3 noAuthNoPriv support with backend test (#6552)
Co-authored-by: dipok-1 <dipokdutta8099@gmail.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-01-18 05:59:02 +01:00
bitloi
b926446a5c
feat: add screenshot delay option for Browser Engine monitor (#6753)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2026-01-17 15:05:58 +01:00
Dorian Grasset
18331eaf33
refactor: simplify filters components (#6749)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2026-01-17 12:38:10 +00:00
Joseph Adams
81ae0af7e1
fix: handle multi-level public suffixes in RDAP lookup (#6752) 2026-01-17 12:18:25 +00:00
Frank Elsinga
324404f780
fix: cancel multiple in progress tests (#6757) 2026-01-17 12:03:04 +00:00
yy
93141ae5a6
chore: fix typos in comments (#6755) 2026-01-17 10:36:32 +00:00
Maybe
777c252915
fix(ui): refreshing the page if pressing enter in the search, horizontal scrolling the monitor lit on mobile/tablets and aligning items in the monitor list (#6751) 2026-01-17 08:56:55 +01:00
Ian Macabulos
f470b01168
fix: clairify the helptext that markdown is supported with a warning about HTML indentation (#6747) 2026-01-16 04:47:04 +00:00
Copilot
dd44342835
chore: Add manual workflow for building Docker base images (#6741)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: louislam <1336778+louislam@users.noreply.github.com>
2026-01-15 19:59:33 +08:00
Dalton Pearson
d7296c6629
feat: added monitoring for postgres query result (#6736)
Co-authored-by: Dalton Pearson <dalton.pearson@praemo.com>
2026-01-14 23:48:48 +01:00
iotux
e022b5f976
fix: allow for private domains like example.local and others (#6711)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2026-01-14 17:13:53 +00:00
Frank Elsinga
7306e7038a
chore(ci): fix a missing --repo in the labeling automation (#6735) 2026-01-14 15:49:37 +00:00
Frank Elsinga
31d2417dde
chore: fix permissions for the draft labeling automation (#6732)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-14 13:21:05 +00:00
Frank Elsinga
0cdb63edd1
chore(deps): bump postcss-* dependencys (#6731) 2026-01-14 13:00:55 +00:00
Frank Elsinga
e95bd6a6e0
fix: automate mark as draft (#6730) 2026-01-14 13:51:46 +01:00
kota
a85868ba7c
feat(notification): discord suppress notifications (#6717) 2026-01-14 11:44:36 +00:00
Frank Elsinga
bb0c1b3723
chore: automate marking as draft on requesting changes (#6729) 2026-01-14 10:22:11 +00:00
Frank Elsinga
c2fd12238f
chore(deps): bump vue-i18n for smoother pluralisation (#6727)
Co-authored-by: Dorian Grasset <doriangrasset@proton.me>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-01-14 08:49:15 +00:00
Dorian Grasset
d893231c6d
feat(maintenance): add quick duration buttons and pre-fill datetime fields (#6718)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2026-01-14 08:12:19 +00:00
Aviral Garg
2790e3d9e6
fix(domain-expiry): move logging out of checkSupport to separate logic to reduce to half the amount of domain expiry logs (#6723)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2026-01-14 07:01:55 +00:00
Frank Elsinga
9ae7ea6a19
chore(release): ignore autofix as an author in the changelog (#6725) 2026-01-14 06:55:02 +00:00
Frank Elsinga
41fe0cb4fc
chore(i18n): improve some of the punctuation related issues (#6724)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-01-14 06:39:06 +00:00
github-actions[bot]
1602d74dd5
chore: update to 2.1.0-beta.2 (#6721)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-14 10:41:03 +08:00
Louis Lam
b2d7031522
fix: final fix of beta workflow (#6713) 2026-01-14 10:28:34 +08:00
Copilot
2f82ff3787
fix: beta workflow - remove incorrect push that prevents PR creation (#6709)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: louislam <1336778+louislam@users.noreply.github.com>
2026-01-13 18:43:24 +08:00
Copilot
85863bdb97
fix: beta workflow (#6708)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: louislam <1336778+louislam@users.noreply.github.com>
2026-01-13 18:24:30 +08:00
Louis Lam
4e68b8d049
fix: beta workflow again again again (#6706) 2026-01-13 18:08:11 +08:00
69 changed files with 5021 additions and 1519 deletions

View File

@ -1,5 +1,9 @@
name: Auto Test name: Auto Test
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-server
cancel-in-progress: true
on: on:
push: push:
branches: [master, 1.23.X, 3.0.0] branches: [master, 1.23.X, 3.0.0]

View File

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

View File

@ -41,14 +41,17 @@ jobs:
- name: Create release branch - name: Create release branch
env: env:
VERSION: ${{ inputs.version }} VERSION: ${{ inputs.version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git"
# Delete remote branch if it exists # Delete remote branch if it exists
git push origin --delete "release-${VERSION}" || true git push origin --delete "release-${VERSION}" || true
# Delete local branch if it exists # Delete local branch if it exists
git branch -D "release-${VERSION}" || true git branch -D "release-${VERSION}" || true
# Create new branch from master # For testing purpose
# git checkout beta-workflow
git checkout -b "release-${VERSION}" git checkout -b "release-${VERSION}"
- name: Install dependencies - name: Install dependencies

48
.github/workflows/build-docker-base.yml vendored Normal file
View File

@ -0,0 +1,48 @@
name: Build Docker Base Images
on:
workflow_dispatch: # Allow manual trigger
permissions: {}
jobs:
build-docker-base:
runs-on: ubuntu-latest
timeout-minutes: 120
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: { persist-credentials: false }
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1
- name: Login to Docker Hub
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Use Node.js 20
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: 20
- name: Build and push base2-slim image
run: npm run build-docker-base-slim
- name: Build and push base2 image
run: npm run build-docker-base

View File

@ -0,0 +1,65 @@
name: Mark PR as draft when changes are requested
# pull_request_target is safe here because:
# 1. Does not use any external actions; only uses the GitHub CLI via run commands
# 2. Has minimal permissions
# 3. Doesn't checkout or execute any untrusted code from PRs
# 4. Only adds/removes labels or changes the draft status
on: # zizmor: ignore[dangerous-triggers]
pull_request_target:
types:
- review_submitted
- labeled
- ready_for_review
permissions: {}
jobs:
mark-draft:
runs-on: ubuntu-latest
permissions:
pull-requests: write
if: |
(
github.event.action == 'review_submitted' &&
github.event.review.state == 'changes_requested'
) || (
github.event.action == 'labeled' &&
github.event.label.name == 'pr:please address review comments'
)
steps:
- name: Add label on requested changes
if: github.event.review.state == 'changes_requested'
env:
GH_TOKEN: ${{ github.token }}
run: |
gh issue edit "${{ github.event.pull_request.number }}" \
--repo "${{ github.repository }}" \
--add-label "pr:please address review comments"
- name: Mark PR as draft
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr ready "${{ github.event.pull_request.number }}" \
--repo "${{ github.repository }}" \
--undo || true
# || true to ignore the case where the pr is already a draft
ready-for-review:
runs-on: ubuntu-latest
permissions:
pull-requests: write
if: github.event.action == 'ready_for_review'
steps:
- name: Update labels for review
env:
GH_TOKEN: ${{ github.token }}
run: |
gh issue edit "${{ github.event.pull_request.number }}" \
--repo "${{ github.repository }}" \
--remove-label "pr:please address review comments" || true
gh issue edit "${{ github.event.pull_request.number }}" \
--repo "${{ github.repository }}" \
--add-label "pr:needs review"

View File

@ -36,6 +36,9 @@ export default defineConfig({
srcDir: "src", srcDir: "src",
filename: "serviceWorker.ts", filename: "serviceWorker.ts",
strategies: "injectManifest", strategies: "injectManifest",
injectManifest: {
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, // 3 MiB
},
}), }),
], ],
css: { css: {

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

View File

@ -0,0 +1,11 @@
exports.up = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.integer("screenshot_delay").notNullable().unsigned().defaultTo(0);
});
};
exports.down = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.dropColumn("screenshot_delay");
});
};

View File

@ -1,3 +1,6 @@
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const pkg = require("../../package.json"); const pkg = require("../../package.json");
const fs = require("fs"); const fs = require("fs");
const childProcess = require("child_process"); const childProcess = require("child_process");
@ -58,8 +61,13 @@ function commit(version) {
throw new Error("commit error"); throw new Error("commit error");
} }
res = childProcess.spawnSync("git", ["push", "origin", "master"]); // Get the current branch name
console.log(res.stdout.toString().trim()); res = childProcess.spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
let branchName = res.stdout.toString().trim();
console.log("Current branch:", branchName);
// Git push the branch
childProcess.spawnSync("git", ["push", "origin", branchName, "--force"], { stdio: "inherit" });
} }
/** /**

View File

@ -4,7 +4,7 @@
import * as childProcess from "child_process"; import * as childProcess from "child_process";
const ignoreList = ["louislam", "CommanderStorm", "UptimeKumaBot", "weblate", "Copilot"]; const ignoreList = ["louislam", "CommanderStorm", "UptimeKumaBot", "weblate", "Copilot", "@autofix-ci[bot]"];
const mergeList = ["Translations Update from Weblate", "Update dependencies"]; const mergeList = ["Translations Update from Weblate", "Update dependencies"];

View File

@ -13,7 +13,6 @@ import {
createReleasePR, createReleasePR,
} from "./lib.mjs"; } from "./lib.mjs";
import semver from "semver"; import semver from "semver";
import { spawnSync } from "node:child_process";
const repoNames = getRepoNames(); const repoNames = getRepoNames();
const version = process.env.RELEASE_BETA_VERSION; const version = process.env.RELEASE_BETA_VERSION;
@ -49,12 +48,9 @@ checkDocker();
await checkTagExists(repoNames, version); await checkTagExists(repoNames, version);
// node extra/beta/update-version.js // node extra/beta/update-version.js
execSync("node ./extra/beta/update-version.js"); await import("../beta/update-version.mjs");
// Git Push // Create Pull Request (gh pr create will handle pushing the branch)
spawnSync("git", ["push", "origin", branchName], { stdio: "inherit" });
// Create Pull Request
await createReleasePR(version, previousVersion, dryRun, branchName, githubRunId); await createReleasePR(version, previousVersion, dryRun, branchName, githubRunId);
// Build frontend dist // Build frontend dist

1875
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "uptime-kuma", "name": "uptime-kuma",
"version": "2.1.0-beta.1", "version": "2.1.0-beta.2",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
@ -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",
@ -159,7 +158,7 @@
"@fortawesome/fontawesome-svg-core": "~1.2.36", "@fortawesome/fontawesome-svg-core": "~1.2.36",
"@fortawesome/free-regular-svg-icons": "~5.15.4", "@fortawesome/free-regular-svg-icons": "~5.15.4",
"@fortawesome/free-solid-svg-icons": "~5.15.4", "@fortawesome/free-solid-svg-icons": "~5.15.4",
"@fortawesome/vue-fontawesome": "~3.0.0-5", "@fortawesome/vue-fontawesome": "~3.1.3",
"@playwright/test": "~1.39.0", "@playwright/test": "~1.39.0",
"@popperjs/core": "~2.10.2", "@popperjs/core": "~2.10.2",
"@testcontainers/hivemq": "^10.13.1", "@testcontainers/hivemq": "^10.13.1",
@ -194,8 +193,8 @@
"favico.js": "~0.3.10", "favico.js": "~0.3.10",
"get-port-please": "^3.1.1", "get-port-please": "^3.1.1",
"node-ssh": "~13.1.0", "node-ssh": "~13.1.0",
"postcss-html": "~1.5.0", "postcss-html": "~1.8.1",
"postcss-rtlcss": "~3.7.2", "postcss-rtlcss": "~5.7.1",
"postcss-scss": "~4.0.4", "postcss-scss": "~4.0.4",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prismjs": "~1.30.0", "prismjs": "~1.30.0",
@ -207,17 +206,17 @@
"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",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vite-plugin-pwa": "^1.1.0", "vite-plugin-pwa": "^1.1.0",
"vue": "~3.4.2", "vue": "~3.5.26",
"vue-chartjs": "~5.2.0", "vue-chartjs": "~5.2.0",
"vue-confirm-dialog": "~1.0.2", "vue-confirm-dialog": "~1.0.2",
"vue-contenteditable": "~3.0.4", "vue-contenteditable": "~3.0.4",
"vue-i18n": "~9.14.3", "vue-i18n": "~11.2.8",
"vue-image-crop-upload": "~3.0.3", "vue-image-crop-upload": "~3.0.3",
"vue-multiselect": "~3.0.0-alpha.2", "vue-multiselect": "~3.0.0-alpha.2",
"vue-prism-editor": "~2.0.0-alpha.2", "vue-prism-editor": "~2.0.0-alpha.2",

View File

@ -5,7 +5,7 @@ const { escape } = require("html-escaper");
* Returns a string that represents the javascript that is required to insert the Plausible Analytics script * Returns a string that represents the javascript that is required to insert the Plausible Analytics script
* into a webpage. * into a webpage.
* @param {string} scriptUrl the Plausible Analytics script url. * @param {string} scriptUrl the Plausible Analytics script url.
* @param {string} domainsToMonitor Domains to track seperated by a ',' to add Plausible Analytics script. * @param {string} domainsToMonitor Domains to track separated by a ',' to add Plausible Analytics script.
* @returns {string} HTML script tags to inject into page * @returns {string} HTML script tags to inject into page
*/ */
function getPlausibleAnalyticsScript(scriptUrl, domainsToMonitor) { function getPlausibleAnalyticsScript(scriptUrl, domainsToMonitor) {

View File

@ -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") {

View File

@ -4,36 +4,25 @@ 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 rootTld = tld?.split(".").pop();
const res = await cachedFetch("https://data.iana.org/rdap/dns.json"); if (rootTld) {
rdapList = await res.json(); for (const [tlds, urls] of services) {
} catch (error) { if (tlds.includes(rootTld)) {
log.debug("rdap", error); return urls[0];
return null; }
}
for (const service of rdapList["services"]) {
const [tlds, urls] = service;
if (tlds.includes(tld)) {
return urls[0];
} }
} }
log.debug("rdap", `No RDAP server found for TLD ${tld}`); log.debug("rdap", `No RDAP server found for TLD ${tld}`);
@ -47,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;
@ -159,39 +148,32 @@ class DomainExpiry extends BeanModel {
const tld = parseTld(target); const tld = parseTld(target);
// Avoid logging for incomplete/invalid input while editing monitors. // Avoid logging for incomplete/invalid input while editing monitors.
if (!tld.domain) {
throw new TranslatableError("domain_expiry_unsupported_invalid_domain", { hostname: tld.hostname });
}
if (!tld.publicSuffix) {
throw new TranslatableError("domain_expiry_unsupported_public_suffix", { publicSuffix: tld.publicSuffix });
}
if (tld.isIp) { if (tld.isIp) {
throw new TranslatableError("domain_expiry_unsupported_is_ip", { hostname: tld.hostname }); throw new TranslatableError("domain_expiry_unsupported_is_ip", { hostname: tld.hostname });
} }
// No one-letter public suffix exists; treat this as an incomplete/invalid input while typing. // No one-letter public suffix exists; treat this as an incomplete/invalid input while typing.
if (tld.publicSuffix.length < 2) { if (tld.publicSuffix.length < 2) {
throw new TranslatableError("domain_expiry_public_suffix_too_short", { publicSuffix: tld.publicSuffix }); throw new TranslatableError("domain_expiry_public_suffix_too_short", { publicSuffix: tld.publicSuffix });
} }
if (!tld.isIcann) {
const rdap = await getRdapServer(tld.publicSuffix); throw new TranslatableError("domain_expiry_unsupported_is_icann", {
if (!rdap) { domain: tld.domain,
// Only warn when the monitor actually has domain expiry notifications enabled.
// The edit monitor page calls this method frequently while the user is typing.
if (Boolean(monitor.domainExpiryNotification)) {
log.warn(
"domain_expiry",
`Domain expiry unsupported for '.${tld.publicSuffix}' because its RDAP endpoint is not listed in the IANA database.`
);
}
throw new TranslatableError("domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint", {
publicSuffix: tld.publicSuffix, publicSuffix: tld.publicSuffix,
}); });
} }
const publicSuffix = tld.publicSuffix;
const rootTld = publicSuffix.split(".").pop();
const rdap = getRdapServer(publicSuffix);
if (!rdap) {
throw new TranslatableError("domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint", {
publicSuffix,
});
}
return { return {
domain: tld.domain, domain: tld.domain,
tld: tld.publicSuffix, tld: rootTld,
}; };
} }

View File

@ -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,
}; };
} }
} }

View File

@ -1059,7 +1059,15 @@ class Monitor extends BeanModel {
log.debug("monitor", `Failed getting expiration date for domain ${supportInfo.domain}`); log.debug("monitor", `Failed getting expiration date for domain ${supportInfo.domain}`);
} }
} catch (error) { } catch (error) {
// purposely not logged due to noise. Is accessible via checkMointor if (
error.message === "domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint" &&
Boolean(this.domainExpiryNotification)
) {
log.warn(
"domain_expiry",
`Domain expiry unsupported for '.${error.meta.publicSuffix}' because its RDAP endpoint is not listed in the IANA database.`
);
}
} }
} }
@ -1497,24 +1505,46 @@ class Monitor extends BeanModel {
let msg = `[${monitor.name}] [${text}] ${bean.msg}`; let msg = `[${monitor.name}] [${text}] ${bean.msg}`;
const heartbeatJSON = await bean.toJSONAsync({ decodeResponse: true });
const monitorData = [{ id: monitor.id, active: monitor.active, name: monitor.name }];
const preloadData = await Monitor.preparePreloadData(monitorData);
// Prevent if the msg is undefined, notifications such as Discord cannot send out.
if (!heartbeatJSON["msg"]) {
heartbeatJSON["msg"] = "N/A";
}
// Also provide the time in server timezone
heartbeatJSON["timezone"] = await UptimeKumaServer.getInstance().getTimezone();
heartbeatJSON["timezoneOffset"] = UptimeKumaServer.getInstance().getTimezoneOffset();
heartbeatJSON["localDateTime"] = dayjs
.utc(heartbeatJSON["time"])
.tz(heartbeatJSON["timezone"])
.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) { for (let notification of notificationList) {
try { try {
const heartbeatJSON = await bean.toJSONAsync({ decodeResponse: true });
const monitorData = [{ id: monitor.id, active: monitor.active, name: monitor.name }];
const preloadData = await Monitor.preparePreloadData(monitorData);
// Prevent if the msg is undefined, notifications such as Discord cannot send out.
if (!heartbeatJSON["msg"]) {
heartbeatJSON["msg"] = "N/A";
}
// Also provide the time in server timezone
heartbeatJSON["timezone"] = await UptimeKumaServer.getInstance().getTimezone();
heartbeatJSON["timezoneOffset"] = UptimeKumaServer.getInstance().getTimezoneOffset();
heartbeatJSON["localDateTime"] = dayjs
.utc(heartbeatJSON["time"])
.tz(heartbeatJSON["timezone"])
.format(SQL_DATETIME_FORMAT);
await Notification.send( await Notification.send(
JSON.parse(notification.config), JSON.parse(notification.config),
msg, msg,
@ -1716,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") { 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)) {
@ -1756,6 +1835,28 @@ class Monitor extends BeanModel {
this.timeout = pingGlobalTimeout; this.timeout = pingGlobalTimeout;
} }
} }
if (this.type === "real-browser") {
// screenshot_delay validation
if (this.screenshot_delay !== undefined && this.screenshot_delay !== null) {
const delay = Number(this.screenshot_delay);
if (isNaN(delay) || delay < 0) {
throw new Error("Screenshot delay must be a non-negative number");
}
// Must not exceed 0.8 * timeout (page.goto timeout is interval * 1000 * 0.8)
const maxDelayFromTimeout = this.interval * 1000 * 0.8;
if (delay >= maxDelayFromTimeout) {
throw new Error(`Screenshot delay must be less than ${maxDelayFromTimeout}ms (0.8 × interval)`);
}
// Must not exceed 0.5 * interval to prevent blocking next check
const maxDelayFromInterval = this.interval * 1000 * 0.5;
if (delay >= maxDelayFromInterval) {
throw new Error(`Screenshot delay must be less than ${maxDelayFromInterval}ms (0.5 × interval)`);
}
}
}
} }
/** /**

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

View File

@ -3,26 +3,61 @@ const { log, UP } = require("../../src/util");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const postgresConParse = require("pg-connection-string").parse; const postgresConParse = require("pg-connection-string").parse;
const { Client } = require("pg"); const { Client } = require("pg");
const { ConditionVariable } = require("../monitor-conditions/variables");
const { defaultStringOperators } = require("../monitor-conditions/operators");
const { ConditionExpressionGroup } = require("../monitor-conditions/expression");
const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator");
class PostgresMonitorType extends MonitorType { class PostgresMonitorType extends MonitorType {
name = "postgres"; name = "postgres";
supportsConditions = true;
conditionVariables = [new ConditionVariable("result", defaultStringOperators)];
/** /**
* @inheritdoc * @inheritdoc
*/ */
async check(monitor, heartbeat, _server) { async check(monitor, heartbeat, _server) {
let startTime = dayjs().valueOf();
let query = monitor.databaseQuery; let query = monitor.databaseQuery;
// No query provided by user, use SELECT 1 // No query provided by user, use SELECT 1
if (!query || (typeof query === "string" && query.trim() === "")) { if (!query || (typeof query === "string" && query.trim() === "")) {
query = "SELECT 1"; query = "SELECT 1";
} }
await this.postgresQuery(monitor.databaseConnectionString, query);
heartbeat.msg = ""; const conditions = monitor.conditions ? ConditionExpressionGroup.fromMonitor(monitor) : null;
heartbeat.status = UP; const hasConditions = conditions && conditions.children && conditions.children.length > 0;
heartbeat.ping = dayjs().valueOf() - startTime;
const startTime = dayjs().valueOf();
try {
if (hasConditions) {
// When conditions are enabled, expect a single value result
const result = await this.postgresQuerySingleValue(monitor.databaseConnectionString, query);
heartbeat.ping = dayjs().valueOf() - startTime;
const conditionsResult = evaluateExpressionGroup(conditions, { result: String(result) });
if (!conditionsResult) {
throw new Error(`Query result did not meet the specified conditions (${result})`);
}
heartbeat.status = UP;
heartbeat.msg = "Query did meet specified conditions";
} else {
// Backwards compatible: just check connection and return row count
const result = await this.postgresQuery(monitor.databaseConnectionString, query);
heartbeat.ping = dayjs().valueOf() - startTime;
heartbeat.status = UP;
heartbeat.msg = result;
}
} catch (error) {
heartbeat.ping = dayjs().valueOf() - startTime;
// Re-throw condition errors as-is, wrap database errors
if (error.message.includes("did not meet the specified conditions")) {
throw error;
}
throw new Error(`Database connection/query failed: ${error.message}`);
}
} }
/** /**
@ -76,6 +111,75 @@ class PostgresMonitorType extends MonitorType {
}); });
}); });
} }
/**
* Run a query on Postgres
* @param {string} connectionString The database connection string
* @param {string} query The query to validate the database with
* @returns {Promise<(string[] | object[] | object)>} Response from
* server
*/
async postgresQuerySingleValue(connectionString, query) {
return new Promise((resolve, reject) => {
const config = postgresConParse(connectionString);
// Fix #3868, which true/false is not parsed to boolean
if (typeof config.ssl === "string") {
config.ssl = config.ssl === "true";
}
if (config.password === "") {
// See https://github.com/brianc/node-postgres/issues/1927
reject(new Error("Password is undefined."));
return;
}
const client = new Client(config);
client.on("error", (error) => {
log.debug(this.name, "Error caught in the error event handler.");
reject(error);
});
client.connect((err) => {
if (err) {
reject(err);
client.end();
} else {
// Connected here
try {
client.query(query, (err, res) => {
if (err) {
reject(err);
} else {
// Check if we have results
if (!res.rows || res.rows.length === 0) {
reject(new Error("Query returned no results"));
return;
}
// Check if we have multiple rows
if (res.rows.length > 1) {
reject(new Error("Multiple values were found, expected only one value"));
return;
}
const firstRow = res.rows[0];
const columnNames = Object.keys(firstRow);
// Check if we have multiple columns
if (columnNames.length > 1) {
reject(new Error("Multiple columns were found, expected only one value"));
return;
}
resolve(firstRow[columnNames[0]]);
}
client.end();
});
} catch (e) {
reject(e);
client.end();
}
}
});
});
}
} }
module.exports = { module.exports = {

View File

@ -269,6 +269,11 @@ class RealBrowserMonitorType extends MonitorType {
timeout: monitor.interval * 1000 * 0.8, timeout: monitor.interval * 1000 * 0.8,
}); });
// Wait for additional time before taking screenshot if configured
if (monitor.screenshot_delay > 0) {
await page.waitForTimeout(monitor.screenshot_delay);
}
let filename = jwt.sign(monitor.id, server.jwtSecret) + ".png"; let filename = jwt.sign(monitor.id, server.jwtSecret) + ".png";
await page.screenshot({ await page.screenshot({

View File

@ -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],
}; };
session = snmp.createSession(monitor.hostname, monitor.radiusPassword, sessionOptions);
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);
}
// Handle errors during session creation // Handle errors during session creation
session.on("error", (error) => { session.on("error", (error) => {

View File

@ -11,6 +11,11 @@ class Discord extends NotificationProvider {
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully."; const okMsg = "Sent Successfully.";
// Discord Message Flags
// @see https://discord.com/developers/docs/resources/message#message-object-message-flags
// This message will not trigger push and desktop notifications
const SUPPRESS_NOTIFICATIONS_FLAG = 1 << 12;
try { try {
let config = this.getAxiosConfigWithProxy({}); let config = this.getAxiosConfigWithProxy({});
const discordDisplayName = notification.discordUsername || "Uptime Kuma"; const discordDisplayName = notification.discordUsername || "Uptime Kuma";
@ -41,6 +46,9 @@ class Discord extends NotificationProvider {
if (notification.discordChannelType === "createNewForumPost") { if (notification.discordChannelType === "createNewForumPost") {
discordtestdata.thread_name = notification.postName; discordtestdata.thread_name = notification.postName;
} }
if (notification.discordSuppressNotifications) {
discordtestdata.flags = SUPPRESS_NOTIFICATIONS_FLAG;
}
await axios.post(webhookUrl.toString(), discordtestdata, config); await axios.post(webhookUrl.toString(), discordtestdata, config);
return okMsg; return okMsg;
} }
@ -48,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: [
@ -68,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"],
@ -89,10 +104,21 @@ class Discord extends NotificationProvider {
if (notification.discordPrefixMessage) { if (notification.discordPrefixMessage) {
discorddowndata.content = notification.discordPrefixMessage; discorddowndata.content = notification.discordPrefixMessage;
} }
if (notification.discordSuppressNotifications) {
discorddowndata.flags = SUPPRESS_NOTIFICATIONS_FLAG;
}
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: [
@ -113,10 +139,23 @@ class Discord extends NotificationProvider {
}, },
] ]
: []), : []),
{ ...(wentOfflineTimestamp
name: `Time (${heartbeatJSON["timezone"]})`, ? [
value: heartbeatJSON["localDateTime"], {
}, name: "Went Offline",
// F for full date/time
value: `<t:${wentOfflineTimestamp}:F>`,
},
]
: []),
...(downtimeDuration
? [
{
name: "Downtime Duration",
value: downtimeDuration,
},
]
: []),
...(heartbeatJSON["ping"] != null ...(heartbeatJSON["ping"] != null
? [ ? [
{ {
@ -140,6 +179,9 @@ class Discord extends NotificationProvider {
if (notification.discordPrefixMessage) { if (notification.discordPrefixMessage) {
discordupdata.content = notification.discordPrefixMessage; discordupdata.content = notification.discordPrefixMessage;
} }
if (notification.discordSuppressNotifications) {
discordupdata.flags = SUPPRESS_NOTIFICATIONS_FLAG;
}
await axios.post(webhookUrl.toString(), discordupdata, config); await axios.post(webhookUrl.toString(), discordupdata, config);
return okMsg; return okMsg;
@ -148,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;

View File

@ -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,

View File

@ -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 = {

View File

@ -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,
},
}; };
} }
} }

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 // 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);

View File

@ -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,16 +204,22 @@ class SetupDatabase {
// External MariaDB // External MariaDB
if (dbConfig.type === "mariadb") { if (dbConfig.type === "mariadb") {
if (!dbConfig.hostname) { // If socketPath is provided and not empty, validate it
response.status(400).json("Hostname is required"); if (process.env.UPTIME_KUMA_DB_SOCKET?.trim().length > 0) {
this.runningSetup = false; dbConfig.socketPath = process.env.UPTIME_KUMA_DB_SOCKET.trim();
return; } else {
} // socketPath not provided, hostname and port are required
if (!dbConfig.hostname) {
response.status(400).json("Hostname is required");
this.runningSetup = false;
return;
}
if (!dbConfig.port) { if (!dbConfig.port) {
response.status(400).json("Port is required"); response.status(400).json("Port is required");
this.runningSetup = false; this.runningSetup = false;
return; return;
}
} }
if (!dbConfig.dbName) { if (!dbConfig.dbName) {
@ -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: {

View File

@ -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);

View File

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<h4>{{ $t("Certificate Info") }}</h4> <h4>{{ $t("Certificate Info") }}</h4>
{{ $t("Certificate Chain") }}: {{ $t("Certificate Chain:") }}
<div v-if="valid" class="rounded d-inline-flex ms-2 text-white tag-valid"> <div v-if="valid" class="rounded d-inline-flex ms-2 text-white tag-valid">
{{ $t("Valid") }} {{ $t("Valid") }}
</div> </div>

View File

@ -40,14 +40,13 @@
required required
/> />
<div class="form-text"> <i18n-t tag="div" keypath="Examples:" class="form-text">
{{ $t("Examples") }}:
<ul> <ul>
<li>/var/run/docker.sock</li> <li><code>/var/run/docker.sock</code></li>
<li>http://localhost:2375</li> <li><code>http://localhost:2375</code></li>
<li>https://localhost:2376 (TLS)</li> <li><code>https://localhost:2376 (TLS)</code></li>
</ul> </ul>
</div> </i18n-t>
</div> </div>
</div> </div>

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

@ -1,8 +1,24 @@
<template> <template>
<div class="shadow-box mb-3" :style="boxStyle"> <div class="shadow-box mb-3 p-0" :style="boxStyle">
<div class="list-header"> <div class="list-header">
<div class="header-top"> <!-- Line 1: Checkbox + Status + Tags + Search Bar -->
<div class="select-checkbox-wrapper"> <div class="filter-row">
<div class="search-wrapper">
<a v-if="searchText != ''" class="search-icon" @click="clearSearchText">
<font-awesome-icon icon="times" />
</a>
<form @submit.prevent>
<input
v-model="searchText"
class="form-control search-input"
:placeholder="$t('Search...')"
:aria-label="$t('Search monitored sites')"
autocomplete="off"
/>
</form>
</div>
<div class="filters-group">
<input <input
v-if="!selectMode" v-if="!selectMode"
v-model="selectMode" v-model="selectMode"
@ -18,33 +34,23 @@
type="checkbox" type="checkbox"
:aria-label="selectAll ? $t('deselectAllMonitorsAria') : $t('selectAllMonitorsAria')" :aria-label="selectAll ? $t('deselectAllMonitorsAria') : $t('selectAllMonitorsAria')"
/> />
</div>
<div class="header-filter"> <MonitorListFilter
<MonitorListFilter :filterState="filterState" @update-filter="updateFilter" /> :filterState="filterState"
</div> :allCollapsed="allGroupsCollapsed"
:hasGroups="groupMonitors.length >= 2"
<div class="search-wrapper ms-auto"> @update-filter="updateFilter"
<a v-if="searchText != ''" class="search-icon" @click="clearSearchText"> @toggle-collapse-all="toggleCollapseAll"
<font-awesome-icon icon="times" /> />
</a>
<form>
<input
v-model="searchText"
class="form-control search-input"
:placeholder="$t('Search...')"
:aria-label="$t('Search monitored sites')"
autocomplete="off"
/>
</form>
</div> </div>
</div> </div>
<div v-if="selectMode && selectedMonitorCount > 0" class="selected-count-row"> <!-- Line 2: Cancel + Actions (shown when selection mode is active) -->
<div v-if="selectMode && selectedMonitorCount > 0" class="selection-row">
<button class="btn btn-outline-normal" @click="cancelSelectMode"> <button class="btn btn-outline-normal" @click="cancelSelectMode">
{{ $t("Cancel") }} {{ $t("Cancel") }}
</button> </button>
<div class="actions-wrapper ms-2"> <div class="actions-wrapper">
<div class="dropdown"> <div class="dropdown">
<button <button
class="btn btn-outline-normal dropdown-toggle" class="btn btn-outline-normal dropdown-toggle"
@ -82,14 +88,14 @@
</ul> </ul>
</div> </div>
</div> </div>
<span class="selected-count ms-2"> <span class="selected-count">
{{ $tc("selectedMonitorCountMsg", selectedMonitorCount) }} {{ $t("selectedMonitorCountMsg", selectedMonitorCount) }}
</span> </span>
</div> </div>
</div> </div>
<div <div
ref="monitorList" ref="monitorList"
class="monitor-list" class="monitor-list px-2"
:class="{ scrollbar: scrollbar }" :class="{ scrollbar: scrollbar }"
:style="monitorListStyle" :style="monitorListStyle"
data-testid="monitor-list" data-testid="monitor-list"
@ -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
@ -360,7 +419,7 @@ export default {
this.bulkActionInProgress = true; this.bulkActionInProgress = true;
activeMonitors.forEach((id) => this.$root.getSocket().emit("pauseMonitor", id, () => {})); activeMonitors.forEach((id) => this.$root.getSocket().emit("pauseMonitor", id, () => {}));
this.$root.toastSuccess(this.$tc("pausedMonitorsMsg", activeMonitors.length)); this.$root.toastSuccess(this.$t("pausedMonitorsMsg", activeMonitors.length));
this.bulkActionInProgress = false; this.bulkActionInProgress = false;
this.cancelSelectMode(); this.cancelSelectMode();
}, },
@ -384,7 +443,7 @@ export default {
this.bulkActionInProgress = true; this.bulkActionInProgress = true;
inactiveMonitors.forEach((id) => this.$root.getSocket().emit("resumeMonitor", id, () => {})); inactiveMonitors.forEach((id) => this.$root.getSocket().emit("resumeMonitor", id, () => {}));
this.$root.toastSuccess(this.$tc("resumedMonitorsMsg", inactiveMonitors.length)); this.$root.toastSuccess(this.$t("resumedMonitorsMsg", inactiveMonitors.length));
this.bulkActionInProgress = false; this.bulkActionInProgress = false;
this.cancelSelectMode(); this.cancelSelectMode();
}, },
@ -424,10 +483,10 @@ export default {
this.bulkActionInProgress = false; this.bulkActionInProgress = false;
if (successCount > 0) { if (successCount > 0) {
this.$root.toastSuccess(this.$tc("deletedMonitorsMsg", successCount)); this.$root.toastSuccess(this.$t("deletedMonitorsMsg", successCount));
} }
if (errorCount > 0) { if (errorCount > 0) {
this.$root.toastError(this.$tc("bulkDeleteErrorMsg", errorCount)); this.$root.toastError(this.$t("bulkDeleteErrorMsg", errorCount));
} }
this.cancelSelectMode(); this.cancelSelectMode();
@ -536,9 +595,11 @@ export default {
.list-header { .list-header {
border-bottom: 1px solid #dee2e6; border-bottom: 1px solid #dee2e6;
border-radius: 10px 10px 0 0; border-radius: 10px 10px 0 0;
margin: -10px;
margin-bottom: 10px; margin-bottom: 10px;
padding: 10px; padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
.dark & { .dark & {
background-color: $dark-header-bg; background-color: $dark-header-bg;
@ -546,37 +607,26 @@ export default {
} }
} }
.search-row { .filter-row {
display: flex;
padding: 10px;
padding-bottom: 5px;
}
.header-top {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 10px; flex-wrap: nowrap;
width: 100%;
@media (max-width: 549px), (min-width: 770px) and (max-width: 1149px), (min-width: 1200px) and (max-width: 1499px) {
flex-wrap: wrap;
}
}
.select-checkbox-wrapper {
display: flex;
align-items: center;
.form-check-input { .form-check-input {
cursor: pointer; cursor: pointer;
margin: 0; margin: 0;
margin-left: 6px;
flex-shrink: 0;
} }
} }
.header-filter { .filters-group {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
} }
.actions-wrapper { .actions-wrapper {
@ -643,6 +693,13 @@ export default {
} }
} }
.selection-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.selected-count { .selected-count {
white-space: nowrap; white-space: nowrap;
font-size: 0.9em; font-size: 0.9em;
@ -653,8 +710,7 @@ export default {
} }
} }
.selected-count-row { .actions-row {
padding: 5px 10px 0 10px;
display: flex; display: flex;
align-items: center; align-items: center;
} }
@ -677,11 +733,29 @@ export default {
} }
} }
@media (max-width: 975px) {
.filter-row {
flex-direction: column-reverse;
align-items: stretch;
gap: 8px;
}
.search-wrapper {
width: 100% !important;
max-width: 100% !important;
margin-left: 0 !important;
flex: 1 1 100%;
}
.filters-group {
width: 100%;
}
}
@media (max-width: 770px) { @media (max-width: 770px) {
.list-header { .list-header {
margin: -20px;
margin-bottom: 10px; margin-bottom: 10px;
padding: 5px; padding: 20px;
} }
} }
@ -689,15 +763,14 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
position: relative; position: relative;
flex: 1 1 auto;
min-width: 0;
max-width: 300px;
margin-left: auto;
order: 1;
@media (max-width: 549px), (min-width: 770px) and (max-width: 1149px), (min-width: 1200px) and (max-width: 1499px) { form {
order: -1;
width: 100%; width: 100%;
margin-bottom: 8px;
form {
width: 100%;
}
} }
} }
@ -715,13 +788,9 @@ export default {
} }
.search-input { .search-input {
max-width: 15em; width: 100%;
padding-right: 30px; padding-right: 30px;
transition: none !important;
@media (max-width: 549px), (min-width: 770px) and (max-width: 1149px), (min-width: 1200px) and (max-width: 1499px) {
max-width: 100%;
width: 100%;
}
} }
.monitor-item { .monitor-item {

View File

@ -1,139 +1,151 @@
<template> <template>
<div class="d-flex align-items-center flex-wrap gap-1"> <MonitorListFilterDropdown :filterActive="filterState.status?.length > 0 || filterState.active?.length > 0">
<MonitorListFilterDropdown :filterActive="filterState.status?.length > 0"> <template #status>
<template #status> <Status
<Status v-if="filterState.status?.length === 1" :status="filterState.status[0]" /> v-if="filterState.status?.length === 1 && !filterState.active?.length"
<span v-else> :status="filterState.status[0]"
{{ $t("Status") }} />
</span> <span
</template> v-else-if="!filterState.status?.length && filterState.active?.length === 1"
<template #dropdown> class="badge status-pill"
<li> :class="filterState.active[0] ? 'running' : 'paused'"
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(1)"> >
<div class="d-flex align-items-center justify-content-between"> <font-awesome-icon :icon="filterState.active[0] ? 'play' : 'pause'" class="icon-small" />
<Status :status="1" /> {{ filterState.active[0] ? $t("Running") : $t("filterActivePaused") }}
<span class="ps-3"> </span>
{{ $root.stats.up }} <span v-else>
<span v-if="filterState.status?.includes(1)" class="px-1 filter-active"> {{ $t("Status") }}
<font-awesome-icon icon="check" /> </span>
</span> </template>
<template #dropdown>
<li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(1)">
<div class="d-flex align-items-center justify-content-between">
<Status :status="1" />
<span class="ps-3">
{{ $root.stats.up }}
<span v-if="filterState.status?.includes(1)" class="px-1 filter-active">
<font-awesome-icon icon="check" />
</span> </span>
</div> </span>
</div> </div>
</li> </div>
<li> </li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(0)"> <li>
<div class="d-flex align-items-center justify-content-between"> <div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(0)">
<Status :status="0" /> <div class="d-flex align-items-center justify-content-between">
<span class="ps-3"> <Status :status="0" />
{{ $root.stats.down }} <span class="ps-3">
<span v-if="filterState.status?.includes(0)" class="px-1 filter-active"> {{ $root.stats.down }}
<font-awesome-icon icon="check" /> <span v-if="filterState.status?.includes(0)" class="px-1 filter-active">
</span> <font-awesome-icon icon="check" />
</span> </span>
</div> </span>
</div> </div>
</li> </div>
<li> </li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(2)"> <li>
<div class="d-flex align-items-center justify-content-between"> <div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(2)">
<Status :status="2" /> <div class="d-flex align-items-center justify-content-between">
<span class="ps-3"> <Status :status="2" />
{{ $root.stats.pending }} <span class="ps-3">
<span v-if="filterState.status?.includes(2)" class="px-1 filter-active"> {{ $root.stats.pending }}
<font-awesome-icon icon="check" /> <span v-if="filterState.status?.includes(2)" class="px-1 filter-active">
</span> <font-awesome-icon icon="check" />
</span> </span>
</div> </span>
</div> </div>
</li> </div>
<li> </li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(3)"> <li>
<div class="d-flex align-items-center justify-content-between"> <div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(3)">
<Status :status="3" /> <div class="d-flex align-items-center justify-content-between">
<span class="ps-3"> <Status :status="3" />
{{ $root.stats.maintenance }} <span class="ps-3">
<span v-if="filterState.status?.includes(3)" class="px-1 filter-active"> {{ $root.stats.maintenance }}
<font-awesome-icon icon="check" /> <span v-if="filterState.status?.includes(3)" class="px-1 filter-active">
</span> <font-awesome-icon icon="check" />
</span> </span>
</div> </span>
</div> </div>
</li> </div>
</template> </li>
</MonitorListFilterDropdown> <li><hr class="dropdown-divider" /></li>
<MonitorListFilterDropdown :filterActive="filterState.active?.length > 0"> <li>
<template #status> <div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(true)">
<span v-if="filterState.active?.length === 1"> <div class="d-flex align-items-center justify-content-between">
<span v-if="filterState.active[0]">{{ $t("Running") }}</span> <span class="badge status-pill running">
<span v-else>{{ $t("filterActivePaused") }}</span> <font-awesome-icon icon="play" class="icon-small" />
</span> {{ $t("Running") }}
<span v-else> </span>
{{ $t("filterActive") }} <span class="ps-3">
</span> {{ $root.stats.active }}
</template> <span v-if="filterState.active?.includes(true)" class="px-1 filter-active">
<template #dropdown> <font-awesome-icon icon="check" />
<li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(true)">
<div class="d-flex align-items-center justify-content-between">
<span>{{ $t("Running") }}</span>
<span class="ps-3">
{{ $root.stats.active }}
<span v-if="filterState.active?.includes(true)" class="px-1 filter-active">
<font-awesome-icon icon="check" />
</span>
</span> </span>
</div> </span>
</div> </div>
</li> </div>
<li> </li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(false)"> <li>
<div class="d-flex align-items-center justify-content-between"> <div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(false)">
<span>{{ $t("filterActivePaused") }}</span> <div class="d-flex align-items-center justify-content-between">
<span class="ps-3"> <span class="badge status-pill paused">
{{ $root.stats.pause }} <font-awesome-icon icon="pause" class="icon-small" />
<span v-if="filterState.active?.includes(false)" class="px-1 filter-active"> {{ $t("filterActivePaused") }}
<font-awesome-icon icon="check" /> </span>
</span> <span class="ps-3">
{{ $root.stats.pause }}
<span v-if="filterState.active?.includes(false)" class="px-1 filter-active">
<font-awesome-icon icon="check" />
</span> </span>
</div> </span>
</div> </div>
</li> </div>
</template> </li>
</MonitorListFilterDropdown> </template>
<MonitorListFilterDropdown :filterActive="filterState.tags?.length > 0"> </MonitorListFilterDropdown>
<template #status> <MonitorListFilterDropdown :filterActive="filterState.tags?.length > 0">
<Tag <template #status>
v-if="filterState.tags?.length === 1" <Tag
:item="tagsList.find((tag) => tag.id === filterState.tags[0])" v-if="filterState.tags?.length === 1"
:size="'sm'" :item="tagsList.find((tag) => tag.id === filterState.tags[0])"
/> :size="'sm'"
<span v-else> />
{{ $t("Tags") }} <span v-else>
</span> {{ $t("Tags") }}
</template> </span>
<template #dropdown> </template>
<li v-for="tag in tagsList" :key="tag.id"> <template #dropdown>
<div class="dropdown-item" tabindex="0" @click.stop="toggleTagFilter(tag)"> <li v-for="tag in tagsList" :key="tag.id">
<div class="d-flex align-items-center justify-content-between"> <div class="dropdown-item" tabindex="0" @click.stop="toggleTagFilter(tag)">
<span><Tag :item="tag" :size="'sm'" /></span> <div class="d-flex align-items-center justify-content-between">
<span class="ps-3"> <span><Tag :item="tag" :size="'sm'" /></span>
{{ getTaggedMonitorCount(tag) }} <span class="ps-3">
<span v-if="filterState.tags?.includes(tag.id)" class="px-1 filter-active"> {{ getTaggedMonitorCount(tag) }}
<font-awesome-icon icon="check" /> <span v-if="filterState.tags?.includes(tag.id)" class="px-1 filter-active">
</span> <font-awesome-icon icon="check" />
</span> </span>
</div> </span>
</div> </div>
</li> </div>
<li v-if="tagsList.length === 0"> </li>
<div class="dropdown-item disabled px-3"> <li v-if="tagsList.length === 0">
{{ $t("No tags found.") }} <div class="dropdown-item disabled px-3">
</div> {{ $t("No tags found.") }}
</li> </div>
</template> </li>
</MonitorListFilterDropdown> </template>
</div> </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>
@ -152,8 +164,16 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
allCollapsed: {
type: Boolean,
default: true,
},
hasGroups: {
type: Boolean,
default: false,
},
}, },
emits: ["updateFilter"], emits: ["updateFilter", "toggle-collapse-all"],
data() { data() {
return { return {
tagsList: [], tagsList: [],
@ -252,6 +272,17 @@ export default {
cursor: pointer; cursor: pointer;
} }
.simple-status {
min-width: 64px;
border: 1px solid #d1d5db;
background-color: transparent !important;
color: inherit !important;
.dark & {
border-color: #6b7280;
}
}
.clear-filters-btn { .clear-filters-btn {
font-size: 0.8em; font-size: 0.8em;
margin-right: 5px; margin-right: 5px;
@ -275,4 +306,41 @@ export default {
} }
} }
} }
.dropdown-divider {
margin: 0.5rem 0;
border-top: 1px solid #d1d5db;
.dark & {
border-top-color: #6b7280;
}
}
.status-pill {
min-width: 64px;
display: inline-block;
text-align: center;
&.running,
&.paused {
background-color: white !important;
border: 1px solid #d1d5db;
color: inherit;
.dark & {
background-color: transparent !important;
border-color: #6b7280;
color: $dark-font-color;
}
.icon-small {
font-size: 0.75em;
margin-right: 4px;
}
}
}
.btn-collapse-all {
transition: none !important;
}
</style> </style>

View File

@ -103,7 +103,7 @@ export default {
@extend .btn-outline-normal; @extend .btn-outline-normal;
display: flex; display: flex;
align-items: center; align-items: center;
margin-left: 5px; margin-left: 0;
color: $link-color; color: $link-color;
.dark & { .dark & {

View File

@ -24,12 +24,12 @@
<router-link :to="monitorURL(monitor.id)" class="item" :class="{ disabled: !monitor.active }"> <router-link :to="monitorURL(monitor.id)" class="item" :class="{ disabled: !monitor.active }">
<div class="row"> <div class="row">
<div <div
class="col-9 col-xl-6 small-padding" class="col-9 col-xl-6 small-padding d-flex align-items-center"
:class="{ :class="{
'monitor-item': $root.userHeartbeatBar == 'bottom' || $root.userHeartbeatBar == 'none', 'monitor-item': $root.userHeartbeatBar == 'bottom' || $root.userHeartbeatBar == 'none',
}" }"
> >
<div class="info"> <div class="info d-flex align-items-center gap-2">
<Uptime :monitor="monitor" type="24" :pill="true" /> <Uptime :monitor="monitor" type="24" :pill="true" />
<span v-if="hasChildren" class="collapse-padding" @click.prevent="changeCollapsed"> <span v-if="hasChildren" class="collapse-padding" @click.prevent="changeCollapsed">
<font-awesome-icon <font-awesome-icon
@ -383,6 +383,7 @@ export default {
/* We don't want the padding change due to the border animated */ /* We don't want the padding change due to the border animated */
.item { .item {
padding: 12px 15px;
transition: none !important; transition: none !important;
} }

View File

@ -231,15 +231,11 @@ export default {
*/ */
formattedCertExpiryMessage(monitor) { formattedCertExpiryMessage(monitor) {
if (monitor?.element?.validCert && monitor?.element?.certExpiryDaysRemaining) { if (monitor?.element?.validCert && monitor?.element?.certExpiryDaysRemaining) {
return ( return this.$t("days", monitor.element.certExpiryDaysRemaining);
monitor.element.certExpiryDaysRemaining +
" " +
this.$tc("day", monitor.element.certExpiryDaysRemaining)
);
} else if (monitor?.element?.validCert === false) { } else if (monitor?.element?.validCert === false) {
return this.$t("noOrBadCertificate"); return this.$t("noOrBadCertificate");
} else { } else {
return this.$t("Unknown") + " " + this.$tc("day", 2); return this.$t("unknownDays");
} }
}, },

View File

@ -31,12 +31,9 @@
required required
/> />
<div class="form-text mt-3"> <i18n-t tag="div" keypath="Example:" class="form-text mt-3">
{{ $t("Examples") }}: <code>ws://chrome.browserless.io/playwright?token=YOUR-API-TOKEN</code>
<ul> </i18n-t>
<li>ws://chrome.browserless.io/playwright?token=YOUR-API-TOKEN</li>
</ul>
</div>
</div> </div>
</div> </div>

View File

@ -68,7 +68,7 @@
<div class="mb-3"> <div class="mb-3">
<label for="tag-monitors" class="form-label"> <label for="tag-monitors" class="form-label">
{{ $tc("Monitor", selectedMonitors.length) }} {{ $t("Monitors", selectedMonitors.length) }}
</label> </label>
<div class="tag-monitors-list"> <div class="tag-monitors-list">
<router-link <router-link

View File

@ -85,12 +85,12 @@ export default {
title() { title() {
if (this.type === "1y") { if (this.type === "1y") {
return `1 ${this.$tc("year", 1)}`; return this.$t("years", 1);
} }
if (this.type === "720") { if (this.type === "720") {
return `30 ${this.$tc("day", 30)}`; return this.$t("days", 30);
} }
return `24 ${this.$tc("hour", 24)}`; return this.$t("hours", 24);
}, },
}, },
}; };

View File

@ -102,6 +102,24 @@
<label class="form-check-label" for="discord-disable-url">{{ $t("Disable URL in Notification") }}</label> <label class="form-check-label" for="discord-disable-url">{{ $t("Disable URL in Notification") }}</label>
</div> </div>
</div> </div>
<div class="mb-3">
<div class="form-check form-switch">
<input
id="discord-suppress-notifications"
v-model="$parent.notification.discordSuppressNotifications"
class="form-check-input"
type="checkbox"
role="switch"
/>
<label class="form-check-label" for="discord-suppress-notifications">
{{ $t("Suppress Notifications") }}
</label>
</div>
<div class="form-text">
{{ $t("discordSuppressNotificationsHelptext") }}
</div>
</div>
</template> </template>
<script> <script>
export default { export default {
@ -112,6 +130,9 @@ export default {
if (this.$parent.notification.disableUrl === undefined) { if (this.$parent.notification.disableUrl === undefined) {
this.$parent.notification.disableUrl = false; this.$parent.notification.disableUrl = false;
} }
if (this.$parent.notification.discordSuppressNotifications === undefined) {
this.$parent.notification.discordSuppressNotifications = false;
}
}, },
}; };
</script> </script>

View File

@ -2,8 +2,8 @@
<div class="mb-3"> <div class="mb-3">
<label for="octopush-version" class="form-label">{{ $t("Octopush API Version") }}</label> <label for="octopush-version" class="form-label">{{ $t("Octopush API Version") }}</label>
<select id="octopush-version" v-model="$parent.notification.octopushVersion" class="form-select"> <select id="octopush-version" v-model="$parent.notification.octopushVersion" class="form-select">
<option value="2">{{ "octopush" }} ({{ $t("endpoint") }}: api.octopush.com)</option> <option value="2">{{ $t("octopushEndpoint", { url: "api.octopush.com" }) }}</option>
<option value="1">{{ $t("Legacy Octopush-DM") }} ({{ $t("endpoint") }}: www.octopush-dm.com)</option> <option value="1">{{ $t("legacyOctopushEndpoint", { url: "www.octopush-dm.com" }) }}</option>
</select> </select>
<div class="form-text"> <div class="form-text">
{{ $t("octopushLegacyHint") }} {{ $t("octopushLegacyHint") }}

View File

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

View File

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

View File

@ -27,7 +27,7 @@
<div class="status"> <div class="status">
{{ $t("apiKey-" + item.status) }} {{ $t("apiKey-" + item.status) }}
</div> </div>
<div class="date">{{ $t("Created") }}: {{ item.createdDate }}</div> <div class="date">{{ $t("createdAt", { date: item.createdDate }) }}</div>
<div class="date"> <div class="date">
{{ $t("Expires") }}: {{ $t("Expires") }}:
{{ item.expires || $t("Never") }} {{ item.expires || $t("Never") }}

View File

@ -3,8 +3,8 @@
<div class="logo d-flex flex-column justify-content-center align-items-center"> <div class="logo d-flex flex-column justify-content-center align-items-center">
<object class="my-4" width="200" height="200" data="/icon.svg" /> <object class="my-4" width="200" height="200" data="/icon.svg" />
<div class="fs-4 fw-bold">Uptime Kuma</div> <div class="fs-4 fw-bold">Uptime Kuma</div>
<div>{{ $t("Version") }}: {{ $root.info.version }}</div> <div>{{ $t("versionIs", { version: $root.info.version }) }}</div>
<div class="frontend-version">{{ $t("Frontend Version") }}: {{ $root.frontendVersion }}</div> <div class="frontend-version">{{ $t("frontendVersionIs", { version: $root.frontendVersion }) }}</div>
<div v-if="!$root.isFrontendBackendVersionMatched" class="alert alert-warning mt-4" role="alert"> <div v-if="!$root.isFrontendBackendVersionMatched" class="alert alert-warning mt-4" role="alert">
{{ $t("Frontend Version do not match backend version!") }} {{ $t("Frontend Version do not match backend version!") }}

View File

@ -64,7 +64,7 @@
:key="day" :key="day"
class="d-flex align-items-center justify-content-between cert-exp-day-row py-2" class="d-flex align-items-center justify-content-between cert-exp-day-row py-2"
> >
<span>{{ day }} {{ $tc("day", day) }}</span> <span>{{ $t("days", day) }}</span>
<button <button
type="button" type="button"
class="btn-rm-expiry btn btn-outline-danger ms-2 py-1" class="btn-rm-expiry btn btn-outline-danger ms-2 py-1"
@ -79,7 +79,7 @@
<ActionInput <ActionInput
v-model="tlsExpiryNotifInput" v-model="tlsExpiryNotifInput"
:type="'number'" :type="'number'"
:placeholder="$t('day')" :placeholder="$t('days', 1)"
:icon="'plus'" :icon="'plus'"
:action="() => addTlsExpiryNotifDay(tlsExpiryNotifInput)" :action="() => addTlsExpiryNotifDay(tlsExpiryNotifInput)"
:action-aria-label="$t('Add a new expiry notification day')" :action-aria-label="$t('Add a new expiry notification day')"
@ -102,7 +102,7 @@
:key="day" :key="day"
class="d-flex align-items-center justify-content-between cert-exp-day-row py-2" class="d-flex align-items-center justify-content-between cert-exp-day-row py-2"
> >
<span>{{ day }} {{ $tc("day", day) }}</span> <span>{{ $t("days", day) }}</span>
<button <button
type="button" type="button"
class="btn-rm-expiry btn btn-outline-danger ms-2 py-1" class="btn-rm-expiry btn btn-outline-danger ms-2 py-1"
@ -117,7 +117,7 @@
<ActionInput <ActionInput
v-model="domainExpiryNotifInput" v-model="domainExpiryNotifInput"
:type="'number'" :type="'number'"
:placeholder="$t('day')" :placeholder="$t('days', 1)"
:icon="'plus'" :icon="'plus'"
:action="() => addDomainExpiryNotifDay(domainExpiryNotifInput)" :action="() => addDomainExpiryNotifDay(domainExpiryNotifInput)"
:action-aria-label="$t('Add a new expiry notification day')" :action-aria-label="$t('Add a new expiry notification day')"

View File

@ -4,15 +4,13 @@
<!-- Change Password --> <!-- Change Password -->
<template v-if="!settings.disableAuth"> <template v-if="!settings.disableAuth">
<p> <p>
{{ $t("Current User") }}:
<strong>{{ $root.username }}</strong>
<button <button
v-if="!settings.disableAuth" v-if="!settings.disableAuth"
id="logout-btn" id="logout-btn"
class="btn btn-danger ms-4 me-2 mb-2" class="btn btn-danger ms-4 me-2 mb-2"
@click="$root.logout" @click="$root.logout"
> >
{{ $t("Logout") }} {{ $t("logoutCurrentUser", { username: $root.username }) }}
</button> </button>
</p> </p>

View File

@ -19,7 +19,7 @@
<Tag :item="tag" /> <Tag :item="tag" />
</div> </div>
<div class="col-5 px-1 d-none d-sm-block"> <div class="col-5 px-1 d-none d-sm-block">
<div>{{ monitorsByTag(tag.id).length }} {{ $tc("Monitor", monitorsByTag(tag.id).length) }}</div> <div>{{ $t("Monitors", monitorsByTag(tag.id).length) }}</div>
</div> </div>
<div class="col-2 pe-2 pe-lg-3 d-flex justify-content-end"> <div class="col-2 pe-2 pe-lg-3 d-flex justify-content-end">
<button <button

View File

@ -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 };

View File

@ -20,7 +20,7 @@
"General": "General", "General": "General",
"Game": "Game", "Game": "Game",
"Primary Base URL": "Primary Base URL", "Primary Base URL": "Primary Base URL",
"Version": "Version", "versionIs": "Version: {version}",
"Check Update On GitHub": "Check Update On GitHub", "Check Update On GitHub": "Check Update On GitHub",
"List": "List", "List": "List",
"Home": "Home", "Home": "Home",
@ -33,18 +33,22 @@
"statusMaintenance": "Maintenance", "statusMaintenance": "Maintenance",
"Maintenance": "Maintenance", "Maintenance": "Maintenance",
"Unknown": "Unknown", "Unknown": "Unknown",
"unknownDays": "Unknown days",
"Cannot connect to the socket server": "Cannot connect to the socket server", "Cannot connect to the socket server": "Cannot connect to the socket server",
"Reconnecting...": "Reconnecting...", "Reconnecting...": "Reconnecting...",
"General Monitor Type": "General Monitor Type", "General Monitor Type": "General Monitor Type",
"Passive Monitor Type": "Passive Monitor Type", "Passive Monitor Type": "Passive Monitor Type",
"Specific Monitor Type": "Specific Monitor Type", "Specific Monitor Type": "Specific Monitor Type",
"markdownSupported": "Markdown syntax supported", "markdownSupported": "Markdown syntax supported. If using HTML, avoid leading spaces to prevent formatting issues.",
"pauseDashboardHome": "Pause", "pauseDashboardHome": "Pause",
"Pause": "Pause", "Pause": "Pause",
"Name": "Name", "Name": "Name",
"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",
@ -52,13 +56,16 @@
"Current": "Current", "Current": "Current",
"Uptime": "Uptime", "Uptime": "Uptime",
"Cert Exp.": "Cert Exp.", "Cert Exp.": "Cert Exp.",
"Monitor": "Monitor | Monitors", "Monitors": "{n} Monitor | {n} Monitors",
"now": "now", "now": "now",
"time ago": "{0} ago", "time ago": "{0} ago",
"day": "day | days", "days": "{n} day | {n} days",
"hour": "hour | hours", "hours": "{n} hour | {n} hours",
"year": "year | years", "minutes": "{n} minute | {n} minutes",
"minuteShort": "{n} min | {n} min",
"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",
@ -143,6 +150,7 @@
"where you intend to implement third-party authentication": "where you intend to implement third-party authentication", "where you intend to implement third-party authentication": "where you intend to implement third-party authentication",
"Please use this option carefully!": "Please use this option carefully!", "Please use this option carefully!": "Please use this option carefully!",
"Logout": "Log out", "Logout": "Log out",
"logoutCurrentUser": "Log out {username}",
"Leave": "Leave", "Leave": "Leave",
"I understand, please disable": "I understand, please disable", "I understand, please disable": "I understand, please disable",
"Confirm": "Confirm", "Confirm": "Confirm",
@ -163,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",
@ -219,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…",
@ -234,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",
@ -278,7 +292,6 @@
"records": "records", "records": "records",
"One record": "One record", "One record": "One record",
"steamApiKeyDescriptionAt": "For monitoring a Steam Game Server you need a Steam Web-API key. You can register your API key at {url}", "steamApiKeyDescriptionAt": "For monitoring a Steam Game Server you need a Steam Web-API key. You can register your API key at {url}",
"Current User": "Current User",
"topic": "Topic", "topic": "Topic",
"topicExplanation": "MQTT topic to monitor", "topicExplanation": "MQTT topic to monitor",
"mqttWebSocketPath": "MQTT WebSocket Path", "mqttWebSocketPath": "MQTT WebSocket Path",
@ -293,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",
@ -318,8 +333,9 @@
"dark": "dark", "dark": "dark",
"Post": "Post", "Post": "Post",
"Please input title and content": "Please input title and content", "Please input title and content": "Please input title and content",
"Created": "Created", "createdAt": "Created: {date}",
"Last Updated": "Last Updated", "lastUpdatedAt": "Last Updated: {date}",
"lastUpdatedAtFromNow": "Last Updated: {date} ({fromNow})",
"Switch to Light Theme": "Switch to Light Theme", "Switch to Light Theme": "Switch to Light Theme",
"Switch to Dark Theme": "Switch to Dark Theme", "Switch to Dark Theme": "Switch to Dark Theme",
"Show Tags": "Show Tags", "Show Tags": "Show Tags",
@ -345,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",
@ -354,7 +371,7 @@
"proxyDescription": "Proxies must be assigned to a monitor to function.", "proxyDescription": "Proxies must be assigned to a monitor to function.",
"enableProxyDescription": "This proxy will not effect on monitor requests until it is activated. You can control temporarily disable the proxy from all monitors by activation status.", "enableProxyDescription": "This proxy will not effect on monitor requests until it is activated. You can control temporarily disable the proxy from all monitors by activation status.",
"setAsDefaultProxyDescription": "This proxy will be enabled by default for new monitors. You can still disable the proxy separately for each monitor.", "setAsDefaultProxyDescription": "This proxy will be enabled by default for new monitors. You can still disable the proxy separately for each monitor.",
"Certificate Chain": "Certificate Chain", "Certificate Chain:": "Certificate Chain:",
"Valid": "Valid", "Valid": "Valid",
"Invalid": "Invalid", "Invalid": "Invalid",
"User": "User", "User": "User",
@ -367,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",
@ -390,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:",
@ -401,7 +421,7 @@
"Add a new expiry notification day": "Add a new expiry notification day", "Add a new expiry notification day": "Add a new expiry notification day",
"Remove the expiry notification": "Remove the expiry notification day", "Remove the expiry notification": "Remove the expiry notification day",
"Proxy": "Proxy", "Proxy": "Proxy",
"Date Created": "Date Created", "dateCreatedAtFromNow": "Date Created: {date} ({fromNow})",
"Footer Text": "Footer Text", "Footer Text": "Footer Text",
"RSS Title": "RSS Title", "RSS Title": "RSS Title",
"Leave blank to use status page title": "Leave blank to use status page title", "Leave blank to use status page title": "Leave blank to use status page title",
@ -475,13 +495,13 @@
"disableCloudflaredNoAuthMsg": "You are in No Auth mode, a password is not required.", "disableCloudflaredNoAuthMsg": "You are in No Auth mode, a password is not required.",
"trustProxyDescription": "Trust 'X-Forwarded-*' headers. If you want to get the correct client IP and your Uptime Kuma is behind a proxy such as Nginx or Apache, you should enable this.", "trustProxyDescription": "Trust 'X-Forwarded-*' headers. If you want to get the correct client IP and your Uptime Kuma is behind a proxy such as Nginx or Apache, you should enable this.",
"wayToGetLineNotifyToken": "You can get an access token from {0}", "wayToGetLineNotifyToken": "You can get an access token from {0}",
"Examples": "Examples", "Examples:": "Examples: {0}",
"supportBaleChatID": "Support Direct Chat / Group / Channel's Chat ID", "supportBaleChatID": "Support Direct Chat / Group / Channel's Chat ID",
"wayToGetBaleChatID": "You can get your chat ID by sending a message to the bot and going to this URL to view the chat_id:", "wayToGetBaleChatID": "You can get your chat ID by sending a message to the bot and going to this URL to view the chat_id:",
"wayToGetBaleToken": "You can get a token from {0}.", "wayToGetBaleToken": "You can get a token from {0}.",
"Home Assistant URL": "Home Assistant URL", "Home Assistant URL": "Home Assistant URL",
"Long-Lived Access Token": "Long-Lived Access Token", "Long-Lived Access Token": "Long-Lived Access Token",
"Long-Lived Access Token can be created by clicking on your profile name (bottom left) and scrolling to the bottom then click Create Token. ": "Long-Lived Access Token can be created by clicking on your profile name (bottom left) and scrolling to the bottom then click Create Token. ", "Long-Lived Access Token can be created by clicking on your profile name (bottom left) and scrolling to the bottom then click Create Token. ": "Long-Lived Access Token can be created by clicking on your profile name (bottom left) and scrolling to the bottom then click Create Token.",
"Notification Service": "Notification Service", "Notification Service": "Notification Service",
"default: notify all devices": "default: notify all devices", "default: notify all devices": "default: notify all devices",
"A list of Notification Services can be found in Home Assistant under \"Developer Tools > Services\" search for \"notification\" to find your device/phone name.": "A list of Notification Services can be found in Home Assistant under \"Developer Tools > Services\" search for \"notification\" to find your device/phone name.", "A list of Notification Services can be found in Home Assistant under \"Developer Tools > Services\" search for \"notification\" to find your device/phone name.": "A list of Notification Services can be found in Home Assistant under \"Developer Tools > Services\" search for \"notification\" to find your device/phone name.",
@ -490,7 +510,7 @@
"Event type:": "Event type:", "Event type:": "Event type:",
"Event data:": "Event data:", "Event data:": "Event data:",
"Then choose an action, for example switch the scene to where an RGB light is red.": "Then choose an action, for example switch the scene to where an RGB light is red.", "Then choose an action, for example switch the scene to where an RGB light is red.": "Then choose an action, for example switch the scene to where an RGB light is red.",
"Frontend Version": "Frontend Version", "frontendVersionIs": "Frontend Version: {version}",
"Frontend Version do not match backend version!": "Frontend Version do not match backend version!", "Frontend Version do not match backend version!": "Frontend Version do not match backend version!",
"backupOutdatedWarning": "Deprecated: Since a lot of features were added and this backup feature is a bit unmaintained, it cannot generate or restore a complete backup.", "backupOutdatedWarning": "Deprecated: Since a lot of features were added and this backup feature is a bit unmaintained, it cannot generate or restore a complete backup.",
"backupRecommend": "Please backup the volume or the data folder (./data/) directly instead.", "backupRecommend": "Please backup the volume or the data folder (./data/) directly instead.",
@ -501,7 +521,7 @@
"startDateTime": "Start Date/Time", "startDateTime": "Start Date/Time",
"endDateTime": "End Date/Time", "endDateTime": "End Date/Time",
"cronExpression": "Cron Expression", "cronExpression": "Cron Expression",
"cronSchedule": "Schedule: ", "cronScheduleDescription": "Schedule: {description}",
"Duration (Minutes)": "Duration (Minutes)", "Duration (Minutes)": "Duration (Minutes)",
"invalidCronExpression": "Invalid Cron Expression: {0}", "invalidCronExpression": "Invalid Cron Expression: {0}",
"recurringInterval": "Interval", "recurringInterval": "Interval",
@ -581,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:",
@ -668,6 +690,8 @@
"recurringIntervalMessage": "Run once every day | Run once every {0} days", "recurringIntervalMessage": "Run once every day | Run once every {0} days",
"affectedMonitorsDescription": "Select monitors that are affected by current maintenance", "affectedMonitorsDescription": "Select monitors that are affected by current maintenance",
"affectedStatusPages": "Show this maintenance message on selected status pages", "affectedStatusPages": "Show this maintenance message on selected status pages",
"Sets end time based on start time": "Sets end time based on start time",
"Please set start time first": "Please set start time first",
"noMonitorsSelectedWarning": "You are creating a maintenance without any affected monitors. Are you sure you want to continue?", "noMonitorsSelectedWarning": "You are creating a maintenance without any affected monitors. Are you sure you want to continue?",
"noMonitorsOrStatusPagesSelectedError": "Cannot create maintenance without affected monitors or status pages", "noMonitorsOrStatusPagesSelectedError": "Cannot create maintenance without affected monitors or status pages",
"passwordNotMatchMsg": "The repeat password does not match.", "passwordNotMatchMsg": "The repeat password does not match.",
@ -678,7 +702,6 @@
"backupDescription": "You can backup all monitors and notifications into a JSON file.", "backupDescription": "You can backup all monitors and notifications into a JSON file.",
"backupDescription2": "Note: history and event data is not included.", "backupDescription2": "Note: history and event data is not included.",
"backupDescription3": "Sensitive data such as notification tokens are included in the export file; please store export securely.", "backupDescription3": "Sensitive data such as notification tokens are included in the export file; please store export securely.",
"endpoint": "endpoint",
"octopushAPIKey": "\"API key\" from HTTP API credentials in control panel", "octopushAPIKey": "\"API key\" from HTTP API credentials in control panel",
"octopushLogin": "\"Login\" from HTTP API credentials in control panel", "octopushLogin": "\"Login\" from HTTP API credentials in control panel",
"promosmsLogin": "API Login Name", "promosmsLogin": "API Login Name",
@ -730,7 +753,7 @@
"apiCredentials": "API credentials", "apiCredentials": "API credentials",
"octopushLegacyHint": "Do you use the legacy version of Octopush (2011-2020) or the new version?", "octopushLegacyHint": "Do you use the legacy version of Octopush (2011-2020) or the new version?",
"Check octopush prices": "Check octopush prices {0}.", "Check octopush prices": "Check octopush prices {0}.",
"octopushPhoneNumber": "Phone number (intl format, eg : +33612345678) ", "octopushPhoneNumber": "Phone number (intl format, eg : +33612345678)",
"octopushSMSSender": "SMS Sender Name : 3-11 alphanumeric characters and space (a-zA-Z0-9)", "octopushSMSSender": "SMS Sender Name : 3-11 alphanumeric characters and space (a-zA-Z0-9)",
"LunaSea Device ID": "LunaSea Device ID", "LunaSea Device ID": "LunaSea Device ID",
"Apprise URL": "Apprise URL", "Apprise URL": "Apprise URL",
@ -788,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",
@ -880,7 +905,8 @@
"From Name/Number": "From Name/Number", "From Name/Number": "From Name/Number",
"Leave blank to use a shared sender number.": "Leave blank to use a shared sender number.", "Leave blank to use a shared sender number.": "Leave blank to use a shared sender number.",
"Octopush API Version": "Octopush API Version", "Octopush API Version": "Octopush API Version",
"Legacy Octopush-DM": "Legacy Octopush-DM", "octopushEndpoint": "octopush (endpoint: {url})",
"legacyOctopushEndpoint": "Legacy Octopush-DM (endpoint: {url})",
"ntfy Topic": "ntfy Topic", "ntfy Topic": "ntfy Topic",
"Server URL should not contain the nfty topic": "Server URL should not contain the nfty topic", "Server URL should not contain the nfty topic": "Server URL should not contain the nfty topic",
"onebotHttpAddress": "OneBot HTTP Address", "onebotHttpAddress": "OneBot HTTP Address",
@ -1049,6 +1075,10 @@
"remoteBrowserToggle": "By default Chromium runs inside the Uptime Kuma container. You can use a remote browser by toggling this switch.", "remoteBrowserToggle": "By default Chromium runs inside the Uptime Kuma container. You can use a remote browser by toggling this switch.",
"useRemoteBrowser": "Use a Remote Browser", "useRemoteBrowser": "Use a Remote Browser",
"deleteRemoteBrowserMessage": "Are you sure want to delete this Remote Browser for all monitors?", "deleteRemoteBrowserMessage": "Are you sure want to delete this Remote Browser for all monitors?",
"Screenshot Delay": "Screenshot Delay (waits {milliseconds})",
"milliseconds": "{n} millisecond | {n} milliseconds",
"screenshotDelayDescription": "Optionally wait this many milliseconds before taking the screenshot. Maximum: {maxValueMs}ms (0.5 × interval).",
"screenshotDelayWarning": "Higher values keep the browser open longer, which may increase memory usage with many concurrent monitors.",
"GrafanaOncallUrl": "Grafana Oncall URL", "GrafanaOncallUrl": "Grafana Oncall URL",
"systemService": "System Service", "systemService": "System Service",
"systemServiceName": "Service Name", "systemServiceName": "Service Name",
@ -1099,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.",
@ -1259,6 +1290,8 @@
"Matomo": "Matomo", "Matomo": "Matomo",
"Umami": "Umami", "Umami": "Umami",
"Disable URL in Notification": "Disable URL in Notification", "Disable URL in Notification": "Disable URL in Notification",
"Suppress Notifications": "Suppress Notifications",
"discordSuppressNotificationsHelptext": "When enabled, messages will be posted to the channel but won't trigger push or desktop notifications for recipients.",
"Ip Family": "IP Family", "Ip Family": "IP Family",
"ipFamilyDescriptionAutoSelect": "Uses the {happyEyeballs} for determining the IP family.", "ipFamilyDescriptionAutoSelect": "Uses the {happyEyeballs} for determining the IP family.",
"Happy Eyeballs algorithm": "Happy Eyeballs algorithm", "Happy Eyeballs algorithm": "Happy Eyeballs algorithm",
@ -1289,9 +1322,8 @@
"domainExpiryDescription": "Trigger notification when domain names expires in:", "domainExpiryDescription": "Trigger notification when domain names expires in:",
"domain_expiry_unsupported_monitor_type": "Domain expiry monitoring is not supported for this monitor type", "domain_expiry_unsupported_monitor_type": "Domain expiry monitoring is not supported for this monitor type",
"domain_expiry_unsupported_missing_target": "No valid domain or hostname is configured for this monitor", "domain_expiry_unsupported_missing_target": "No valid domain or hostname is configured for this monitor",
"domain_expiry_unsupported_invalid_domain": "The configured value \"{hostname}\" is not a valid domain name",
"domain_expiry_public_suffix_too_short": "\".{publicSuffix}\" is too short for a top level domain", "domain_expiry_public_suffix_too_short": "\".{publicSuffix}\" is too short for a top level domain",
"domain_expiry_unsupported_public_suffix": "The domain \"{domain}\" does not have a valid public suffix", "domain_expiry_unsupported_is_icann": "The domain \"{domain}\" is not a candidate for domain expiry monitoring, because its public suffix \".{publicSuffix}\" is not ICAN",
"domain_expiry_unsupported_is_ip": "\"{hostname}\" is an IP address. Domain expiry monitoring requires a domain name", "domain_expiry_unsupported_is_ip": "\"{hostname}\" is an IP address. Domain expiry monitoring requires a domain name",
"domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint": "Domain expiry monitoring is not available for \".{publicSuffix}\" because no RDAP service is listed by IANA", "domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint": "Domain expiry monitoring is not available for \".{publicSuffix}\" because no RDAP service is listed by IANA",
"minimumIntervalWarning": "Intervals below 20 seconds may result in poor performance.", "minimumIntervalWarning": "Intervals below 20 seconds may result in poor performance.",
@ -1354,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"
} }

View File

@ -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" />

View File

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

View File

@ -194,7 +194,7 @@
class="col-12 col-sm col row d-flex align-items-center d-sm-block" class="col-12 col-sm col row d-flex align-items-center d-sm-block"
> >
<h4 class="col-4 col-sm-12">{{ pingTitle(true) }}</h4> <h4 class="col-4 col-sm-12">{{ pingTitle(true) }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">({{ 24 }} {{ $tc("hour", 24) }})</p> <p class="col-4 col-sm-12 mb-0 mb-sm-2">({{ $t("hours", 24) }})</p>
<span class="col-4 col-sm-12 num"> <span class="col-4 col-sm-12 num">
<CountUp :value="avgPing" /> <CountUp :value="avgPing" />
</span> </span>
@ -203,7 +203,7 @@
<!-- Uptime (24-hour) --> <!-- Uptime (24-hour) -->
<div class="col-12 col-sm col row d-flex align-items-center d-sm-block"> <div class="col-12 col-sm col row d-flex align-items-center d-sm-block">
<h4 class="col-4 col-sm-12">{{ $t("Uptime") }}</h4> <h4 class="col-4 col-sm-12">{{ $t("Uptime") }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">({{ 24 }} {{ $tc("hour", 24) }})</p> <p class="col-4 col-sm-12 mb-0 mb-sm-2">({{ $t("hours", 24) }})</p>
<span class="col-4 col-sm-12 num"> <span class="col-4 col-sm-12 num">
<Uptime :monitor="monitor" type="24" /> <Uptime :monitor="monitor" type="24" />
</span> </span>
@ -212,7 +212,7 @@
<!-- Uptime (30-day) --> <!-- Uptime (30-day) -->
<div class="col-12 col-sm col row d-flex align-items-center d-sm-block"> <div class="col-12 col-sm col row d-flex align-items-center d-sm-block">
<h4 class="col-4 col-sm-12">{{ $t("Uptime") }}</h4> <h4 class="col-4 col-sm-12">{{ $t("Uptime") }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">({{ 30 }} {{ $tc("day", 30) }})</p> <p class="col-4 col-sm-12 mb-0 mb-sm-2">({{ $t("days", 30) }})</p>
<span class="col-4 col-sm-12 num"> <span class="col-4 col-sm-12 num">
<Uptime :monitor="monitor" type="720" /> <Uptime :monitor="monitor" type="720" />
</span> </span>
@ -221,7 +221,7 @@
<!-- Uptime (1-year) --> <!-- Uptime (1-year) -->
<div class="col-12 col-sm col row d-flex align-items-center d-sm-block"> <div class="col-12 col-sm col row d-flex align-items-center d-sm-block">
<h4 class="col-4 col-sm-12">{{ $t("Uptime") }}</h4> <h4 class="col-4 col-sm-12">{{ $t("Uptime") }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">({{ 1 }} {{ $tc("year", 1) }})</p> <p class="col-4 col-sm-12 mb-0 mb-sm-2">({{ $t("years", 1) }})</p>
<span class="col-4 col-sm-12 num"> <span class="col-4 col-sm-12 num">
<Uptime :monitor="monitor" type="1y" /> <Uptime :monitor="monitor" type="1y" />
</span> </span>
@ -236,7 +236,7 @@
</p> </p>
<span class="col-4 col-sm-12 num"> <span class="col-4 col-sm-12 num">
<a href="#" @click.prevent="toggleCertInfoBox = !toggleCertInfoBox"> <a href="#" @click.prevent="toggleCertInfoBox = !toggleCertInfoBox">
{{ tlsInfo.certInfo.daysRemaining }} {{ $tc("day", tlsInfo.certInfo.daysRemaining) }} {{ $t("days", tlsInfo.certInfo.daysRemaining) }}
</a> </a>
<font-awesome-icon <font-awesome-icon
v-if="tlsInfo.hostnameMatchMonitorUrl === false" v-if="tlsInfo.hostnameMatchMonitorUrl === false"
@ -254,7 +254,7 @@
) )
</p> </p>
<span class="col-4 col-sm-12 num"> <span class="col-4 col-sm-12 num">
{{ domainInfo.daysRemaining }} {{ $tc("day", domainInfo.daysRemaining) }} {{ $t("days", domainInfo.daysRemaining) }}
</span> </span>
</div> </div>
</div> </div>
@ -374,7 +374,7 @@
type="checkbox" type="checkbox"
/> />
<label class="form-check-label" for="delete-children-checkbox"> <label class="form-check-label" for="delete-children-checkbox">
{{ $t("deleteChildrenMonitors", childrenCount, { count: childrenCount }) }} {{ $t("deleteChildrenMonitors", childrenCount) }}
</label> </label>
</div> </div>
</div> </div>

View File

@ -123,16 +123,13 @@
</select> </select>
</div> </div>
<!-- Single Maintenance Window -->
<template v-if="maintenance.strategy === 'single'"></template>
<template v-if="maintenance.strategy === 'cron'"> <template v-if="maintenance.strategy === 'cron'">
<!-- Cron --> <!-- Cron -->
<div class="my-3"> <div class="my-3">
<label for="cron" class="form-label"> <label for="cron" class="form-label">
{{ $t("cronExpression") }} {{ $t("cronExpression") }}
</label> </label>
<p>{{ $t("cronSchedule") }}{{ cronDescription }}</p> <p>{{ $t("cronScheduleDescription", { description: cronDescription }) }}</p>
<input <input
id="cron" id="cron"
v-model="maintenance.cron" v-model="maintenance.cron"
@ -167,7 +164,7 @@
<template v-if="maintenance.intervalDay >= 1"> <template v-if="maintenance.intervalDay >= 1">
({{ ({{
$tc("recurringIntervalMessage", maintenance.intervalDay, [ $t("recurringIntervalMessage", maintenance.intervalDay, [
maintenance.intervalDay, maintenance.intervalDay,
]) ])
}}) }})
@ -331,6 +328,102 @@
</div> </div>
</div> </div>
</template> </template>
<template v-if="maintenance.strategy === 'single'">
<div class="my-3">
<div class="d-flex gap-2 flex-wrap">
<button
type="button"
class="btn btn-sm"
:class="
currentDurationMinutes === 15 ? 'btn-primary' : 'btn-outline-primary'
"
:disabled="currentDurationMinutes === 15"
@click="setQuickDuration(15)"
>
{{ $t("minuteShort", 15) }}
</button>
<button
type="button"
class="btn btn-sm"
:class="
currentDurationMinutes === 30 ? 'btn-primary' : 'btn-outline-primary'
"
:disabled="currentDurationMinutes === 30"
@click="setQuickDuration(30)"
>
{{ $t("minuteShort", 30) }}
</button>
<button
type="button"
class="btn btn-sm"
:class="
currentDurationMinutes === 60 ? 'btn-primary' : 'btn-outline-primary'
"
:disabled="currentDurationMinutes === 60"
@click="setQuickDuration(60)"
>
{{ $t("hours", 1) }}
</button>
<button
type="button"
class="btn btn-sm"
:class="
currentDurationMinutes === 120 ? 'btn-primary' : 'btn-outline-primary'
"
:disabled="currentDurationMinutes === 120"
@click="setQuickDuration(120)"
>
{{ $t("hours", 2) }}
</button>
<button
type="button"
class="btn btn-sm"
:class="
currentDurationMinutes === 240 ? 'btn-primary' : 'btn-outline-primary'
"
:disabled="currentDurationMinutes === 240"
@click="setQuickDuration(240)"
>
{{ $t("hours", 4) }}
</button>
<button
type="button"
class="btn btn-sm"
:class="
currentDurationMinutes === 480 ? 'btn-primary' : 'btn-outline-primary'
"
:disabled="currentDurationMinutes === 480"
@click="setQuickDuration(480)"
>
{{ $t("hours", 8) }}
</button>
<button
type="button"
class="btn btn-sm"
:class="
currentDurationMinutes === 720 ? 'btn-primary' : 'btn-outline-primary'
"
:disabled="currentDurationMinutes === 720"
@click="setQuickDuration(720)"
>
{{ $t("hours", 12) }}
</button>
<button
type="button"
class="btn btn-sm"
:class="
currentDurationMinutes === 1440 ? 'btn-primary' : 'btn-outline-primary'
"
:disabled="currentDurationMinutes === 1440"
@click="setQuickDuration(1440)"
>
{{ $t("hours", 24) }}
</button>
</div>
<div class="form-text">{{ $t("Sets end time based on start time") }}</div>
</div>
</template>
</div> </div>
</div> </div>
@ -511,6 +604,22 @@ export default {
hasStatusPages() { hasStatusPages() {
return this.showOnAllPages || this.selectedStatusPages.length > 0; return this.showOnAllPages || this.selectedStatusPages.length > 0;
}, },
/**
* Calculate the current duration in minutes between start and end dates
* @returns {number|null} Duration in minutes, or null if dates are invalid
*/
currentDurationMinutes() {
if (!this.maintenance.dateRange?.[0] || !this.maintenance.dateRange?.[1]) {
return null;
}
const start = new Date(this.maintenance.dateRange[0]);
const end = new Date(this.maintenance.dateRange[1]);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return null;
}
return Math.round((end.getTime() - start.getTime()) / 60000);
},
}, },
watch: { watch: {
"$route.fullPath"() { "$route.fullPath"() {
@ -570,6 +679,19 @@ export default {
this.selectedStatusPages = []; this.selectedStatusPages = [];
if (this.isAdd) { if (this.isAdd) {
// Get current date/time in local timezone
const now = new Date();
const oneHourLater = new Date(now.getTime() + 60 * 60000);
const formatDateTime = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
this.maintenance = { this.maintenance = {
title: "", title: "",
description: "", description: "",
@ -578,7 +700,7 @@ export default {
cron: "30 3 * * *", cron: "30 3 * * *",
durationMinutes: 60, durationMinutes: 60,
intervalDay: 1, intervalDay: 1,
dateRange: [], dateRange: [formatDateTime(now), formatDateTime(oneHourLater)],
timeRange: [ timeRange: [
{ {
hours: 2, hours: 2,
@ -591,7 +713,7 @@ export default {
], ],
weekdays: [], weekdays: [],
daysOfMonth: [], daysOfMonth: [],
timezoneOption: null, timezoneOption: "SAME_AS_SERVER",
}; };
} else if (this.isEdit || this.isClone) { } else if (this.isEdit || this.isClone) {
this.$root.getSocket().emit("getMaintenance", this.$route.params.id, (res) => { this.$root.getSocket().emit("getMaintenance", this.$route.params.id, (res) => {
@ -655,6 +777,30 @@ export default {
} }
}, },
/**
* Set quick duration for single maintenance
* Calculates end time based on start time + duration in minutes
* @param {number} minutes Duration in minutes
* @returns {void}
*/
setQuickDuration(minutes) {
if (!this.maintenance.dateRange[0]) {
this.$root.toastError(this.$t("Please set start time first"));
return;
}
const startDate = new Date(this.maintenance.dateRange[0]);
const endDate = new Date(startDate.getTime() + minutes * 60000);
const year = endDate.getFullYear();
const month = String(endDate.getMonth() + 1).padStart(2, "0");
const day = String(endDate.getDate()).padStart(2, "0");
const hours = String(endDate.getHours()).padStart(2, "0");
const mins = String(endDate.getMinutes()).padStart(2, "0");
this.maintenance.dateRange[1] = `${year}-${month}-${day}T${hours}:${mins}`;
},
/** /**
* Handle form submission - show confirmation if no monitors selected * Handle form submission - show confirmation if no monitors selected
* @returns {void} * @returns {void}

View File

@ -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>
@ -1263,6 +1278,36 @@
<div class="form-text"></div> <div class="form-text"></div>
</div> </div>
<!-- Screenshot Delay - Real Browser only -->
<div v-if="monitor.type === 'real-browser'" class="my-3">
<label for="screenshot-delay" class="form-label">
{{
$t("Screenshot Delay", {
milliseconds: $t("milliseconds", monitor.screenshot_delay),
})
}}
</label>
<input
id="screenshot-delay"
v-model="monitor.screenshot_delay"
type="number"
class="form-control"
min="0"
:max="Math.floor(monitor.interval * 1000 * 0.5)"
step="100"
/>
<div class="form-text">
{{
$t("screenshotDelayDescription", {
maxValueMs: Math.floor(monitor.interval * 1000 * 0.5),
})
}}
</div>
<div v-if="monitor.screenshot_delay" class="form-text text-warning">
{{ $t("screenshotDelayWarning") }}
</div>
</div>
<div v-if="showDomainExpiryNotification" class="my-3 form-check"> <div v-if="showDomainExpiryNotification" class="my-3 form-check">
<input <input
id="domain-expiry-notification" id="domain-expiry-notification"
@ -2293,6 +2338,7 @@ const monitorDefaults = {
kafkaProducerAllowAutoTopicCreation: false, kafkaProducerAllowAutoTopicCreation: false,
gamedigGivenPortOnly: true, gamedigGivenPortOnly: true,
remote_browser: null, remote_browser: null,
screenshot_delay: 0,
rabbitmqNodes: [], rabbitmqNodes: [],
rabbitmqUsername: "", rabbitmqUsername: "",
rabbitmqPassword: "", rabbitmqPassword: "",
@ -2755,7 +2801,7 @@ message HealthCheckResponse {
this.monitor.jsonPath = "$"; this.monitor.jsonPath = "$";
} }
// Set default condition for for jsonPathOperator // Set default condition for jsonPathOperator
if (!this.monitor.jsonPathOperator) { if (!this.monitor.jsonPathOperator) {
this.monitor.jsonPathOperator = "=="; this.monitor.jsonPathOperator = "==";
} }

View File

@ -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");

View File

@ -25,9 +25,7 @@
class="form-control" class="form-control"
data-testid="description-input" data-testid="description-input"
></textarea> ></textarea>
<div class="form-text"> <div class="form-text">{{ $t("markdownSupported") }}</div>
{{ $t("markdownSupported") }}
</div>
</div> </div>
<!-- Footer Text --> <!-- Footer Text -->
@ -39,9 +37,7 @@
class="form-control" class="form-control"
data-testid="footer-text-input" data-testid="footer-text-input"
></textarea> ></textarea>
<div class="form-text"> <div class="form-text">{{ $t("markdownSupported") }}</div>
{{ $t("markdownSupported") }}
</div>
</div> </div>
<div class="my-3"> <div class="my-3">
@ -301,125 +297,83 @@
</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" <!-- Active Pinned Incidents -->
:noNL="true" <template v-for="activeIncident in activeIncidents" :key="activeIncident.id">
class="alert-heading" <!-- Edit mode for this specific incident -->
data-testid="incident-title" <IncidentEditForm
v-if="editIncidentMode && incident !== null && incident.id === activeIncident.id"
v-model="incident"
@post="postIncident"
@cancel="cancelIncident"
/> />
<strong v-if="editIncidentMode">{{ $t("Content") }}:</strong> <!-- Display mode for this incident -->
<Editable
v-if="editIncidentMode"
v-model="incident.content"
tag="div"
:contenteditable="editIncidentMode"
class="content"
data-testid="incident-content-editable"
/>
<div v-if="editIncidentMode" class="form-text">
{{ $t("markdownSupported") }}
</div>
<!-- eslint-disable vue/no-v-html-->
<div <div
v-if="!editIncidentMode" v-else
class="content" class="shadow-box alert mb-4 p-4 incident"
data-testid="incident-content" role="alert"
v-html="incidentHTML" :class="'bg-' + activeIncident.style"
></div> data-testid="incident"
<!-- eslint-enable vue/no-v-html--> >
<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 --> <!-- Incident Date -->
<div class="date mt-3"> <div class="date mt-3">
{{ $t("Date Created") }}: {{ $root.datetime(incident.createdDate) }} ({{ {{
dateFromNow(incident.createdDate) $t("dateCreatedAtFromNow", {
}}) date: $root.datetime(activeIncident.createdDate),
<br /> fromNow: dateFromNow(activeIncident.createdDate),
<span v-if="incident.lastUpdatedDate"> })
{{ $t("Last Updated") }}: {{ $root.datetime(incident.lastUpdatedDate) }} ({{ }}
dateFromNow(incident.lastUpdatedDate) <br />
}}) <span v-if="activeIncident.lastUpdatedDate">
</span> {{
</div> $t("lastUpdatedAtFromNow", {
date: $root.datetime(activeIncident.lastUpdatedDate),
<div v-if="editMode" class="mt-3"> fromNow: dateFromNow(activeIncident.lastUpdatedDate),
<button })
v-if="editIncidentMode" }}
class="btn btn-light me-2" </span>
data-testid="post-incident-button"
@click="postIncident"
>
<font-awesome-icon icon="bullhorn" />
{{ $t("Post") }}
</button>
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="editIncident">
<font-awesome-icon icon="edit" />
{{ $t("Edit") }}
</button>
<button v-if="editIncidentMode" class="btn btn-light me-2" @click="cancelIncident">
<font-awesome-icon icon="times" />
{{ $t("Cancel") }}
</button>
<div v-if="editIncidentMode" class="dropdown d-inline-block me-2">
<button
id="dropdownMenuButton1"
class="btn btn-secondary dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
{{ $t("Style") }}: {{ $t(incident.style) }}
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
<li>
<a class="dropdown-item" href="#" @click="incident.style = 'info'">{{ $t("info") }}</a>
</li>
<li>
<a class="dropdown-item" href="#" @click="incident.style = 'warning'">
{{ $t("warning") }}
</a>
</li>
<li>
<a class="dropdown-item" href="#" @click="incident.style = 'danger'">
{{ $t("danger") }}
</a>
</li>
<li>
<a class="dropdown-item" href="#" @click="incident.style = 'primary'">
{{ $t("primary") }}
</a>
</li>
<li>
<a class="dropdown-item" href="#" @click="incident.style = 'light'">
{{ $t("light") }}
</a>
</li>
<li>
<a class="dropdown-item" href="#" @click="incident.style = 'dark'">{{ $t("dark") }}</a>
</li>
</ul>
</div> </div>
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="unpinIncident"> <div v-if="editMode" class="mt-3">
<font-awesome-icon icon="unlink" /> <button class="btn btn-light me-2" @click="resolveIncident(activeIncident)">
{{ $t("Delete") }} <font-awesome-icon icon="check" />
</button> {{ $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>
</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">
@ -542,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>
@ -572,9 +576,9 @@
</p> </p>
<div class="refresh-info mb-2"> <div class="refresh-info mb-2">
<div>{{ $t("Last Updated") }}: {{ lastUpdateTimeDisplay }}</div> <div>{{ $t("lastUpdatedAt", { date: lastUpdateTimeDisplay }) }}</div>
<div data-testid="update-countdown-text"> <div data-testid="update-countdown-text">
{{ $tc("statusPageRefreshIn", [updateCountdownText]) }} {{ $t("statusPageRefreshIn", [updateCountdownText]) }}
</div> </div>
</div> </div>
</footer> </footer>
@ -615,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,
@ -648,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
@ -693,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: {
@ -818,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 "";
@ -844,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: {
/** /**
@ -946,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;
@ -970,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
@ -1242,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);
} }
@ -1250,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);
}, },
/** /**
@ -1281,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
@ -1311,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>
@ -1429,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;
@ -1443,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 {
@ -1584,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>

View File

@ -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;

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

View File

@ -49,5 +49,203 @@ describe(
await assert.rejects(postgresMonitor.check(monitor, heartbeat, {}), regex); await assert.rejects(postgresMonitor.check(monitor, heartbeat, {}), regex);
}); });
test("check() sets status to UP when custom query returns single value", async () => {
// The default timeout of 30 seconds might not be enough for the container to start
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
.withStartupTimeout(60000)
.start();
const postgresMonitor = new PostgresMonitorType();
const monitor = {
databaseConnectionString: postgresContainer.getConnectionUri(),
databaseQuery: "SELECT 42",
conditions: "[]",
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await postgresMonitor.check(monitor, heartbeat, {});
assert.strictEqual(heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}`);
} finally {
await postgresContainer.stop();
}
});
test("check() sets status to UP when custom query result meets condition", async () => {
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
.withStartupTimeout(60000)
.start();
const postgresMonitor = new PostgresMonitorType();
const monitor = {
databaseConnectionString: postgresContainer.getConnectionUri(),
databaseQuery: "SELECT 42 AS value",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "42",
},
]),
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await postgresMonitor.check(monitor, heartbeat, {});
assert.strictEqual(heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}`);
} finally {
await postgresContainer.stop();
}
});
test("check() rejects when custom query result does not meet condition", async () => {
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
.withStartupTimeout(60000)
.start();
const postgresMonitor = new PostgresMonitorType();
const monitor = {
databaseConnectionString: postgresContainer.getConnectionUri(),
databaseQuery: "SELECT 99 AS value",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "42",
},
]),
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await assert.rejects(
postgresMonitor.check(monitor, heartbeat, {}),
new Error("Query result did not meet the specified conditions (99)")
);
assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
} finally {
await postgresContainer.stop();
}
});
test("check() rejects when query returns no results with conditions", async () => {
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
.withStartupTimeout(60000)
.start();
const postgresMonitor = new PostgresMonitorType();
const monitor = {
databaseConnectionString: postgresContainer.getConnectionUri(),
databaseQuery: "SELECT 1 WHERE 1 = 0",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "1",
},
]),
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await assert.rejects(
postgresMonitor.check(monitor, heartbeat, {}),
new Error("Database connection/query failed: Query returned no results")
);
assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
} finally {
await postgresContainer.stop();
}
});
test("check() rejects when query returns multiple rows with conditions", async () => {
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
.withStartupTimeout(60000)
.start();
const postgresMonitor = new PostgresMonitorType();
const monitor = {
databaseConnectionString: postgresContainer.getConnectionUri(),
databaseQuery: "SELECT 1 UNION ALL SELECT 2",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "1",
},
]),
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await assert.rejects(
postgresMonitor.check(monitor, heartbeat, {}),
new Error("Database connection/query failed: Multiple values were found, expected only one value")
);
assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
} finally {
await postgresContainer.stop();
}
});
test("check() rejects when query returns multiple columns with conditions", async () => {
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
.withStartupTimeout(60000)
.start();
const postgresMonitor = new PostgresMonitorType();
const monitor = {
databaseConnectionString: postgresContainer.getConnectionUri(),
databaseQuery: "SELECT 1 AS col1, 2 AS col2",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "1",
},
]),
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await assert.rejects(
postgresMonitor.check(monitor, heartbeat, {}),
new Error("Database connection/query failed: Multiple columns were found, expected only one value")
);
assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
} finally {
await postgresContainer.stop();
}
});
} }
); );

View File

@ -96,58 +96,26 @@ describe("Domain Expiry", () => {
}); });
describe("Domain Parsing", () => { describe("Domain Parsing", () => {
test("throws error for invalid domain (no domain part)", async () => { test("throws error for IP address (isIp check)", async () => {
const monitor = { const monitor = {
type: "http", type: "http",
url: "https://", url: "https://127.0.0.1",
domainExpiryNotification: true, domainExpiryNotification: true,
}; };
await assert.rejects( await assert.rejects(
async () => await DomainExpiry.checkSupport(monitor), async () => await DomainExpiry.checkSupport(monitor),
(error) => { (error) => {
assert.strictEqual(error.constructor.name, "TranslatableError"); assert.strictEqual(error.constructor.name, "TranslatableError");
assert.strictEqual(error.message, "domain_expiry_unsupported_invalid_domain"); assert.strictEqual(error.message, "domain_expiry_unsupported_is_ip");
return true; return true;
} }
); );
}); });
test("throws error for IPv4 address instead of domain", async () => { test("throws error for too short suffix(example.a)", async () => {
const monitor = { const monitor = {
type: "http", type: "http",
url: "https://192.168.1.1", url: "https://example.a",
domainExpiryNotification: true,
};
await assert.rejects(
async () => await DomainExpiry.checkSupport(monitor),
(error) => {
assert.strictEqual(error.constructor.name, "TranslatableError");
assert.strictEqual(error.message, "domain_expiry_unsupported_invalid_domain");
return true;
}
);
});
test("throws error for IPv6 address", async () => {
const monitor = {
type: "http",
url: "https://[2001:db8::1]",
domainExpiryNotification: true,
};
await assert.rejects(
async () => await DomainExpiry.checkSupport(monitor),
(error) => {
assert.strictEqual(error.constructor.name, "TranslatableError");
assert.strictEqual(error.message, "domain_expiry_unsupported_invalid_domain");
return true;
}
);
});
test("throws error for single-letter TLD", async () => {
const monitor = {
type: "http",
url: "https://example.x",
domainExpiryNotification: true, domainExpiryNotification: true,
}; };
await assert.rejects( await assert.rejects(
@ -159,6 +127,22 @@ describe("Domain Expiry", () => {
} }
); );
}); });
test("throws error for non-ICANN TLD (e.g. .local)", async () => {
const monitor = {
type: "http",
url: "https://example.local",
domainExpiryNotification: true,
};
await assert.rejects(
async () => await DomainExpiry.checkSupport(monitor),
(error) => {
assert.strictEqual(error.constructor.name, "TranslatableError");
assert.strictEqual(error.message, "domain_expiry_unsupported_is_icann");
return true;
}
);
});
}); });
describe("Edge Cases & RDAP Support", () => { describe("Edge Cases & RDAP Support", () => {
@ -173,6 +157,17 @@ describe("Domain Expiry", () => {
assert.strictEqual(supportInfo.tld, "com"); assert.strictEqual(supportInfo.tld, "com");
}); });
test("supports multi-level public suffix via RDAP fallback (e.g. com.br)", async () => {
const monitor = {
type: "http",
url: "https://record.com.br",
domainExpiryNotification: true,
};
const supportInfo = await DomainExpiry.checkSupport(monitor);
assert.strictEqual(supportInfo.domain, "record.com.br");
assert.strictEqual(supportInfo.tld, "br");
});
test("handles complex subdomain correctly", async () => { test("handles complex subdomain correctly", async () => {
const monitor = { const monitor = {
type: "http", type: "http",
@ -205,22 +200,6 @@ describe("Domain Expiry", () => {
assert.strictEqual(supportInfo.domain, "example.com"); assert.strictEqual(supportInfo.domain, "example.com");
assert.strictEqual(supportInfo.tld, "com"); assert.strictEqual(supportInfo.tld, "com");
}); });
test("throws error for unsupported TLD without RDAP endpoint", async () => {
const monitor = {
type: "http",
url: "https://example.localhost",
domainExpiryNotification: true,
};
await assert.rejects(
async () => await DomainExpiry.checkSupport(monitor),
(error) => {
assert.strictEqual(error.constructor.name, "TranslatableError");
assert.strictEqual(error.message, "domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint");
return true;
}
);
});
}); });
}); });

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

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