Merge branch 'master' into refactor/filters

This commit is contained in:
Dorian Grasset 2026-01-16 12:39:01 +01:00
commit 4dc26182a4
36 changed files with 1029 additions and 268 deletions

View File

@ -50,7 +50,8 @@ jobs:
git push origin --delete "release-${VERSION}" || true
# Delete local branch if it exists
git branch -D "release-${VERSION}" || true
# Create new branch from master
# For testing purpose
# git checkout beta-workflow
git checkout -b "release-${VERSION}"
- 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",
filename: "serviceWorker.ts",
strategies: "injectManifest",
injectManifest: {
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, // 3 MiB
},
}),
],
css: {

View File

@ -1,3 +1,6 @@
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const pkg = require("../../package.json");
const fs = require("fs");
const childProcess = require("child_process");
@ -58,8 +61,13 @@ function commit(version) {
throw new Error("commit error");
}
// Note: Push is handled by gh pr create in the release script
// No need to push here as we're on a release branch, not master
// Get the current branch name
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";
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"];

View File

@ -48,7 +48,7 @@ checkDocker();
await checkTagExists(repoNames, version);
// node extra/beta/update-version.js
execSync("node ./extra/beta/update-version.js");
await import("../beta/update-version.mjs");
// Create Pull Request (gh pr create will handle pushing the branch)
await createReleasePR(version, previousVersion, dryRun, branchName, githubRunId);

309
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "uptime-kuma",
"version": "2.1.0-beta.1",
"version": "2.1.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "uptime-kuma",
"version": "2.1.0-beta.1",
"version": "2.1.0-beta.2",
"license": "MIT",
"dependencies": {
"@grpc/grpc-js": "~1.8.22",
@ -97,7 +97,7 @@
"@fortawesome/fontawesome-svg-core": "~1.2.36",
"@fortawesome/free-regular-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",
"@popperjs/core": "~2.10.2",
"@testcontainers/hivemq": "^10.13.1",
@ -132,8 +132,8 @@
"favico.js": "~0.3.10",
"get-port-please": "^3.1.1",
"node-ssh": "~13.1.0",
"postcss-html": "~1.5.0",
"postcss-rtlcss": "~3.7.2",
"postcss-html": "~1.8.1",
"postcss-rtlcss": "~5.7.1",
"postcss-scss": "~4.0.4",
"prettier": "^3.7.4",
"prismjs": "~1.30.0",
@ -151,11 +151,11 @@
"vite": "~5.4.15",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-pwa": "^1.1.0",
"vue": "~3.4.2",
"vue": "~3.5.26",
"vue-chartjs": "~5.2.0",
"vue-confirm-dialog": "~1.0.2",
"vue-contenteditable": "~3.0.4",
"vue-i18n": "~9.14.3",
"vue-i18n": "~11.2.8",
"vue-image-crop-upload": "~3.0.3",
"vue-multiselect": "~3.0.0-alpha.2",
"vue-prism-editor": "~2.0.0-alpha.2",
@ -3491,13 +3491,13 @@
}
},
"node_modules/@fortawesome/vue-fontawesome": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.8.tgz",
"integrity": "sha512-yyHHAj4G8pQIDfaIsMvQpwKMboIZtcHTUvPqXjOHyldh1O1vZfH4W03VDPv5RvI9P6DLTzJQlmVgj9wCf7c2Fw==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.1.3.tgz",
"integrity": "sha512-OHHUTLPEzdwP8kcYIzhioUdUOjZ4zzmi+midwa4bqscza4OJCOvTKJEHkXNz8PgZ23kWci1HkKVX0bm8f9t9gQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6",
"@fortawesome/fontawesome-svg-core": "~1 || ~6 || ~7",
"vue": ">= 3.0.0 < 4"
}
},
@ -3605,14 +3605,14 @@
"license": "BSD-3-Clause"
},
"node_modules/@intlify/core-base": {
"version": "9.14.5",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz",
"integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==",
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.8.tgz",
"integrity": "sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "9.14.5",
"@intlify/shared": "9.14.5"
"@intlify/message-compiler": "11.2.8",
"@intlify/shared": "11.2.8"
},
"engines": {
"node": ">= 16"
@ -3622,13 +3622,13 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "9.14.5",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz",
"integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==",
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.8.tgz",
"integrity": "sha512-A5n33doOjmHsBtCN421386cG1tWp5rpOjOYPNsnpjIJbQ4POF0QY2ezhZR9kr0boKwaHjbOifvyQvHj2UTrDFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@intlify/shared": "9.14.5",
"@intlify/shared": "11.2.8",
"source-map-js": "^1.0.2"
},
"engines": {
@ -3639,9 +3639,9 @@
}
},
"node_modules/@intlify/shared": {
"version": "9.14.5",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz",
"integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==",
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.8.tgz",
"integrity": "sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw==",
"dev": true,
"license": "MIT",
"engines": {
@ -6473,51 +6473,128 @@
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.38.tgz",
"integrity": "sha512-4vl4wMMVniLsSYYeldAKzbk72+D3hUnkw9z8lDeJacTxAkXeDAP1uE9xr2+aKIN0ipOL8EG2GPouVTH6yF7Gnw==",
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
"integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/shared": "3.4.38"
"@vue/shared": "3.5.26"
}
},
"node_modules/@vue/reactivity/node_modules/@vue/shared": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz",
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
"dev": true,
"license": "MIT"
},
"node_modules/@vue/runtime-core": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.38.tgz",
"integrity": "sha512-21z3wA99EABtuf+O3IhdxP0iHgkBs1vuoCAsCKLVJPEjpVqvblwBnTj42vzHRlWDCyxu9ptDm7sI2ZMcWrQqlA==",
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz",
"integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.4.38",
"@vue/shared": "3.4.38"
"@vue/reactivity": "3.5.26",
"@vue/shared": "3.5.26"
}
},
"node_modules/@vue/runtime-core/node_modules/@vue/shared": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz",
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
"dev": true,
"license": "MIT"
},
"node_modules/@vue/runtime-dom": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.38.tgz",
"integrity": "sha512-afZzmUreU7vKwKsV17H1NDThEEmdYI+GCAK/KY1U957Ig2NATPVjCROv61R19fjZNzMmiU03n79OMnXyJVN0UA==",
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz",
"integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.4.38",
"@vue/runtime-core": "3.4.38",
"@vue/shared": "3.4.38",
"csstype": "^3.1.3"
"@vue/reactivity": "3.5.26",
"@vue/runtime-core": "3.5.26",
"@vue/shared": "3.5.26",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/runtime-dom/node_modules/@vue/shared": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz",
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
"dev": true,
"license": "MIT"
},
"node_modules/@vue/server-renderer": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.38.tgz",
"integrity": "sha512-NggOTr82FbPEkkUvBm4fTGcwUY8UuTsnWC/L2YZBmvaQ4C4Jl/Ao4HHTB+l7WnFCt5M/dN3l0XLuyjzswGYVCA==",
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz",
"integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.4.38",
"@vue/shared": "3.4.38"
"@vue/compiler-ssr": "3.5.26",
"@vue/shared": "3.5.26"
},
"peerDependencies": {
"vue": "3.4.38"
"vue": "3.5.26"
}
},
"node_modules/@vue/server-renderer/node_modules/@vue/compiler-core": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
"integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/shared": "3.5.26",
"entities": "^7.0.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/server-renderer/node_modules/@vue/compiler-dom": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz",
"integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.26",
"@vue/shared": "3.5.26"
}
},
"node_modules/@vue/server-renderer/node_modules/@vue/compiler-ssr": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz",
"integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.26",
"@vue/shared": "3.5.26"
}
},
"node_modules/@vue/server-renderer/node_modules/@vue/shared": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz",
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
"dev": true,
"license": "MIT"
},
"node_modules/@vue/server-renderer/node_modules/entities": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
"integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@vue/shared": {
@ -12584,9 +12661,9 @@
}
},
"node_modules/js-tokens": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-8.0.3.tgz",
"integrity": "sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==",
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
@ -15089,15 +15166,15 @@
}
},
"node_modules/postcss-html": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/postcss-html/-/postcss-html-1.5.0.tgz",
"integrity": "sha512-kCMRWJRHKicpA166kc2lAVUGxDZL324bkj/pVOb6RhjB0Z5Krl7mN0AsVkBhVIRZZirY0lyQXG38HCVaoKVNoA==",
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/postcss-html/-/postcss-html-1.8.1.tgz",
"integrity": "sha512-OLF6P7qctfAWayOhLpcVnTGqVeJzu2W3WpIYelfz2+JV5oGxfkcEvweN9U4XpeqE0P98dcD9ssusGwlF0TK0uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"htmlparser2": "^8.0.0",
"js-tokens": "^8.0.0",
"postcss": "^8.4.0",
"js-tokens": "^9.0.0",
"postcss": "^8.5.0",
"postcss-safe-parser": "^6.0.0"
},
"engines": {
@ -15132,19 +15209,19 @@
"license": "MIT"
},
"node_modules/postcss-rtlcss": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/postcss-rtlcss/-/postcss-rtlcss-3.7.2.tgz",
"integrity": "sha512-GurrGedCKvOTe1QrifI+XpDKXA3bJky1v8KiOa/TYYHs1bfJOxI53GIRvVSqLJLly7e1WcNMz8KMESTN01vbZQ==",
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/postcss-rtlcss/-/postcss-rtlcss-5.7.1.tgz",
"integrity": "sha512-zE68CuARv5StOG/UQLa0W1Y/raUTzgJlfjtas43yh3/G1BFmoPEaHxPRHgeowXRFFhW33FehrNgsljxRLmPVWw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"rtlcss": "^3.5.0"
"rtlcss": "4.3.0"
},
"engines": {
"node": ">=12.0.0"
"node": ">=18.0.0"
},
"peerDependencies": {
"postcss": "^8.0.0"
"postcss": "^8.4.21"
}
},
"node_modules/postcss-safe-parser": {
@ -16431,19 +16508,22 @@
}
},
"node_modules/rtlcss": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-3.5.0.tgz",
"integrity": "sha512-wzgMaMFHQTnyi9YOwsx9LjOxYXJPzS8sYnFaKm6R5ysvTkwzHiB0vxnbHwchHQT65PTdBjDG21/kQBWI7q9O7A==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz",
"integrity": "sha512-FI+pHEn7Wc4NqKXMXFM+VAYKEj/mRIcW4h24YVwVtyjI+EqGrLc2Hx/Ny0lrZ21cBWU2goLy36eqMcNj3AQJig==",
"dev": true,
"license": "MIT",
"dependencies": {
"find-up": "^5.0.0",
"escalade": "^3.1.1",
"picocolors": "^1.0.0",
"postcss": "^8.3.11",
"postcss": "^8.4.21",
"strip-json-comments": "^3.1.1"
},
"bin": {
"rtlcss": "bin/rtlcss.js"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/run-applescript": {
@ -18943,17 +19023,17 @@
}
},
"node_modules/vue": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.38.tgz",
"integrity": "sha512-f0ZgN+mZ5KFgVv9wz0f4OgVKukoXtS3nwET4c2vLBGQR50aI8G0cqbFtLlX9Yiyg3LFGBitruPHt2PxwTduJEw==",
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.4.38",
"@vue/compiler-sfc": "3.4.38",
"@vue/runtime-dom": "3.4.38",
"@vue/server-renderer": "3.4.38",
"@vue/shared": "3.4.38"
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-sfc": "3.5.26",
"@vue/runtime-dom": "3.5.26",
"@vue/server-renderer": "3.5.26",
"@vue/shared": "3.5.26"
},
"peerDependencies": {
"typescript": "*"
@ -19048,15 +19128,14 @@
}
},
"node_modules/vue-i18n": {
"version": "9.14.5",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz",
"integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==",
"deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html",
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz",
"integrity": "sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@intlify/core-base": "9.14.5",
"@intlify/shared": "9.14.5",
"@intlify/core-base": "11.2.8",
"@intlify/shared": "11.2.8",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
@ -19150,6 +19229,80 @@
"vue": "^3.0.2"
}
},
"node_modules/vue/node_modules/@vue/compiler-core": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
"integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/shared": "3.5.26",
"entities": "^7.0.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/vue/node_modules/@vue/compiler-dom": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz",
"integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.26",
"@vue/shared": "3.5.26"
}
},
"node_modules/vue/node_modules/@vue/compiler-sfc": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.26",
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-ssr": "3.5.26",
"@vue/shared": "3.5.26",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
}
},
"node_modules/vue/node_modules/@vue/compiler-ssr": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz",
"integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.26",
"@vue/shared": "3.5.26"
}
},
"node_modules/vue/node_modules/@vue/shared": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz",
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
"dev": true,
"license": "MIT"
},
"node_modules/vue/node_modules/entities": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
"integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",

View File

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

View File

@ -159,31 +159,22 @@ class DomainExpiry extends BeanModel {
const tld = parseTld(target);
// 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) {
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.
if (tld.publicSuffix.length < 2) {
throw new TranslatableError("domain_expiry_public_suffix_too_short", { publicSuffix: tld.publicSuffix });
}
if (!tld.isIcann) {
throw new TranslatableError("domain_expiry_unsupported_is_icann", {
domain: tld.domain,
publicSuffix: tld.publicSuffix,
});
}
const rdap = await getRdapServer(tld.publicSuffix);
if (!rdap) {
// 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,
});

View File

@ -1059,7 +1059,15 @@ class Monitor extends BeanModel {
log.debug("monitor", `Failed getting expiration date for domain ${supportInfo.domain}`);
}
} 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.`
);
}
}
}

View File

@ -3,26 +3,61 @@ const { log, UP } = require("../../src/util");
const dayjs = require("dayjs");
const postgresConParse = require("pg-connection-string").parse;
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 {
name = "postgres";
supportsConditions = true;
conditionVariables = [new ConditionVariable("result", defaultStringOperators)];
/**
* @inheritdoc
*/
async check(monitor, heartbeat, _server) {
let startTime = dayjs().valueOf();
let query = monitor.databaseQuery;
// No query provided by user, use SELECT 1
if (!query || (typeof query === "string" && query.trim() === "")) {
query = "SELECT 1";
}
await this.postgresQuery(monitor.databaseConnectionString, query);
heartbeat.msg = "";
heartbeat.status = UP;
heartbeat.ping = dayjs().valueOf() - startTime;
const conditions = monitor.conditions ? ConditionExpressionGroup.fromMonitor(monitor) : null;
const hasConditions = conditions && conditions.children && conditions.children.length > 0;
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 = {

View File

@ -11,6 +11,11 @@ class Discord extends NotificationProvider {
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
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 {
let config = this.getAxiosConfigWithProxy({});
const discordDisplayName = notification.discordUsername || "Uptime Kuma";
@ -41,6 +46,9 @@ class Discord extends NotificationProvider {
if (notification.discordChannelType === "createNewForumPost") {
discordtestdata.thread_name = notification.postName;
}
if (notification.discordSuppressNotifications) {
discordtestdata.flags = SUPPRESS_NOTIFICATIONS_FLAG;
}
await axios.post(webhookUrl.toString(), discordtestdata, config);
return okMsg;
}
@ -89,6 +97,9 @@ class Discord extends NotificationProvider {
if (notification.discordPrefixMessage) {
discorddowndata.content = notification.discordPrefixMessage;
}
if (notification.discordSuppressNotifications) {
discorddowndata.flags = SUPPRESS_NOTIFICATIONS_FLAG;
}
await axios.post(webhookUrl.toString(), discorddowndata, config);
return okMsg;
@ -140,6 +151,9 @@ class Discord extends NotificationProvider {
if (notification.discordPrefixMessage) {
discordupdata.content = notification.discordPrefixMessage;
}
if (notification.discordSuppressNotifications) {
discordupdata.flags = SUPPRESS_NOTIFICATIONS_FLAG;
}
await axios.post(webhookUrl.toString(), discordupdata, config);
return okMsg;

View File

@ -1,7 +1,7 @@
<template>
<div>
<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">
{{ $t("Valid") }}
</div>

View File

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

View File

@ -358,7 +358,7 @@ export default {
this.bulkActionInProgress = true;
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.cancelSelectMode();
},
@ -382,7 +382,7 @@ export default {
this.bulkActionInProgress = true;
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.cancelSelectMode();
},
@ -422,10 +422,10 @@ export default {
this.bulkActionInProgress = false;
if (successCount > 0) {
this.$root.toastSuccess(this.$tc("deletedMonitorsMsg", successCount));
this.$root.toastSuccess(this.$t("deletedMonitorsMsg", successCount));
}
if (errorCount > 0) {
this.$root.toastError(this.$tc("bulkDeleteErrorMsg", errorCount));
this.$root.toastError(this.$t("bulkDeleteErrorMsg", errorCount));
}
this.cancelSelectMode();
@ -539,7 +539,7 @@ export default {
padding: 10px;
display: flex;
flex-direction: column;
gap: 4px;
gap: 8px;
.dark & {
background-color: $dark-header-bg;
@ -627,6 +627,13 @@ export default {
}
}
.selection-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.selected-count {
white-space: nowrap;
font-size: 0.9em;

View File

@ -59,23 +59,14 @@
</div>
</div>
</li>
</template>
</MonitorListFilterDropdown>
<MonitorListFilterDropdown :filterActive="filterState.active?.length > 0">
<template #status>
<span v-if="filterState.active?.length === 1">
<span v-if="filterState.active[0]">{{ $t("Running") }}</span>
<span v-else>{{ $t("filterActivePaused") }}</span>
</span>
<span v-else>
{{ $t("filterActive") }}
</span>
</template>
<template #dropdown>
<li><hr class="dropdown-divider"></li>
<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="badge status-pill running">
<font-awesome-icon icon="play" class="icon-small" />
{{ $t("Running") }}
</span>
<span class="ps-3">
{{ $root.stats.active }}
<span v-if="filterState.active?.includes(true)" class="px-1 filter-active">
@ -88,7 +79,10 @@
<li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(false)">
<div class="d-flex align-items-center justify-content-between">
<span>{{ $t("filterActivePaused") }}</span>
<span class="badge status-pill paused">
<font-awesome-icon icon="pause" class="icon-small" />
{{ $t("filterActivePaused") }}
</span>
<span class="ps-3">
{{ $root.stats.pause }}
<span v-if="filterState.active?.includes(false)" class="px-1 filter-active">
@ -284,4 +278,37 @@ 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;
}
}
}
</style>

View File

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

View File

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

View File

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

View File

@ -85,12 +85,12 @@ export default {
title() {
if (this.type === "1y") {
return `1 ${this.$tc("year", 1)}`;
return this.$t("years", 1);
}
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>
</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>
<script>
export default {
@ -112,6 +130,9 @@ export default {
if (this.$parent.notification.disableUrl === undefined) {
this.$parent.notification.disableUrl = false;
}
if (this.$parent.notification.discordSuppressNotifications === undefined) {
this.$parent.notification.discordSuppressNotifications = false;
}
},
};
</script>

View File

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

View File

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

View File

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

View File

@ -64,7 +64,7 @@
:key="day"
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
type="button"
class="btn-rm-expiry btn btn-outline-danger ms-2 py-1"
@ -79,7 +79,7 @@
<ActionInput
v-model="tlsExpiryNotifInput"
:type="'number'"
:placeholder="$t('day')"
:placeholder="$t('days', 1)"
:icon="'plus'"
:action="() => addTlsExpiryNotifDay(tlsExpiryNotifInput)"
:action-aria-label="$t('Add a new expiry notification day')"
@ -102,7 +102,7 @@
:key="day"
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
type="button"
class="btn-rm-expiry btn btn-outline-danger ms-2 py-1"
@ -117,7 +117,7 @@
<ActionInput
v-model="domainExpiryNotifInput"
:type="'number'"
:placeholder="$t('day')"
:placeholder="$t('days', 1)"
:icon="'plus'"
:action="() => addDomainExpiryNotifDay(domainExpiryNotifInput)"
:action-aria-label="$t('Add a new expiry notification day')"

View File

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

View File

@ -19,7 +19,7 @@
<Tag :item="tag" />
</div>
<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 class="col-2 pe-2 pe-lg-3 d-flex justify-content-end">
<button

View File

@ -20,7 +20,7 @@
"General": "General",
"Game": "Game",
"Primary Base URL": "Primary Base URL",
"Version": "Version",
"versionIs": "Version: {version}",
"Check Update On GitHub": "Check Update On GitHub",
"List": "List",
"Home": "Home",
@ -33,12 +33,13 @@
"statusMaintenance": "Maintenance",
"Maintenance": "Maintenance",
"Unknown": "Unknown",
"unknownDays": "Unknown days",
"Cannot connect to the socket server": "Cannot connect to the socket server",
"Reconnecting...": "Reconnecting...",
"General Monitor Type": "General Monitor Type",
"Passive Monitor Type": "Passive 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",
"Pause": "Pause",
"Name": "Name",
@ -52,12 +53,14 @@
"Current": "Current",
"Uptime": "Uptime",
"Cert Exp.": "Cert Exp.",
"Monitor": "Monitor | Monitors",
"Monitors": "{n} Monitor | {n} Monitors",
"now": "now",
"time ago": "{0} ago",
"day": "day | days",
"hour": "hour | hours",
"year": "year | years",
"days": "{n} day | {n} days",
"hours": "{n} hour | {n} hours",
"minutes": "{n} minute | {n} minutes",
"minuteShort": "{n} min | {n} min",
"years": "{n} year | {n} years",
"Response": "Response",
"Ping": "Ping",
"Monitor Type": "Monitor Type",
@ -143,6 +146,7 @@
"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!",
"Logout": "Log out",
"logoutCurrentUser": "Log out {username}",
"Leave": "Leave",
"I understand, please disable": "I understand, please disable",
"Confirm": "Confirm",
@ -278,7 +282,6 @@
"records": "records",
"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}",
"Current User": "Current User",
"topic": "Topic",
"topicExplanation": "MQTT topic to monitor",
"mqttWebSocketPath": "MQTT WebSocket Path",
@ -318,8 +321,9 @@
"dark": "dark",
"Post": "Post",
"Please input title and content": "Please input title and content",
"Created": "Created",
"Last Updated": "Last Updated",
"createdAt": "Created: {date}",
"lastUpdatedAt": "Last Updated: {date}",
"lastUpdatedAtFromNow": "Last Updated: {date} ({fromNow})",
"Switch to Light Theme": "Switch to Light Theme",
"Switch to Dark Theme": "Switch to Dark Theme",
"Show Tags": "Show Tags",
@ -354,7 +358,7 @@
"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.",
"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",
"Invalid": "Invalid",
"User": "User",
@ -401,7 +405,7 @@
"Add a new expiry notification day": "Add a new expiry notification day",
"Remove the expiry notification": "Remove the expiry notification day",
"Proxy": "Proxy",
"Date Created": "Date Created",
"dateCreatedAtFromNow": "Date Created: {date} ({fromNow})",
"Footer Text": "Footer Text",
"RSS Title": "RSS Title",
"Leave blank to use status page title": "Leave blank to use status page title",
@ -475,13 +479,13 @@
"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.",
"wayToGetLineNotifyToken": "You can get an access token from {0}",
"Examples": "Examples",
"Examples:": "Examples: {0}",
"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:",
"wayToGetBaleToken": "You can get a token from {0}.",
"Home Assistant URL": "Home Assistant URL",
"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",
"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.",
@ -490,7 +494,7 @@
"Event type:": "Event type:",
"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.",
"Frontend Version": "Frontend Version",
"frontendVersionIs": "Frontend Version: {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.",
"backupRecommend": "Please backup the volume or the data folder (./data/) directly instead.",
@ -501,7 +505,7 @@
"startDateTime": "Start Date/Time",
"endDateTime": "End Date/Time",
"cronExpression": "Cron Expression",
"cronSchedule": "Schedule: ",
"cronScheduleDescription": "Schedule: {description}",
"Duration (Minutes)": "Duration (Minutes)",
"invalidCronExpression": "Invalid Cron Expression: {0}",
"recurringInterval": "Interval",
@ -668,6 +672,8 @@
"recurringIntervalMessage": "Run once every day | Run once every {0} days",
"affectedMonitorsDescription": "Select monitors that are affected by current maintenance",
"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?",
"noMonitorsOrStatusPagesSelectedError": "Cannot create maintenance without affected monitors or status pages",
"passwordNotMatchMsg": "The repeat password does not match.",
@ -678,7 +684,6 @@
"backupDescription": "You can backup all monitors and notifications into a JSON file.",
"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.",
"endpoint": "endpoint",
"octopushAPIKey": "\"API key\" from HTTP API credentials in control panel",
"octopushLogin": "\"Login\" from HTTP API credentials in control panel",
"promosmsLogin": "API Login Name",
@ -730,7 +735,7 @@
"apiCredentials": "API credentials",
"octopushLegacyHint": "Do you use the legacy version of Octopush (2011-2020) or the new version?",
"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)",
"LunaSea Device ID": "LunaSea Device ID",
"Apprise URL": "Apprise URL",
@ -880,7 +885,8 @@
"From Name/Number": "From Name/Number",
"Leave blank to use a shared sender number.": "Leave blank to use a shared sender number.",
"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",
"Server URL should not contain the nfty topic": "Server URL should not contain the nfty topic",
"onebotHttpAddress": "OneBot HTTP Address",
@ -1259,6 +1265,8 @@
"Matomo": "Matomo",
"Umami": "Umami",
"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",
"ipFamilyDescriptionAutoSelect": "Uses the {happyEyeballs} for determining the IP family.",
"Happy Eyeballs algorithm": "Happy Eyeballs algorithm",
@ -1289,9 +1297,8 @@
"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_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_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_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.",

View File

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

View File

@ -123,16 +123,13 @@
</select>
</div>
<!-- Single Maintenance Window -->
<template v-if="maintenance.strategy === 'single'"></template>
<template v-if="maintenance.strategy === 'cron'">
<!-- Cron -->
<div class="my-3">
<label for="cron" class="form-label">
{{ $t("cronExpression") }}
</label>
<p>{{ $t("cronSchedule") }}{{ cronDescription }}</p>
<p>{{ $t("cronScheduleDescription", { description: cronDescription }) }}</p>
<input
id="cron"
v-model="maintenance.cron"
@ -167,7 +164,7 @@
<template v-if="maintenance.intervalDay >= 1">
({{
$tc("recurringIntervalMessage", maintenance.intervalDay, [
$t("recurringIntervalMessage", maintenance.intervalDay, [
maintenance.intervalDay,
])
}})
@ -331,6 +328,102 @@
</div>
</div>
</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>
@ -511,6 +604,22 @@ export default {
hasStatusPages() {
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: {
"$route.fullPath"() {
@ -570,6 +679,19 @@ export default {
this.selectedStatusPages = [];
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 = {
title: "",
description: "",
@ -578,7 +700,7 @@ export default {
cron: "30 3 * * *",
durationMinutes: 60,
intervalDay: 1,
dateRange: [],
dateRange: [formatDateTime(now), formatDateTime(oneHourLater)],
timeRange: [
{
hours: 2,
@ -591,7 +713,7 @@ export default {
],
weekdays: [],
daysOfMonth: [],
timezoneOption: null,
timezoneOption: "SAME_AS_SERVER",
};
} else if (this.isEdit || this.isClone) {
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
* @returns {void}

View File

@ -25,9 +25,7 @@
class="form-control"
data-testid="description-input"
></textarea>
<div class="form-text">
{{ $t("markdownSupported") }}
</div>
<div class="form-text">{{ $t("markdownSupported") }}</div>
</div>
<!-- Footer Text -->
@ -39,9 +37,7 @@
class="form-control"
data-testid="footer-text-input"
></textarea>
<div class="form-text">
{{ $t("markdownSupported") }}
</div>
<div class="form-text">{{ $t("markdownSupported") }}</div>
</div>
<div class="my-3">
@ -342,14 +338,20 @@
<!-- Incident Date -->
<div class="date mt-3">
{{ $t("Date Created") }}: {{ $root.datetime(incident.createdDate) }} ({{
dateFromNow(incident.createdDate)
}})
{{
$t("dateCreatedAtFromNow", {
date: $root.datetime(incident.createdDate),
fromNow: dateFromNow(incident.createdDate),
})
}}
<br />
<span v-if="incident.lastUpdatedDate">
{{ $t("Last Updated") }}: {{ $root.datetime(incident.lastUpdatedDate) }} ({{
dateFromNow(incident.lastUpdatedDate)
}})
{{
$t("lastUpdatedAtFromNow", {
date: $root.datetime(incident.lastUpdatedDate),
fromNow: dateFromNow(incident.lastUpdatedDate),
})
}}
</span>
</div>
@ -572,9 +574,9 @@
</p>
<div class="refresh-info mb-2">
<div>{{ $t("Last Updated") }}: {{ lastUpdateTimeDisplay }}</div>
<div>{{ $t("lastUpdatedAt", { date: lastUpdateTimeDisplay }) }}</div>
<div data-testid="update-countdown-text">
{{ $tc("statusPageRefreshIn", [updateCountdownText]) }}
{{ $t("statusPageRefreshIn", [updateCountdownText]) }}
</div>
</div>
</footer>

View File

@ -49,5 +49,203 @@ describe(
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", () => {
test("throws error for invalid domain (no domain part)", async () => {
test("throws error for IP address (isIp check)", async () => {
const monitor = {
type: "http",
url: "https://",
url: "https://127.0.0.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");
assert.strictEqual(error.message, "domain_expiry_unsupported_is_ip");
return true;
}
);
});
test("throws error for IPv4 address instead of domain", async () => {
test("throws error for too short suffix(example.a)", async () => {
const monitor = {
type: "http",
url: "https://192.168.1.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 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",
url: "https://example.a",
domainExpiryNotification: true,
};
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", () => {
@ -205,22 +189,6 @@ describe("Domain Expiry", () => {
assert.strictEqual(supportInfo.domain, "example.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;
}
);
});
});
});