Merge branch 'master' into fix/database-down-notification

This commit is contained in:
aka James4u 2026-01-14 19:09:44 -08:00 committed by GitHub
commit 818841c94c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
70 changed files with 3336 additions and 1297 deletions

View File

@ -1,36 +1,31 @@
<sub> To keep reviews fast and effective, please make sure youve [read our pull request guidelines](https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma)</sub>
# Summary
## 📝 Summary of changes done and why they are done
In this pull request, the following changes are made:
<!-- Provide a clear summary of the purpose and scope of this pull request-->
## 📋 Related issues
- Foobar was changed to FooFoo, because ...
<!--Please link any GitHub issues or tasks that this pull request addresses-->
- Relates to #issue-number <!--this links related the issue-->
- Resolves #issue-number <!--this auto-closes the issue-->
## 📄 Checklist
<details>
<summary>Please follow this checklist to avoid unnecessary back and forth (click to expand)</summary>
- [ ] ⚠️ If there are Breaking change (a fix or feature that alters existing functionality in a way that could cause issues) I have called them out
- [ ] 🧠 I have disclosed any use of LLMs/AI in this contribution and reviewed all generated content.
I understand that I am responsible for and able to explain every line of code I submit.
- [ ] 🔍 My code adheres to the style guidelines of this project.
- [ ] ⚠️ My changes generate no new warnings.
- [ ] 🛠️ I have reviewed and tested my code.
- [ ] 🔍 Any UI changes adhere to visual style of this project.
- [ ] 🛠️ I have self-reviewed and self-tested my code to ensure it works as expected.
- [ ] 📝 I have commented my code, especially in hard-to-understand areas (e.g., using JSDoc for methods).
- [ ] 🤖 I added or updated automated tests where appropriate.
- [ ] 📄 Documentation updates are included (if applicable).
- [ ] 🔒 I have considered potential security impacts and mitigated risks.
- [ ] 🧰 Dependency updates are listed and explained.
- [ ] ⚠️ CI passes and is green.
</details>
## 📷 Screenshots or Visual Changes
## Screenshots for Visual Changes
<!--
If this pull request introduces visual changes, please provide the following details.

View File

@ -1,6 +1,3 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Auto Test
on:
@ -12,7 +9,6 @@ permissions: {}
jobs:
auto-test:
runs-on: ${{ matrix.os }}
timeout-minutes: 15
permissions:
contents: read

View File

@ -42,4 +42,8 @@ jobs:
run: npm run fmt
continue-on-error: true
- name: Compile TypeScript
run: npm run tsc
continue-on-error: true
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27

93
.github/workflows/beta-release.yml vendored Normal file
View File

@ -0,0 +1,93 @@
name: Beta Release
on:
workflow_dispatch:
inputs:
version:
description: "Beta version number (e.g., 2.1.0-beta.2)"
required: true
type: string
previous_version:
description: "Previous version tag for changelog (e.g., 2.1.0-beta.1)"
required: true
type: string
dry_run:
description: "Dry Run (The docker image will not be pushed to registries. PR will still be created.)"
required: false
type: boolean
default: false
permissions:
contents: write
pull-requests: write
jobs:
beta-release:
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: master
persist-credentials: true
fetch-depth: 0 # Fetch all history for changelog generation
- name: Set up Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: 24
- name: Create release branch
env:
VERSION: ${{ inputs.version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "github-actions[bot]"
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
git push origin --delete "release-${VERSION}" || true
# Delete local branch if it exists
git branch -D "release-${VERSION}" || true
# For testing purpose
# git checkout beta-workflow
git checkout -b "release-${VERSION}"
- name: Install dependencies
run: npm clean-install --no-fund
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- 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: Run release-beta
env:
RELEASE_BETA_VERSION: ${{ inputs.version }}
RELEASE_PREVIOUS_VERSION: ${{ inputs.previous_version }}
DRY_RUN: ${{ inputs.dry_run }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: npm run release-beta
- name: Upload dist.tar.gz as artifact
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: dist-${{ inputs.version }}
path: ./tmp/dist.tar.gz
retention-days: 90

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

@ -0,0 +1,40 @@
name: New contributor message
on:
# Safety
# This workflow uses pull_request_target so it can run with write permissions on first-time contributor PRs.
# It is safe because it does not check out or execute any code from the pull request and
# only uses the pinned, trusted plbstl/first-contribution action
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, closed]
branches:
- master
permissions:
pull-requests: write
jobs:
build:
if: github.repository == 'louislam/uptime-kuma'
name: Hello new contributor
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: plbstl/first-contribution@4b2b042fffa26792504a18e49aa9543a87bec077 # v4.1.0
with:
pr-reactions: rocket
pr-opened-msg: >
Hello and thanks for lending a paw to Uptime Kuma! 🐻👋
As this is your first contribution, please be sure to check out our [Pull Request guidelines](https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma).
In particular:
- Mark your PR as Draft while youre still making changes
- Mark it as Ready for review once its fully ready
If you have any design or process questions, feel free to ask them right here in this pull request - unclear documentation is a bug too.
pr-merged-msg: >
@{fc-author} congrats on your first contribution to Uptime Kuma! 🐻
We hope you enjoy contributing to our project and look forward to seeing more of your work in the future!
If you want to see your contribution in action, please see our [nightly builds here](https://hub.docker.com/layers/louislam/uptime-kuma/nightly2).

58
.github/workflows/nightly-release.yml vendored Normal file
View File

@ -0,0 +1,58 @@
name: Nightly Release
on:
schedule:
# Runs at 2:00 AM UTC every day
- cron: "0 2 * * *"
workflow_dispatch: # Allow manual trigger
permissions: {}
jobs:
release-nightly:
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: Cache/Restore node_modules
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
id: node-modules-cache
with:
path: node_modules
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
run: npm clean-install --no-fund
- name: Run release-nightly
run: npm run release-nightly

View File

@ -175,8 +175,8 @@ You can mention me if you ask a question on the subreddit.
### Create Pull Requests
We DO NOT accept all types of pull requests and do not want to waste your time. Please be sure that you have read and follow pull request rules:
[CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma](https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma)
Pull requests are awesome.
To keep reviews fast and effective, please make sure youve [read our pull request guidelines](https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma).
### Test Pull Requests

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

@ -0,0 +1,15 @@
exports.up = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.boolean("save_response").notNullable().defaultTo(false);
table.boolean("save_error_response").notNullable().defaultTo(true);
table.integer("response_max_length").notNullable().defaultTo(1024); // Default 1KB
});
};
exports.down = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.dropColumn("save_response");
table.dropColumn("save_error_response");
table.dropColumn("response_max_length");
});
};

View File

@ -0,0 +1,11 @@
exports.up = function (knex) {
return knex.schema.alterTable("heartbeat", function (table) {
table.text("response").nullable().defaultTo(null);
});
};
exports.down = function (knex) {
return knex.schema.alterTable("heartbeat", function (table) {
table.dropColumn("response");
});
};

View File

@ -0,0 +1,43 @@
exports.up = function (knex) {
return knex.schema
.alterTable("heartbeat", function (table) {
table.bigInteger("ping").alter();
})
.alterTable("stat_minutely", function (table) {
table.float("ping", 20, 2).notNullable().alter();
table.float("ping_min", 20, 2).notNullable().defaultTo(0).alter();
table.float("ping_max", 20, 2).notNullable().defaultTo(0).alter();
})
.alterTable("stat_daily", function (table) {
table.float("ping", 20, 2).notNullable().alter();
table.float("ping_min", 20, 2).notNullable().defaultTo(0).alter();
table.float("ping_max", 20, 2).notNullable().defaultTo(0).alter();
})
.alterTable("stat_hourly", function (table) {
table.float("ping", 20, 2).notNullable().alter();
table.float("ping_min", 20, 2).notNullable().defaultTo(0).alter();
table.float("ping_max", 20, 2).notNullable().defaultTo(0).alter();
});
};
exports.down = function (knex) {
return knex.schema
.alterTable("heartbeat", function (table) {
table.integer("ping").alter();
})
.alterTable("stat_minutely", function (table) {
table.float("ping").notNullable().alter();
table.float("ping_min").notNullable().defaultTo(0).alter();
table.float("ping_max").notNullable().defaultTo(0).alter();
})
.alterTable("stat_daily", function (table) {
table.float("ping").notNullable().alter();
table.float("ping_min").notNullable().defaultTo(0).alter();
table.float("ping_max").notNullable().defaultTo(0).alter();
})
.alterTable("stat_hourly", function (table) {
table.float("ping").notNullable().alter();
table.float("ping_min").notNullable().defaultTo(0).alter();
table.float("ping_max").notNullable().defaultTo(0).alter();
});
};

View File

@ -0,0 +1,12 @@
exports.up = function (knex) {
// Add new column to table monitor for json-query retry behavior
return knex.schema.alterTable("monitor", function (table) {
table.boolean("retry_only_on_status_code_failure").defaultTo(false).notNullable();
});
};
exports.down = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.dropColumn("retry_only_on_status_code_failure");
});
};

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");
}
res = childProcess.spawnSync("git", ["push", "origin", "master"]);
console.log(res.stdout.toString().trim());
// 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,22 +4,13 @@
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"];
const template = `
LLM Task: Please help to put above PRs into the following sections based on their content. If a PR fits multiple sections, choose the most relevant one. If a PR doesn't fit any section, place it in "Others". If there are grammatical errors in the PR titles, please correct them. Don't change the PR numbers and authors, and keep the format. Output as markdown.
LLM Task: Please help to put above PRs into the following sections based on their content. If a PR fits multiple sections, choose the most relevant one. If a PR doesn't fit any section, place it in "Others". If there are grammatical errors in the PR titles, please correct them. Don't change the PR numbers and authors, and keep the format. Output as markdown file format.
Changelog:
@ -37,7 +28,9 @@ Changelog:
- Other small changes, code refactoring and comment/doc updates in this repo:
`;
await main();
if (import.meta.main) {
await main();
}
/**
* Main Function
@ -52,60 +45,63 @@ async function main() {
}
console.log(`Generating changelog since version ${previousVersion}...`);
console.log(await generateChangelog(previousVersion));
}
try {
const prList = await getPullRequestList(previousVersion);
const list = [];
/**
* Generate Changelog
* @param {string} previousVersion Previous Version Tag
* @returns {Promise<string>} Changelog Content
*/
export async function generateChangelog(previousVersion) {
const prList = await getPullRequestList(previousVersion);
const list = [];
let content = "";
let i = 1;
for (const pr of prList) {
console.log(`Progress: ${i++}/${prList.length}`);
let authorSet = await getAuthorList(pr.number);
authorSet = await mainAuthorToFront(pr.author.login, authorSet);
let i = 1;
for (const pr of prList) {
console.log(`Progress: ${i++}/${prList.length}`);
let authorSet = await getAuthorList(pr.number);
authorSet = await mainAuthorToFront(pr.author.login, authorSet);
if (mergeList.includes(pr.title)) {
// Check if it is already in the list
const existingItem = list.find(item => item.title === pr.title);
if (existingItem) {
existingItem.numbers.push(pr.number);
for (const author of authorSet) {
existingItem.authors.add(author);
// Sort the authors
existingItem.authors = new Set([ ...existingItem.authors ].sort((a, b) => a.localeCompare(b)));
}
continue;
if (mergeList.includes(pr.title)) {
// Check if it is already in the list
const existingItem = list.find((item) => item.title === pr.title);
if (existingItem) {
existingItem.numbers.push(pr.number);
for (const author of authorSet) {
existingItem.authors.add(author);
// Sort the authors
existingItem.authors = new Set([...existingItem.authors].sort((a, b) => a.localeCompare(b)));
}
continue;
}
const item = {
numbers: [ pr.number ],
title: pr.title,
authors: authorSet,
};
list.push(item);
}
for (const item of list) {
// Concat pr numbers into a string like #123 #456
const prPart = item.numbers.map(num => `#${num}`).join(" ");
const item = {
numbers: [pr.number],
title: pr.title,
authors: authorSet,
};
// Concat authors into a string like @user1 @user2
let authorPart = [ ...item.authors ].map(author => `@${author}`).join(" ");
if (authorPart) {
authorPart = `(Thanks ${authorPart})`;
}
console.log(`- ${prPart} ${item.title} ${authorPart}`);
}
console.log(template);
} catch (e) {
console.error("Failed to get pull request list:", e);
process.exit(1);
list.push(item);
}
for (const item of list) {
// Concat pr numbers into a string like #123 #456
const prPart = item.numbers.map((num) => `#${num}`).join(" ");
// Concat authors into a string like @user1 @user2
let authorPart = [...item.authors].map((author) => `@${author}`).join(" ");
if (authorPart) {
authorPart = `(Thanks ${authorPart})`;
}
content += `- ${prPart} ${item.title} ${authorPart}\n`;
}
return content + "\n" + template;
}
/**
@ -114,28 +110,37 @@ async function main() {
*/
async function getPullRequestList(previousVersion) {
// Get the date of previousVersion in YYYY-MM-DD format from git
const previousVersionDate = childProcess.execSync(`git log -1 --format=%cd --date=short ${previousVersion}`).toString().trim();
const previousVersionDate = childProcess
.execSync(`git log -1 --format=%cd --date=short ${previousVersion}`)
.toString()
.trim();
if (!previousVersionDate) {
throw new Error(`Unable to find the date of version ${previousVersion}. Please make sure the version tag exists.`);
throw new Error(
`Unable to find the date of version ${previousVersion}. Please make sure the version tag exists.`
);
}
const ghProcess = childProcess.spawnSync("gh", [
"pr",
"list",
"--state",
"merged",
"--base",
"master",
"--search",
`merged:>=${previousVersionDate}`,
"--json",
"number,title,author",
"--limit",
"1000"
], {
encoding: "utf-8"
});
const ghProcess = childProcess.spawnSync(
"gh",
[
"pr",
"list",
"--state",
"merged",
"--base",
"master",
"--search",
`merged:>=${previousVersionDate}`,
"--json",
"number,title,author",
"--limit",
"1000",
],
{
encoding: "utf-8",
}
);
if (ghProcess.error) {
throw ghProcess.error;
@ -153,14 +158,8 @@ async function getPullRequestList(previousVersion) {
* @returns {Promise<Set<string>>} Set of Authors' GitHub Usernames
*/
async function getAuthorList(prID) {
const ghProcess = childProcess.spawnSync("gh", [
"pr",
"view",
prID,
"--json",
"commits"
], {
encoding: "utf-8"
const ghProcess = childProcess.spawnSync("gh", ["pr", "view", prID, "--json", "commits"], {
encoding: "utf-8",
});
if (ghProcess.error) {
@ -185,7 +184,7 @@ async function getAuthorList(prID) {
}
// Sort the set
return new Set([ ...set ].sort((a, b) => a.localeCompare(b)));
return new Set([...set].sort((a, b) => a.localeCompare(b)));
}
/**
@ -197,5 +196,5 @@ async function mainAuthorToFront(mainAuthor, authorSet) {
if (ignoreList.includes(mainAuthor)) {
return authorSet;
}
return new Set([ mainAuthor, ...authorSet ]);
return new Set([mainAuthor, ...authorSet]);
}

View File

@ -7,24 +7,28 @@ import {
checkTagExists,
checkVersionFormat,
getRepoNames,
pressAnyKey,
execSync, uploadArtifacts, checkReleaseBranch,
execSync,
checkReleaseBranch,
createDistTarGz,
createReleasePR,
} from "./lib.mjs";
import semver from "semver";
const repoNames = getRepoNames();
const version = process.env.RELEASE_BETA_VERSION;
const githubToken = process.env.RELEASE_GITHUB_TOKEN;
const dryRun = process.env.DRY_RUN === "true";
const previousVersion = process.env.RELEASE_PREVIOUS_VERSION;
const branchName = `release-${version}`;
const githubRunId = process.env.GITHUB_RUN_ID;
if (dryRun) {
console.log("Dry run mode enabled. No images will be pushed.");
}
console.log("RELEASE_BETA_VERSION:", version);
if (!githubToken) {
console.error("GITHUB_TOKEN is required");
process.exit(1);
}
// Check if the current branch is "release"
checkReleaseBranch();
// Check if the current branch is "release-{version}"
checkReleaseBranch(branchName);
// Check if the version is a valid semver
checkVersionFormat(version);
@ -44,24 +48,34 @@ 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);
// Build frontend dist
buildDist();
// Build slim image (rootless)
buildImage(repoNames, [ "beta-slim-rootless", ver(version, "slim-rootless") ], "rootless", "BASE_IMAGE=louislam/uptime-kuma:base2-slim");
if (!dryRun) {
// Build slim image (rootless)
buildImage(
repoNames,
["beta-slim-rootless", ver(version, "slim-rootless")],
"rootless",
"BASE_IMAGE=louislam/uptime-kuma:base2-slim"
);
// Build full image (rootless)
buildImage(repoNames, [ "beta-rootless", ver(version, "rootless") ], "rootless");
// Build full image (rootless)
buildImage(repoNames, ["beta-rootless", ver(version, "rootless")], "rootless");
// Build slim image
buildImage(repoNames, [ "beta-slim", ver(version, "slim") ], "release", "BASE_IMAGE=louislam/uptime-kuma:base2-slim");
// Build slim image
buildImage(repoNames, ["beta-slim", ver(version, "slim")], "release", "BASE_IMAGE=louislam/uptime-kuma:base2-slim");
// Build full image
buildImage(repoNames, [ "beta", version ], "release");
// Build full image
buildImage(repoNames, ["beta", version], "release");
} else {
console.log("Dry run mode - skipping image build and push.");
}
await pressAnyKey();
// npm run upload-artifacts
uploadArtifacts(version, githubToken);
// Create dist.tar.gz
await createDistTarGz();

View File

@ -1,6 +1,9 @@
import "dotenv/config";
import * as childProcess from "child_process";
import semver from "semver";
import { generateChangelog } from "../generate-changelog.mjs";
import fs from "fs";
import tar from "tar";
export const dryRun = process.env.RELEASE_DRY_RUN === "1";
@ -23,16 +26,14 @@ export function checkDocker() {
/**
* Get Docker Hub repository name
* @returns {string[]} List of repository names
*/
export function getRepoNames() {
if (process.env.RELEASE_REPO_NAMES) {
// Split by comma
return process.env.RELEASE_REPO_NAMES.split(",").map((name) => name.trim());
}
return [
"louislam/uptime-kuma",
"ghcr.io/louislam/uptime-kuma",
];
return ["louislam/uptime-kuma", "ghcr.io/louislam/uptime-kuma"];
}
/**
@ -57,15 +58,15 @@ export function buildDist() {
* @param {string} platform Build platform
* @returns {void}
*/
export function buildImage(repoNames, tags, target, buildArgs = "", dockerfile = "docker/dockerfile", platform = "linux/amd64,linux/arm64,linux/arm/v7") {
let args = [
"buildx",
"build",
"-f",
dockerfile,
"--platform",
platform,
];
export function buildImage(
repoNames,
tags,
target,
buildArgs = "",
dockerfile = "docker/dockerfile",
platform = "linux/amd64,linux/arm64,linux/arm/v7"
) {
let args = ["buildx", "build", "-f", dockerfile, "--platform", platform];
for (let repoName of repoNames) {
// Add tags
@ -74,22 +75,14 @@ export function buildImage(repoNames, tags, target, buildArgs = "", dockerfile =
}
}
args = [
...args,
"--target",
target,
];
args = [...args, "--target", target];
// Add build args
if (buildArgs) {
args.push("--build-arg", buildArgs);
}
args = [
...args,
".",
"--push",
];
args = [...args, ".", "--push"];
if (!dryRun) {
childProcess.spawnSync("docker", args, { stdio: "inherit" });
@ -172,11 +165,13 @@ export function pressAnyKey() {
console.log("Git Push and Publish the release note on github, then press any key to continue");
process.stdin.setRawMode(true);
process.stdin.resume();
return new Promise(resolve => process.stdin.once("data", data => {
process.stdin.setRawMode(false);
process.stdin.pause();
resolve();
}));
return new Promise((resolve) =>
process.stdin.once("data", (data) => {
process.stdin.setRawMode(false);
process.stdin.pause();
resolve();
})
);
}
/**
@ -189,9 +184,9 @@ export function ver(version, identifier) {
const obj = semver.parse(version);
if (obj.prerelease.length === 0) {
obj.prerelease = [ identifier ];
obj.prerelease = [identifier];
} else {
obj.prerelease[0] = [ obj.prerelease[0], identifier ].join("-");
obj.prerelease[0] = [obj.prerelease[0], identifier].join("-");
}
return obj.format();
}
@ -202,6 +197,7 @@ export function ver(version, identifier) {
* @param {string} version Version
* @param {string} githubToken GitHub token
* @returns {void}
* @deprecated
*/
export function uploadArtifacts(version, githubToken) {
let args = [
@ -251,14 +247,117 @@ export function execSync(cmd) {
}
/**
* Check if the current branch is "release"
* Check if the current branch matches the expected release branch pattern
* @param {string} expectedBranch Expected branch name (can be "release" or "release-{version}")
* @returns {void}
*/
export function checkReleaseBranch() {
const res = childProcess.spawnSync("git", [ "rev-parse", "--abbrev-ref", "HEAD" ]);
export function checkReleaseBranch(expectedBranch = "release") {
const res = childProcess.spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
const branch = res.stdout.toString().trim();
if (branch !== "release") {
console.error(`Current branch is ${branch}, please switch to "release" branch`);
if (branch !== expectedBranch) {
console.error(`Current branch is ${branch}, please switch to "${expectedBranch}" branch`);
process.exit(1);
}
}
/**
* Create dist.tar.gz from the dist directory
* Similar to "tar -zcvf dist.tar.gz dist", but using nodejs
* @returns {Promise<void>}
*/
export async function createDistTarGz() {
const distPath = "dist";
const outputPath = "./tmp/dist.tar.gz";
const tmpDir = "./tmp";
// Ensure tmp directory exists
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, { recursive: true });
}
// Check if dist directory exists
if (!fs.existsSync(distPath)) {
console.error("Error: dist directory not found");
process.exit(1);
}
console.log(`Creating ${outputPath} from ${distPath}...`);
try {
await tar.create(
{
gzip: true,
file: outputPath,
},
[distPath]
);
console.log(`Successfully created ${outputPath}`);
} catch (error) {
console.error(`Failed to create tarball: ${error.message}`);
process.exit(1);
}
}
/**
* Create a draft release PR
* @param {string} version Version
* @param {string} previousVersion Previous version tag
* @param {boolean} dryRun Still create the PR, but add "[DRY RUN]" to the title
* @param {string} branchName The branch name to use for the PR head (defaults to "release")
* @param {string} githubRunId The GitHub Actions run ID for linking to artifacts
* @returns {Promise<void>}
*/
export async function createReleasePR(version, previousVersion, dryRun, branchName = "release", githubRunId = null) {
const changelog = await generateChangelog(previousVersion);
const title = dryRun ? `chore: update to ${version} (dry run)` : `chore: update to ${version}`;
// Build the artifact link - use direct run link if available, otherwise link to workflow file
const artifactLink = githubRunId
? `https://github.com/louislam/uptime-kuma/actions/runs/${githubRunId}/workflow`
: `https://github.com/louislam/uptime-kuma/actions/workflows/beta-release.yml`;
const body = `## Release ${version}
This PR prepares the release for version ${version}.
### Manual Steps Required
- [ ] Merge this PR (squash and merge)
- [ ] Create a new release on GitHub with the tag \`${version}\`.
- [ ] Ask any LLM to categorize the changelog into sections.
- [ ] Place the changelog in the release note.
- [ ] Download the \`dist.tar.gz\` artifact from the [workflow run](${artifactLink}) and upload it to the release.
- [ ] (Beta only) Set prerelease
- [ ] Publish the release note on GitHub.
### Changelog
\`\`\`md
${changelog}
\`\`\`
### Release Artifacts
The \`dist.tar.gz\` archive will be available as an artifact in the workflow run.
`;
// Create the PR using gh CLI
const args = ["pr", "create", "--title", title, "--body", body, "--base", "master", "--head", branchName, "--draft"];
console.log(`Creating draft PR: ${title}`);
const result = childProcess.spawnSync("gh", args, {
encoding: "utf-8",
stdio: "inherit",
env: {
...process.env,
GH_TOKEN: process.env.GH_TOKEN || process.env.GITHUB_TOKEN,
},
});
if (result.status !== 0) {
console.error("Failed to create pull request");
process.exit(1);
}
console.log("Successfully created draft pull request");
}

1667
package-lock.json generated

File diff suppressed because it is too large Load Diff

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",
@ -125,7 +125,7 @@
"node-fetch-cache": "^5.1.0",
"node-radius-utils": "~1.2.0",
"nodemailer": "~7.0.12",
"nostr-tools": "^2.10.4",
"nostr-tools": "^2.17.0",
"notp": "~2.0.3",
"openid-client": "^5.4.2",
"password-hash": "~1.2.2",
@ -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

@ -87,10 +87,12 @@ async function sendImportantHeartbeatList(socket, monitorID, toUser = false, ove
timeLogger.print(`[Monitor: ${monitorID}] sendImportantHeartbeatList`);
const result = list.map((bean) => bean.toJSON());
if (toUser) {
io.to(socket.userID).emit("importantHeartbeatList", monitorID, list, overwrite);
io.to(socket.userID).emit("importantHeartbeatList", monitorID, result, overwrite);
} else {
socket.emit("importantHeartbeatList", monitorID, list, overwrite);
socket.emit("importantHeartbeatList", monitorID, result, overwrite);
}
}

View File

@ -284,6 +284,14 @@ class Database {
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
...(dbConfig.ssl
? {
ssl: {
rejectUnauthorized: true,
...(dbConfig.ca && dbConfig.ca.trim() !== "" ? { ca: [dbConfig.ca] } : {}),
},
}
: {}),
});
// Set to true, so for example "uptime.kuma", becomes `uptime.kuma`, not `uptime`.`kuma`
@ -309,6 +317,14 @@ class Database {
}
return next();
},
...(dbConfig.ssl
? {
ssl: {
rejectUnauthorized: true,
...(dbConfig.ca && dbConfig.ca.trim() !== "" ? { ca: [dbConfig.ca] } : {}),
},
}
: {}),
},
pool: mariadbPoolConfig,
};

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

@ -1,4 +1,7 @@
const { BeanModel } = require("redbean-node/dist/bean-model");
const zlib = require("node:zlib");
const { promisify } = require("node:util");
const brotliDecompress = promisify(zlib.brotliDecompress);
/**
* status:
@ -36,8 +39,46 @@ class Heartbeat extends BeanModel {
important: this._important,
duration: this._duration,
retries: this._retries,
response: this._response,
};
}
/**
* Return an object that ready to parse to JSON
* @param {{ decodeResponse?: boolean }} opts Options for JSON serialization
* @returns {Promise<object>} Object ready to parse
*/
async toJSONAsync(opts) {
return {
monitorID: this._monitorId,
status: this._status,
time: this._time,
msg: this._msg,
ping: this._ping,
important: this._important,
duration: this._duration,
retries: this._retries,
response: opts?.decodeResponse ? await Heartbeat.decodeResponseValue(this._response) : undefined,
};
}
/**
* Decode compressed response payload stored in database.
* @param {string|null} response Encoded response payload.
* @returns {string|null} Decoded response payload.
*/
static async decodeResponseValue(response) {
if (!response) {
return response;
}
try {
// Offload brotli decode from main event loop to libuv thread pool
return (await brotliDecompress(Buffer.from(response, "base64"))).toString("utf8");
} catch (error) {
return response;
}
}
}
module.exports = Heartbeat;

View File

@ -24,6 +24,8 @@ const {
PING_PER_REQUEST_TIMEOUT_MIN,
PING_PER_REQUEST_TIMEOUT_MAX,
PING_PER_REQUEST_TIMEOUT_DEFAULT,
RESPONSE_BODY_LENGTH_DEFAULT,
RESPONSE_BODY_LENGTH_MAX,
} = require("../../src/util");
const {
ping,
@ -56,6 +58,9 @@ const { CookieJar } = require("tough-cookie");
const { HttpsCookieAgent } = require("http-cookie-agent/http");
const https = require("https");
const http = require("http");
const zlib = require("node:zlib");
const { promisify } = require("node:util");
const brotliCompress = promisify(zlib.brotliCompress);
const DomainExpiry = require("./domain_expiry");
const rootCertificates = rootCertificatesFingerprints();
@ -144,6 +149,7 @@ class Monitor extends BeanModel {
timeout: this.timeout,
interval: this.interval,
retryInterval: this.retryInterval,
retryOnlyOnStatusCodeFailure: Boolean(this.retry_only_on_status_code_failure),
resendInterval: this.resendInterval,
keyword: this.keyword,
invertKeyword: this.isInvertKeyword(),
@ -202,6 +208,11 @@ class Monitor extends BeanModel {
ping_numeric: this.isPingNumeric(),
ping_count: this.ping_count,
ping_per_request_timeout: this.ping_per_request_timeout,
// response saving options
saveResponse: this.getSaveResponse(),
saveErrorResponse: this.getSaveErrorResponse(),
responseMaxLength: this.response_max_length ?? RESPONSE_BODY_LENGTH_DEFAULT,
};
if (includeSensitiveData) {
@ -385,6 +396,22 @@ class Monitor extends BeanModel {
return Boolean(this.kafkaProducerAllowAutoTopicCreation);
}
/**
* Parse to boolean
* @returns {boolean} Should save response data on success?
*/
getSaveResponse() {
return Boolean(this.save_response);
}
/**
* Parse to boolean
* @returns {boolean} Should save response data on error?
*/
getSaveErrorResponse() {
return Boolean(this.save_error_response);
}
/**
* Start monitor
* @param {Server} io Socket server instance
@ -619,6 +646,11 @@ class Monitor extends BeanModel {
bean.msg = `${res.status} - ${res.statusText}`;
bean.ping = dayjs().valueOf() - startTime;
// in the frontend, the save response is only shown if the saveErrorResponse is set
if (this.getSaveResponse() && this.getSaveErrorResponse()) {
await this.saveResponseData(bean, res.data);
}
// fallback for if kelog event is not emitted, but we may still have tlsInfo,
// e.g. if the connection is made through a proxy
if (this.getUrl()?.protocol === "https:" && tlsInfo.valid === undefined) {
@ -930,16 +962,40 @@ class Monitor extends BeanModel {
bean.msg = error.message;
}
if (this.getSaveErrorResponse() && error?.response?.data !== undefined) {
await this.saveResponseData(bean, error.response.data);
}
// If UP come in here, it must be upside down mode
// Just reset the retries
if (this.isUpsideDown() && bean.status === UP) {
retries = 0;
} else if (this.maxretries > 0 && retries < this.maxretries) {
retries++;
bean.status = PENDING;
} else if (this.type === "json-query" && this.retry_only_on_status_code_failure) {
// For json-query monitors with retry_only_on_status_code_failure enabled,
// only retry if the error is NOT from JSON query evaluation
// JSON query errors have the message "JSON query does not pass..."
const isJsonQueryError =
typeof error.message === "string" && error.message.includes("JSON query does not pass");
if (isJsonQueryError) {
// Don't retry on JSON query failures, mark as DOWN immediately
retries = 0;
} else if (this.maxretries > 0 && retries < this.maxretries) {
retries++;
bean.status = PENDING;
} else {
// Continue counting retries during DOWN
retries++;
}
} else {
// Continue counting retries during DOWN
retries++;
// General retry logic for all other monitor types
if (this.maxretries > 0 && retries < this.maxretries) {
retries++;
bean.status = PENDING;
} else {
// Continue counting retries during DOWN
retries++;
}
}
}
@ -1003,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.`
);
}
}
}
@ -1044,7 +1108,10 @@ class Monitor extends BeanModel {
await R.store(bean);
log.debug("monitor", `[${this.name}] prometheus.update`);
this.prometheus?.update(bean, tlsInfo);
const data24h = uptimeCalculator.get24Hour();
const data30d = uptimeCalculator.get30Day();
const data1y = uptimeCalculator.get1Year();
this.prometheus?.update(bean, tlsInfo, { data24h, data30d, data1y });
previousBeat = bean;
@ -1108,6 +1175,35 @@ class Monitor extends BeanModel {
}
}
/**
* Save response body to a heartbeat if response saving is enabled.
* @param {import("redbean-node").Bean} bean Heartbeat bean to populate.
* @param {unknown} data Response payload.
* @returns {void}
*/
async saveResponseData(bean, data) {
if (data === undefined) {
return;
}
let responseData = data;
if (typeof responseData !== "string") {
try {
responseData = JSON.stringify(responseData);
} catch (error) {
responseData = String(responseData);
}
}
const maxSize = this.response_max_length ?? RESPONSE_BODY_LENGTH_DEFAULT;
if (responseData.length > maxSize) {
responseData = responseData.substring(0, maxSize) + "... (truncated)";
}
// Offload brotli compression from main event loop to libuv thread pool
bean.response = (await brotliCompress(Buffer.from(responseData, "utf8"))).toString("base64");
}
/**
* Make a request using axios
* @param {object} options Options for Axios
@ -1411,7 +1507,7 @@ class Monitor extends BeanModel {
* Send a notification about a monitor
* @param {boolean} isFirstBeat Is this beat the first of this monitor?
* @param {Monitor} monitor The monitor to send a notification about
* @param {Bean} bean Status information about monitor
* @param {import("./heartbeat")} bean Status information about monitor
* @returns {Promise<void>}
*/
static async sendNotification(isFirstBeat, monitor, bean) {
@ -1429,7 +1525,7 @@ class Monitor extends BeanModel {
for (let notification of notificationList) {
try {
const heartbeatJSON = bean.toJSON();
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.
@ -1636,6 +1732,16 @@ class Monitor extends BeanModel {
throw new Error(`Retry interval cannot be less than ${MIN_INTERVAL_SECOND} seconds`);
}
if (this.response_max_length !== undefined) {
if (this.response_max_length < 0) {
throw new Error(`Response max length cannot be less than 0`);
}
if (this.response_max_length > RESPONSE_BODY_LENGTH_MAX) {
throw new Error(`Response max length cannot be more than ${RESPONSE_BODY_LENGTH_MAX} bytes`);
}
}
if (this.type === "ping") {
// ping parameters validation
if (this.packetSize && (this.packetSize < PING_PACKET_SIZE_MIN || this.packetSize > PING_PACKET_SIZE_MAX)) {
@ -1970,7 +2076,7 @@ class Monitor extends BeanModel {
*/
async handleTlsInfo(tlsInfo) {
await this.updateTlsInfo(tlsInfo);
this.prometheus?.update(null, tlsInfo);
this.prometheus?.update(null, tlsInfo, null);
if (!this.getIgnoreTls() && this.isEnabledExpiryNotification()) {
log.debug("monitor", `[${this.name}] call checkCertExpiryNotifications`);

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

@ -19,7 +19,9 @@ class AliyunSMS extends NotificationProvider {
name: monitorJSON["name"],
time: heartbeatJSON["localDateTime"],
status: this.statusToString(heartbeatJSON["status"]),
msg: this.removeIpAndDomain(heartbeatJSON["msg"]),
...(notification.optionalParameters && {
msg: this.removeIpAndDomain(heartbeatJSON["msg"]),
}),
});
if (await this.sendSms(notification, msgBody)) {
return okMsg;
@ -29,7 +31,9 @@ class AliyunSMS extends NotificationProvider {
name: "",
time: "",
status: "",
msg: this.removeIpAndDomain(msg),
...(notification.optionalParameters && {
msg: this.removeIpAndDomain(msg),
}),
});
if (await this.sendSms(notification, msgBody)) {
return okMsg;

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,17 +1,8 @@
const NotificationProvider = require("./notification-provider");
const { finalizeEvent, Relay, kinds, nip04, nip19 } = require("nostr-tools");
const { finalizeEvent, Relay, nip19, nip59 } = require("nostr-tools");
// polyfills for node versions
const semver = require("semver");
const nodeVersion = process.version;
if (semver.lt(nodeVersion, "20.0.0")) {
// polyfills for node 18
global.crypto = require("crypto");
global.WebSocket = require("isomorphic-ws");
} else {
// polyfills for node 20
global.WebSocket = require("isomorphic-ws");
}
// polyfill WebSocket for nostr-tools
global.WebSocket = require("isomorphic-ws");
class Nostr extends NotificationProvider {
name = "nostr";
@ -20,24 +11,27 @@ class Nostr extends NotificationProvider {
* @inheritdoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
// All DMs should have same timestamp
const createdAt = Math.floor(Date.now() / 1000);
const senderPrivateKey = await this.getPrivateKey(notification.sender);
const recipientsPublicKeys = await this.getPublicKeys(notification.recipients);
// Create NIP-04 encrypted direct message event for each recipient
// Create NIP-59 gift-wrapped events for each recipient
// This uses NIP-17 kind 14 (private direct message) wrapped with NIP-59
// to prevent metadata leakage (sender/recipient public keys are hidden)
const createdAt = Math.floor(Date.now() / 1000);
const events = [];
for (const recipientPublicKey of recipientsPublicKeys) {
const ciphertext = await nip04.encrypt(senderPrivateKey, recipientPublicKey, msg);
let event = {
kind: kinds.EncryptedDirectMessage,
const event = {
kind: 14, // NIP-17 private direct message
created_at: createdAt,
tags: [["p", recipientPublicKey]],
content: ciphertext,
content: msg,
};
const signedEvent = finalizeEvent(event, senderPrivateKey);
events.push(signedEvent);
try {
const wrappedEvent = nip59.wrapEvent(event, senderPrivateKey, recipientPublicKey);
events.push(wrappedEvent);
} catch (error) {
throw new Error(`Failed to create gift-wrapped event for recipient: ${error.message}`);
}
}
// Publish events to each relay

View File

@ -108,16 +108,51 @@ class NotificationProvider {
* @throws {any} The error specified
*/
throwGeneralAxiosError(error) {
let msg = "Error: " + error + " ";
let msg = error && error.message ? error.message : String(error);
if (error.response && error.response.data) {
if (error && error.code) {
msg += ` (code=${error.code})`;
}
if (error && error.response && error.response.status) {
msg += ` (HTTP ${error.response.status}${error.response.statusText ? " " + error.response.statusText : ""})`;
}
if (error && error.response && error.response.data) {
if (typeof error.response.data === "string") {
msg += error.response.data;
msg += " " + error.response.data;
} else {
msg += JSON.stringify(error.response.data);
try {
msg += " " + JSON.stringify(error.response.data);
} catch (e) {
msg += " " + String(error.response.data);
}
}
}
// Expand AggregateError to show underlying causes
let agg = null;
if (error && error.name === "AggregateError" && Array.isArray(error.errors)) {
agg = error;
} else if (error && error.cause && error.cause.name === "AggregateError" && Array.isArray(error.cause.errors)) {
agg = error.cause;
}
if (agg) {
let causes = agg.errors
.map((e) => {
let m = e && e.message ? e.message : String(e);
if (e && e.code) {
m += ` (code=${e.code})`;
}
return m;
})
.join("; ");
msg += " - caused by: " + causes;
} else if (error && error.cause && error.cause.message) {
msg += " - cause: " + error.cause.message;
}
throw new Error(msg);
}

View File

@ -90,6 +90,20 @@ class Slack extends NotificationProvider {
},
});
// Optional context line for monitor group path (excluding monitor name)
const groupPath = monitorJSON?.path?.length > 1 ? monitorJSON.path.slice(0, -1).join(" / ") : "";
if (groupPath) {
blocks.push({
type: "context",
elements: [
{
type: "mrkdwn",
text: `_${groupPath}_`,
},
],
});
}
// the body block, containing the details
blocks.push({
type: "section",
@ -142,7 +156,7 @@ class Slack extends NotificationProvider {
const baseURL = await setting("primaryBaseURL");
const title = "Uptime Kuma Alert";
const title = monitorJSON?.name || "Uptime Kuma Alert";
let data = {
text: msg,
channel: notification.slackchannel,

View File

@ -4,6 +4,8 @@ const { R } = require("redbean-node");
let monitorCertDaysRemaining = null;
let monitorCertIsValid = null;
let monitorUptimeRatio = null;
let monitorAverageResponseTimeSeconds = null;
let monitorResponseTime = null;
let monitorStatus = null;
@ -69,6 +71,18 @@ class Prometheus {
labelNames: commonLabels,
});
monitorUptimeRatio = new PrometheusClient.Gauge({
name: "monitor_uptime_ratio",
help: "Uptime ratio calculated over sliding window specified by the 'window' label. (0.0 - 1.0)",
labelNames: [...commonLabels, "window"],
});
monitorAverageResponseTimeSeconds = new PrometheusClient.Gauge({
name: "monitor_response_time_seconds",
help: "Average response time in seconds calculated over sliding window specified by the 'window' label",
labelNames: [...commonLabels, "window"],
});
monitorResponseTime = new PrometheusClient.Gauge({
name: "monitor_response_time",
help: "Monitor Response Time (ms)",
@ -130,11 +144,13 @@ class Prometheus {
/**
* Update the metrics page
* @typedef {import("./uptime-calculator").UptimeDataResult} UptimeDataResult
* @param {object} heartbeat Heartbeat details
* @param {object} tlsInfo TLS details
* @param {{data24h: UptimeDataResult, data30d: UptimeDataResult, data1y:UptimeDataResult} | null} uptime the uptime and average response rate over a variety of fixed windows
* @returns {void}
*/
update(heartbeat, tlsInfo) {
update(heartbeat, tlsInfo, uptime) {
if (typeof tlsInfo !== "undefined") {
try {
let isValid;
@ -145,8 +161,7 @@ class Prometheus {
}
monitorCertIsValid.set(this.monitorLabelValues, isValid);
} catch (e) {
log.error("prometheus", "Caught error");
log.error("prometheus", e);
log.error("prometheus", "Caught error", e);
}
try {
@ -154,8 +169,49 @@ class Prometheus {
monitorCertDaysRemaining.set(this.monitorLabelValues, tlsInfo.certInfo.daysRemaining);
}
} catch (e) {
log.error("prometheus", "Caught error");
log.error("prometheus", e);
log.error("prometheus", "Caught error", e);
}
}
if (uptime) {
try {
monitorAverageResponseTimeSeconds.set(
{ ...this.monitorLabelValues, window: "1d" },
uptime.data24h.avgPing / 1000
);
} catch (e) {
log.error("prometheus", "Caught error", e);
}
try {
monitorAverageResponseTimeSeconds.set(
{ ...this.monitorLabelValues, window: "30d" },
uptime.data30d.avgPing / 1000
);
} catch (e) {
log.error("prometheus", "Caught error", e);
}
try {
monitorAverageResponseTimeSeconds.set(
{ ...this.monitorLabelValues, window: "365d" },
uptime.data1y.avgPing / 1000
);
} catch (e) {
log.error("prometheus", "Caught error", e);
}
try {
monitorUptimeRatio.set({ ...this.monitorLabelValues, window: "1d" }, uptime.data24h.uptime);
} catch (e) {
log.error("prometheus", "Caught error", e);
}
try {
monitorUptimeRatio.set({ ...this.monitorLabelValues, window: "30d" }, uptime.data30d.uptime);
} catch (e) {
log.error("prometheus", "Caught error", e);
}
try {
monitorUptimeRatio.set({ ...this.monitorLabelValues, window: "365d" }, uptime.data1y.uptime);
} catch (e) {
log.error("prometheus", "Caught error", e);
}
}
@ -189,6 +245,8 @@ class Prometheus {
try {
monitorCertDaysRemaining.remove(this.monitorLabelValues);
monitorCertIsValid.remove(this.monitorLabelValues);
monitorUptimeRatio.remove(this.monitorLabelValues);
monitorAverageResponseTimeSeconds.remove(this.monitorLabelValues);
monitorResponseTime.remove(this.monitorLabelValues);
monitorStatus.remove(this.monitorLabelValues);
} catch (e) {

View File

@ -52,6 +52,13 @@ router.all("/api/push/:pushToken", async (request, response) => {
let statusString = request.query.status || "up";
const statusFromParam = statusString === "up" ? UP : DOWN;
// Validate ping value - max 100 billion ms (~3.17 years)
// Fits safely in both BIGINT and FLOAT(20,2)
const MAX_PING_MS = 100000000000;
if (ping !== null && (ping < 0 || ping > MAX_PING_MS)) {
throw new Error(`Invalid ping value. Must be between 0 and ${MAX_PING_MS} ms.`);
}
let monitor = await R.findOne("monitor", " push_token = ? AND active = 1 ", [pushToken]);
if (!monitor) {

View File

@ -762,6 +762,10 @@ let needSetup = false;
}
bean.import(monitor);
// Map camelCase frontend property to snake_case database column
if (monitor.retryOnlyOnStatusCodeFailure !== undefined) {
bean.retry_only_on_status_code_failure = monitor.retryOnlyOnStatusCodeFailure;
}
bean.user_id = socket.userID;
bean.validate();
@ -868,6 +872,9 @@ let needSetup = false;
bean.packetSize = monitor.packetSize;
bean.maxredirects = monitor.maxredirects;
bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
bean.save_response = monitor.saveResponse;
bean.save_error_response = monitor.saveErrorResponse;
bean.response_max_length = monitor.responseMaxLength;
bean.dns_resolve_type = monitor.dns_resolve_type;
bean.dns_resolve_server = monitor.dns_resolve_server;
bean.pushToken = monitor.pushToken;
@ -914,6 +921,7 @@ let needSetup = false;
bean.snmpVersion = monitor.snmpVersion;
bean.snmpOid = monitor.snmpOid;
bean.jsonPathOperator = monitor.jsonPathOperator;
bean.retry_only_on_status_code_failure = Boolean(monitor.retryOnlyOnStatusCodeFailure);
bean.timeout = monitor.timeout;
bean.rabbitmqNodes = JSON.stringify(monitor.rabbitmqNodes);
bean.rabbitmqUsername = monitor.rabbitmqUsername;

View File

@ -102,6 +102,8 @@ class SetupDatabase {
dbConfig.dbName = process.env.UPTIME_KUMA_DB_NAME;
dbConfig.username = getEnvOrFile("UPTIME_KUMA_DB_USERNAME");
dbConfig.password = getEnvOrFile("UPTIME_KUMA_DB_PASSWORD");
dbConfig.ssl = getEnvOrFile("UPTIME_KUMA_DB_SSL")?.toLowerCase() === "true";
dbConfig.ca = getEnvOrFile("UPTIME_KUMA_DB_CA");
Database.writeDBConfig(dbConfig);
}
}
@ -239,6 +241,14 @@ class SetupDatabase {
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.dbName,
...(dbConfig.ssl
? {
ssl: {
rejectUnauthorized: true,
...(dbConfig.ca && dbConfig.ca.trim() !== "" ? { ca: [dbConfig.ca] } : {}),
},
}
: {}),
});
await connection.execute("SELECT 1");
connection.end();

View File

@ -206,7 +206,7 @@ class UptimeCalculator {
* @param {number} status status
* @param {number} ping Ping
* @param {dayjs.Dayjs} date Date (Only for migration)
* @returns {dayjs.Dayjs} date
* @returns {Promise<dayjs.Dayjs>} date
* @throws {Error} Invalid status
*/
async update(status, ping = 0, date) {
@ -217,7 +217,7 @@ class UptimeCalculator {
let flatStatus = this.flatStatus(status);
if (flatStatus === DOWN && ping > 0) {
log.debug("uptime-calc", "The ping is not effective when the status is DOWN");
log.debug("uptime_calc", "The ping is not effective when the status is DOWN");
}
let divisionKey = this.getMinutelyKey(date);
@ -295,7 +295,7 @@ class UptimeCalculator {
// Don't store data in test mode
if (process.env.TEST_BACKEND) {
log.debug("uptime-calc", "Skip storing data in test mode");
log.debug("uptime_calc", "Skip storing data in test mode");
return date;
}
@ -358,7 +358,7 @@ class UptimeCalculator {
if (!this.migrationMode) {
// Remove the old data
// TODO: Improvement: Convert it to a job?
log.debug("uptime-calc", "Remove old data");
log.debug("uptime_calc", "Remove old data");
await R.exec("DELETE FROM stat_minutely WHERE monitor_id = ? AND timestamp < ?", [
this.monitorID,
this.getMinutelyKey(currentDate.subtract(this.statMinutelyKeepHour, "hour")),

View File

@ -157,6 +157,16 @@ exports.pingAsync = function (
deadline = PING_GLOBAL_TIMEOUT_DEFAULT,
timeout = PING_PER_REQUEST_TIMEOUT_DEFAULT
) {
try {
const url = new URL(`http://${destAddr}`);
destAddr = url.hostname;
if (destAddr.startsWith("[") && destAddr.endsWith("]")) {
destAddr = destAddr.slice(1, -1);
}
} catch (e) {
// ignore
}
return new Promise((resolve, reject) => {
ping.promise
.probe(destAddr, {

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

@ -2,20 +2,29 @@
<div class="shadow-box mb-3" :style="boxStyle">
<div class="list-header">
<div class="header-top">
<button
class="btn btn-outline-normal ms-2"
:class="{ active: selectMode }"
type="button"
@click="selectMode = !selectMode"
>
{{ $t("Select") }}
</button>
<div class="select-checkbox-wrapper">
<input
v-if="!selectMode"
v-model="selectMode"
class="form-check-input"
type="checkbox"
:aria-label="$t('selectAllMonitorsAria')"
@change="selectAll = selectMode"
/>
<input
v-else
v-model="selectAll"
class="form-check-input"
type="checkbox"
:aria-label="selectAll ? $t('deselectAllMonitorsAria') : $t('selectAllMonitorsAria')"
/>
</div>
<div class="placeholder"></div>
<div class="search-wrapper">
<a v-if="searchText == ''" class="search-icon">
<font-awesome-icon icon="search" />
</a>
<div class="header-filter">
<MonitorListFilter :filterState="filterState" @update-filter="updateFilter" />
</div>
<div class="search-wrapper ms-auto">
<a v-if="searchText != ''" class="search-icon" @click="clearSearchText">
<font-awesome-icon icon="times" />
</a>
@ -30,25 +39,51 @@
</form>
</div>
</div>
<div class="header-filter">
<MonitorListFilter :filterState="filterState" @update-filter="updateFilter" />
</div>
<!-- Selection Controls -->
<div v-if="selectMode" class="selection-controls px-2 pt-2">
<input v-model="selectAll" class="form-check-input select-input" type="checkbox" />
<button class="btn-outline-normal" @click="pauseDialog">
<font-awesome-icon icon="pause" size="sm" />
{{ $t("Pause") }}
<div v-if="selectMode && selectedMonitorCount > 0" class="selected-count-row">
<button class="btn btn-outline-normal" @click="cancelSelectMode">
{{ $t("Cancel") }}
</button>
<button class="btn-outline-normal" @click="resumeSelected">
<font-awesome-icon icon="play" size="sm" />
{{ $t("Resume") }}
</button>
<span v-if="selectedMonitorCount > 0">
{{ $t("selectedMonitorCount", [selectedMonitorCount]) }}
<div class="actions-wrapper ms-2">
<div class="dropdown">
<button
class="btn btn-outline-normal dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
:aria-label="$t('Actions')"
:disabled="bulkActionInProgress"
aria-expanded="false"
>
{{ $t("Actions") }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="#" @click.prevent="pauseDialog">
<font-awesome-icon icon="pause" class="me-2" />
{{ $t("Pause") }}
</a>
</li>
<li>
<a class="dropdown-item" href="#" @click.prevent="resumeSelected">
<font-awesome-icon icon="play" class="me-2" />
{{ $t("Resume") }}
</a>
</li>
<li>
<a
class="dropdown-item text-danger"
href="#"
@click.prevent="$refs.confirmDelete.show()"
>
<font-awesome-icon icon="trash" class="me-2" />
{{ $t("Delete") }}
</a>
</li>
</ul>
</div>
</div>
<span class="selected-count ms-2">
{{ $t("selectedMonitorCountMsg", selectedMonitorCount) }}
</span>
</div>
</div>
@ -81,6 +116,10 @@
<Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseSelected">
{{ $t("pauseMonitorMsg") }}
</Confirm>
<Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteSelected">
{{ $t("deleteMonitorsMsg") }}
</Confirm>
</template>
<script>
@ -109,6 +148,7 @@ export default {
disableSelectAllWatcher: false,
selectedMonitors: {},
windowTop: 0,
bulkActionInProgress: false,
filterState: {
status: null,
active: null,
@ -307,10 +347,21 @@ export default {
* @returns {void}
*/
pauseSelected() {
Object.keys(this.selectedMonitors)
.filter((id) => this.$root.monitorList[id].active)
.forEach((id) => this.$root.getSocket().emit("pauseMonitor", id, () => {}));
if (this.bulkActionInProgress) {
return;
}
const activeMonitors = Object.keys(this.selectedMonitors).filter((id) => this.$root.monitorList[id].active);
if (activeMonitors.length === 0) {
this.$root.toastError(this.$t("noMonitorsPausedMsg"));
return;
}
this.bulkActionInProgress = true;
activeMonitors.forEach((id) => this.$root.getSocket().emit("pauseMonitor", id, () => {}));
this.$root.toastSuccess(this.$t("pausedMonitorsMsg", activeMonitors.length));
this.bulkActionInProgress = false;
this.cancelSelectMode();
},
/**
@ -318,9 +369,66 @@ export default {
* @returns {void}
*/
resumeSelected() {
Object.keys(this.selectedMonitors)
.filter((id) => !this.$root.monitorList[id].active)
.forEach((id) => this.$root.getSocket().emit("resumeMonitor", id, () => {}));
if (this.bulkActionInProgress) {
return;
}
const inactiveMonitors = Object.keys(this.selectedMonitors).filter(
(id) => !this.$root.monitorList[id].active
);
if (inactiveMonitors.length === 0) {
this.$root.toastError(this.$t("noMonitorsResumedMsg"));
return;
}
this.bulkActionInProgress = true;
inactiveMonitors.forEach((id) => this.$root.getSocket().emit("resumeMonitor", id, () => {}));
this.$root.toastSuccess(this.$t("resumedMonitorsMsg", inactiveMonitors.length));
this.bulkActionInProgress = false;
this.cancelSelectMode();
},
/**
* Delete each selected monitor
* @returns {Promise<void>}
*/
async deleteSelected() {
if (this.bulkActionInProgress) {
return;
}
const monitorIds = Object.keys(this.selectedMonitors);
this.bulkActionInProgress = true;
let successCount = 0;
let errorCount = 0;
for (const id of monitorIds) {
try {
await new Promise((resolve, reject) => {
this.$root.getSocket().emit("deleteMonitor", id, false, (res) => {
if (res.ok) {
successCount++;
resolve();
} else {
errorCount++;
reject();
}
});
});
} catch (error) {
// Error already counted
}
}
this.bulkActionInProgress = false;
if (successCount > 0) {
this.$root.toastSuccess(this.$t("deletedMonitorsMsg", successCount));
}
if (errorCount > 0) {
this.$root.toastError(this.$t("bulkDeleteErrorMsg", errorCount));
}
this.cancelSelectMode();
},
@ -438,10 +546,32 @@ export default {
}
}
.search-row {
display: flex;
padding: 10px;
padding-bottom: 5px;
}
.header-top {
display: flex;
justify-content: space-between;
justify-content: flex-start;
align-items: center;
gap: 8px;
padding: 10px;
@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 {
cursor: pointer;
margin: 0;
}
}
.header-filter {
@ -449,6 +579,104 @@ export default {
align-items: center;
}
.actions-wrapper {
display: flex;
align-items: center;
.dropdown-toggle {
white-space: nowrap;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.dropdown-menu {
min-width: 140px;
padding: 4px 0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
.dark & {
background-color: $dark-bg;
border-color: $dark-border-color;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
}
.dropdown-item {
cursor: pointer;
padding: 6px 12px;
font-size: 0.9em;
.dark & {
color: $dark-font-color;
&:hover {
background-color: $dark-bg2;
color: $dark-font-color;
}
}
&.text-danger {
color: #dc3545;
.dark & {
color: #dc3545;
}
&:hover {
background-color: #dc3545 !important;
color: white !important;
.dark & {
background-color: #dc3545 !important;
color: white !important;
}
svg {
color: white !important;
}
}
}
}
}
.selected-count {
white-space: nowrap;
font-size: 0.9em;
color: $primary;
.dark & {
color: $dark-font-color;
}
}
.selected-count-row {
padding: 5px 10px 0 10px;
display: flex;
align-items: center;
}
.selection-controls {
margin-top: 5px;
display: flex;
align-items: center;
.d-flex {
width: 100%;
}
.gap-2 {
gap: 0.5rem;
}
.selected-count {
margin-left: auto;
}
}
@media (max-width: 770px) {
.list-header {
margin: -20px;
@ -460,25 +688,40 @@ export default {
.search-wrapper {
display: flex;
align-items: center;
position: relative;
@media (max-width: 549px), (min-width: 770px) and (max-width: 1149px), (min-width: 1200px) and (max-width: 1499px) {
order: -1;
width: 100%;
margin-bottom: 8px;
form {
width: 100%;
}
}
}
.search-icon {
padding: 10px;
position: absolute;
right: 10px;
color: #c0c0c0;
cursor: pointer;
transition: all ease-in-out 0.1s;
z-index: 1;
// Clear filter button (X)
svg[data-icon="times"] {
cursor: pointer;
transition: all ease-in-out 0.1s;
&:hover {
opacity: 0.5;
}
&:hover {
opacity: 0.5;
}
}
.search-input {
max-width: 15em;
padding-right: 30px;
@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 {
@ -498,10 +741,13 @@ export default {
margin-top: 5px;
}
.selection-controls {
margin-top: 5px;
display: flex;
align-items: center;
gap: 10px;
@media (max-width: 549px), (min-width: 770px) and (max-width: 1149px), (min-width: 1200px) and (max-width: 1499px) {
.selection-controls {
.selected-count {
margin-left: 0;
width: 100%;
margin-top: 0.25rem;
}
}
}
</style>

View File

@ -1,18 +1,5 @@
<template>
<div class="px-2 pt-2 d-flex">
<button
type="button"
:title="$t('Clear current filters')"
class="clear-filters-btn btn"
:class="{ active: numFiltersActive > 0 }"
tabindex="0"
:disabled="numFiltersActive === 0"
@click="clearFilters"
>
<font-awesome-icon icon="stream" />
<span v-if="numFiltersActive > 0" class="px-1 fw-bold">{{ numFiltersActive }}</span>
<font-awesome-icon v-if="numFiltersActive > 0" icon="times" />
</button>
<div class="d-flex align-items-center flex-wrap gap-1">
<MonitorListFilterDropdown :filterActive="filterState.status?.length > 0">
<template #status>
<Status v-if="filterState.status?.length === 1" :status="filterState.status[0]" />

View File

@ -24,7 +24,7 @@
<router-link :to="monitorURL(monitor.id)" class="item" :class="{ disabled: !monitor.active }">
<div class="row">
<div
class="col-6 small-padding"
class="col-9 col-xl-6 small-padding"
:class="{
'monitor-item': $root.userHeartbeatBar == 'bottom' || $root.userHeartbeatBar == 'none',
}"
@ -44,7 +44,11 @@
<Tag v-for="tag in monitor.tags" :key="tag" :item="tag" :size="'sm'" />
</div>
</div>
<div v-show="$root.userHeartbeatBar == 'normal'" :key="$root.userHeartbeatBar" class="col-6">
<div
v-show="$root.userHeartbeatBar == 'normal'"
:key="$root.userHeartbeatBar"
class="col-3 col-xl-6"
>
<HeartbeatBar ref="heartbeatBar" size="small" :monitor-id="monitor.id" />
</div>
</div>

View File

@ -51,7 +51,7 @@
<template #item="monitor">
<div class="item" data-testid="monitor">
<div class="row">
<div class="col-6 small-padding">
<div class="col-9 col-xl-6 small-padding">
<div class="info">
<font-awesome-icon
v-if="editMode"
@ -116,7 +116,7 @@
</div>
</div>
</div>
<div :key="$root.userHeartbeatBar" class="col-6">
<div :key="$root.userHeartbeatBar" class="col-3 col-xl-6">
<HeartbeatBar size="mid" :monitor-id="monitor.element.id" />
</div>
</div>
@ -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

@ -4,25 +4,34 @@
{{ $t("AccessKeyId") }}
<span style="color: red"><sup>*</sup></span>
</label>
<input id="accessKeyId" v-model="$parent.notification.accessKeyId" type="text" class="form-control" required />
<HiddenInput
id="accessKeyId"
v-model="$parent.notification.accessKeyId"
:required="true"
autocomplete="new-password"
></HiddenInput>
<label for="secretAccessKey" class="form-label">
{{ $t("SecretAccessKey") }}
<span style="color: red"><sup>*</sup></span>
</label>
<input
<HiddenInput
id="secretAccessKey"
v-model="$parent.notification.secretAccessKey"
type="text"
class="form-control"
required
/>
:required="true"
autocomplete="new-password"
></HiddenInput>
<label for="phonenumber" class="form-label">
{{ $t("PhoneNumbers") }}
<span style="color: red"><sup>*</sup></span>
</label>
<input id="phonenumber" v-model="$parent.notification.phonenumber" type="text" class="form-control" required />
<HiddenInput
id="phonenumber"
v-model="$parent.notification.phonenumber"
:required="true"
autocomplete="new-password"
></HiddenInput>
<label for="templateCode" class="form-label">
{{ $t("TemplateCode") }}
@ -41,13 +50,28 @@
<span style="color: red"><sup>*</sup></span>
</label>
<input id="signName" v-model="$parent.notification.signName" type="text" class="form-control" required />
<div class="form-check form-switch">
<label class="form-check-label">{{ $t("OptionalParameters") }}</label>
<input
id="optionalParameters"
v-model="$parent.notification.optionalParameters"
class="form-check-input"
type="checkbox"
/>
<div class="form-text">{{ $t("aliyun_enable_optional_variables_at_the_risk_of_non_delivery") }}</div>
</div>
<br />
<div class="form-text">
<p>
{{ $t("Sms template must contain parameters: ") }}
<br />
<code>${name} ${time} ${status} ${msg}</code>
</p>
<i18n-t tag="p" keypath="aliyun-template-requirements-and-parameters">
<template #parameters>
<code>${name} ${time} ${status}</code>
</template>
</i18n-t>
<i18n-t tag="p" keypath="aliyun-template-optional-parameters">
<template #parameters>
<code>${msg}</code>
</template>
</i18n-t>
<i18n-t tag="p" keypath="Read more:">
<a href="https://help.aliyun.com/document_detail/101414.html" target="_blank">
https://help.aliyun.com/document_detail/101414.html
@ -56,3 +80,12 @@
</div>
</div>
</template>
<script>
import HiddenInput from "../HiddenInput.vue";
export default {
components: {
HiddenInput,
},
};
</script>

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

@ -6,6 +6,10 @@
"setupDatabaseSQLite": "A simple database file, recommended for small-scale deployments. Prior to v2.0.0, Uptime Kuma used SQLite as the default database.",
"settingUpDatabaseMSG": "Setting up the database. It may take a while, please be patient.",
"dbName": "Database Name",
"enableSSL": "Enable SSL/TLS",
"mariadbUseSSLHelptext": "Enable to use a encrypted connection to your database. Required for most cloud databases.",
"mariadbCaCertificateLabel": "CA Certificate",
"mariadbCaCertificateHelptext": "Paste the CA Cert in PEM format to use with self-signed certificates. Leave blank if your database uses a certificate signed by a public CA.",
"Settings": "Settings",
"Dashboard": "Dashboard",
"Help": "Help",
@ -16,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",
@ -29,6 +33,7 @@
"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",
@ -48,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",
@ -82,6 +89,8 @@
"resendEveryXTimes": "Resend every {0} times",
"resendDisabled": "Resend disabled",
"retriesDescription": "Maximum retries before the service is marked as down and a notification is sent",
"Only retry if status code check fails": "Only retry if status code check fails",
"retryOnlyOnStatusCodeFailureDescription": "If enabled, retries will only occur when the HTTP status code check fails (e.g., server is down). If the status code check passes but the JSON query fails, the monitor will be marked as down immediately without retries.",
"ignoredTLSError": "TLS/SSL errors have been ignored",
"ignoreTLSError": "Ignore TLS/SSL errors for HTTPS websites",
"ignoreTLSErrorGeneral": "Ignore TLS/SSL error for connection",
@ -94,6 +103,11 @@
"maxRedirectDescription": "Maximum number of redirects to follow. Set to 0 to disable redirects.",
"Upside Down Mode": "Upside Down Mode",
"Max. Redirects": "Max. Redirects",
"saveResponseForNotifications": "Save HTTP Success Response for Notifications",
"saveErrorResponseForNotifications": "Save HTTP Error Response for Notifications",
"saveResponseDescription": "Stores the HTTP response and makes it available to notification templates as {templateVariable}",
"responseMaxLength": "Response Max Length (bytes)",
"responseMaxLengthDescription": "Maximum size of response data to store. Set to 0 for unlimited. Larger responses will be truncated. Default: 1024 (1KB)",
"Accepted Status Codes": "Accepted Status Codes",
"Push URL": "Push URL",
"needPushEvery": "You should call this URL every {0} seconds.",
@ -132,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",
@ -267,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",
@ -307,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",
@ -324,7 +339,11 @@
"Cancel": "Cancel",
"auto-select": "Auto Select",
"Select": "Select",
"selectedMonitorCount": "Selected: {0}",
"Actions": "Actions",
"selectedMonitorCountMsg": "selected: {n} | selected: {n}",
"selectMonitorMsg": "Select monitors to perform actions",
"selectAllMonitorsAria": "Select all monitors",
"deselectAllMonitorsAria": "Deselect all monitors",
"Check/Uncheck": "Check/Uncheck",
"Powered by": "Powered by",
"Customize": "Customize",
@ -339,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",
@ -386,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",
@ -460,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.",
@ -475,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.",
@ -486,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",
@ -620,6 +639,13 @@
"grpcMethodDescription": "Method name is convert to camelCase format such as sayHello, check, etc.",
"acceptedStatusCodesDescription": "Select status codes which are considered as a successful response.",
"deleteMonitorMsg": "Are you sure want to delete this monitor?",
"deleteMonitorsMsg": "Are you sure you want to delete the selected monitors?",
"pausedMonitorsMsg": "Paused {n} monitor | Paused {n} monitors",
"resumedMonitorsMsg": "Resumed {n} monitor | Resumed {n} monitors",
"deletedMonitorsMsg": "Deleted {n} monitor | Deleted {n} monitors",
"noMonitorsPausedMsg": "No monitors paused (none were active)",
"noMonitorsResumedMsg": "No monitors resumed (none were inactive)",
"bulkDeleteErrorMsg": "Failed to delete {n} monitor | Failed to delete {n} monitors",
"deleteGroupMsg": "Are you sure you want to delete this group?",
"deleteChildrenMonitors": "Also delete the direct child monitors and its children if it has any | Also delete all {count} direct child monitors and their children if they have any",
"deleteMaintenanceMsg": "Are you sure want to delete this maintenance?",
@ -648,6 +674,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.",
@ -658,7 +686,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",
@ -710,7 +737,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",
@ -739,7 +766,10 @@
"PhoneNumbers": "PhoneNumbers",
"TemplateCode": "TemplateCode",
"SignName": "SignName",
"Sms template must contain parameters: ": "Sms template must contain parameters: ",
"OptionalParameters": "Optional Parameters",
"aliyun_enable_optional_variables_at_the_risk_of_non_delivery": "Due to carrier restrictions, enable optional variables at the risk of non-delivery",
"aliyun-template-requirements-and-parameters": "The aliyun SMS template must contain parameters: {parameters}",
"aliyun-template-optional-parameters": "Optional parameters: {parameters}",
"Bark API Version": "Bark API Version",
"Bark Endpoint": "Bark Endpoint",
"Bark Group": "Bark Group",
@ -857,7 +887,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",
@ -1236,6 +1267,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",
@ -1266,9 +1299,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

@ -11,7 +11,7 @@
<MonitorList :scrollbar="true" />
</div>
<div ref="container" class="col-12 col-md-7 col-xl-8 mb-3">
<div ref="container" class="col-12 col-md-7 col-xl-8 mb-3 gx-0">
<!-- Add :key to disable vue router re-use the same component -->
<router-view :key="$route.fullPath" :calculatedHeight="height" />
</div>

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

@ -1119,7 +1119,7 @@
:max="maxInterval"
step="1"
@focus="lowIntervalConfirmation.editedValue = true"
@blur="checkIntervalValue"
@blur="finishUpdateInterval"
/>
<div class="form-text">
@ -1147,7 +1147,7 @@
</div>
</div>
<div class="my-3">
<div v-if="monitor.maxretries" class="my-3">
<label for="retry-interval" class="form-label">
{{ $t("Heartbeat Retry Interval") }}
<span>({{ $t("retryCheckEverySecond", [monitor.retryInterval]) }})</span>
@ -1167,6 +1167,24 @@
</div>
</div>
<!-- Retry only on status code failure: JSON Query only -->
<div v-if="monitor.type === 'json-query' && monitor.maxretries > 0" class="my-3">
<div class="form-check">
<input
id="retry-only-on-status-code-failure"
v-model="monitor.retryOnlyOnStatusCodeFailure"
type="checkbox"
class="form-check-input"
/>
<label for="retry-only-on-status-code-failure" class="form-check-label">
{{ $t("Only retry if status code check fails") }}
</label>
</div>
<div class="form-text">
{{ $t("retryOnlyOnStatusCodeFailureDescription") }}
</div>
</div>
<!-- Timeout: HTTP / JSON query / Keyword / Ping / RabbitMQ / SNMP / Websocket Upgrade only -->
<div
v-if="
@ -1496,6 +1514,89 @@
</div>
</div>
<div
v-if="
monitor.type === 'http' ||
monitor.type === 'keyword' ||
monitor.type === 'json-query'
"
class="my-3"
>
<div class="form-check">
<input
id="saveErrorResponse"
v-model="monitor.saveErrorResponse"
class="form-check-input"
type="checkbox"
/>
<label class="form-check-label" for="saveErrorResponse">
{{ $t("saveErrorResponseForNotifications") }}
</label>
</div>
<div class="form-text">
<i18n-t keypath="saveResponseDescription" tag="div" class="form-text">
<template #templateVariable>
<code>heartbeatJSON.response</code>
</template>
</i18n-t>
</div>
</div>
<div
v-if="
(monitor.type === 'http' ||
monitor.type === 'keyword' ||
monitor.type === 'json-query') &&
monitor.saveErrorResponse
"
class="my-3"
>
<div class="form-check">
<input
id="saveResponse"
v-model="monitor.saveResponse"
class="form-check-input"
type="checkbox"
/>
<label class="form-check-label" for="saveResponse">
{{ $t("saveResponseForNotifications") }}
</label>
</div>
<div class="form-text">
<i18n-t keypath="saveResponseDescription" tag="div" class="form-text">
<template #templateVariable>
<code>heartbeatJSON.response</code>
</template>
</i18n-t>
</div>
</div>
<div
v-if="
(monitor.type === 'http' ||
monitor.type === 'keyword' ||
monitor.type === 'json-query') &&
(monitor.saveResponse || monitor.saveErrorResponse)
"
class="my-3"
>
<label for="responseMaxLength" class="form-label">
{{ $t("responseMaxLength") }}
</label>
<input
id="responseMaxLength"
v-model="monitor.responseMaxLength"
type="number"
class="form-control"
required
min="0"
step="1"
/>
<div class="form-text">
{{ $t("responseMaxLengthDescription") }}
</div>
</div>
<div class="my-3">
<label for="acceptedStatusCodes" class="form-label">
{{ $t("Accepted Status Codes") }}
@ -2158,6 +2259,7 @@ const monitorDefaults = {
retryInterval: 60,
resendInterval: 0,
maxretries: 0,
retryOnlyOnStatusCodeFailure: false,
notificationIDList: {},
ignoreTls: false,
upsideDown: false,
@ -2165,6 +2267,9 @@ const monitorDefaults = {
domainExpiryNotification: true,
maxredirects: 10,
accepted_statuscodes: ["200-299"],
saveResponse: false,
saveErrorResponse: true,
responseMaxLength: 1024,
dns_resolve_type: "A",
dns_resolve_server: "1.1.1.1",
docker_container: "",

View File

@ -121,6 +121,42 @@
<input id="floatingInput" v-model="dbConfig.dbName" type="text" class="form-control" required />
<label for="floatingInput">{{ $t("dbName") }}</label>
</div>
<div class="mt-3 short text-start">
<div class="form-check form-switch ps-0" style="height: auto; display: block; padding: 0">
<div class="d-flex align-items-center">
<input
id="sslCheck"
v-model="dbConfig.ssl"
type="checkbox"
role="switch"
class="form-check-input ms-0 me-2"
style="float: none"
/>
<label class="form-check-label fw-bold" for="sslCheck">
{{ $t("enableSSL") }}
<span class="fw-normal text-muted" style="font-size: 0.9em">
({{ $t("Optional") }})
</span>
</label>
</div>
<div class="form-text mt-1">
{{ $t("mariadbUseSSLHelptext") }}
</div>
</div>
</div>
<div v-if="dbConfig.ssl" class="form-floating mt-3 short">
<textarea
id="caInput"
v-model="dbConfig.ca"
class="form-control"
placeholder="-----BEGIN CERTIFICATE-----"
style="height: 120px"
></textarea>
<label for="caInput">{{ $t("mariadbCaCertificateLabel") }}</label>
<div class="form-text">{{ $t("mariadbCaCertificateHelptext") }}</div>
</div>
</template>
<button class="btn btn-primary mt-4 short" type="submit" :disabled="disabledButton">
@ -148,6 +184,8 @@ export default {
username: "",
password: "",
dbName: "kuma",
ssl: false,
ca: "",
},
info: {
needSetup: false,
@ -253,6 +291,14 @@ export default {
}
}
.form-check {
height: calc(3.5rem + 2px);
padding: 0;
display: flex;
align-items: center;
justify-content: space-around;
}
.short {
width: 300px;
}

View File

@ -342,14 +342,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 +578,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

@ -10,97 +10,12 @@
*/
var _a;
Object.defineProperty(exports, "__esModule", { value: true });
exports.CONSOLE_STYLE_FgPink =
exports.CONSOLE_STYLE_FgBrown =
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.PING_PER_REQUEST_TIMEOUT_DEFAULT =
exports.PING_PER_REQUEST_TIMEOUT_MAX =
exports.PING_PER_REQUEST_TIMEOUT_MIN =
exports.PING_COUNT_DEFAULT =
exports.PING_COUNT_MAX =
exports.PING_COUNT_MIN =
exports.PING_GLOBAL_TIMEOUT_DEFAULT =
exports.PING_GLOBAL_TIMEOUT_MAX =
exports.PING_GLOBAL_TIMEOUT_MIN =
exports.PING_PACKET_SIZE_DEFAULT =
exports.PING_PACKET_SIZE_MAX =
exports.PING_PACKET_SIZE_MIN =
exports.MIN_INTERVAL_SECOND =
exports.MAX_INTERVAL_SECOND =
exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND =
exports.SQL_DATETIME_FORMAT =
exports.SQL_DATE_FORMAT =
exports.STATUS_PAGE_MAINTENANCE =
exports.STATUS_PAGE_PARTIAL_DOWN =
exports.STATUS_PAGE_ALL_UP =
exports.STATUS_PAGE_ALL_DOWN =
exports.MAINTENANCE =
exports.PENDING =
exports.UP =
exports.DOWN =
exports.appName =
exports.isNode =
exports.isDev =
void 0;
exports.TYPES_WITH_DOMAIN_EXPIRY_SUPPORT_VIA_FIELD =
exports.evaluateJsonQuery =
exports.intHash =
exports.localToUTC =
exports.utcToLocal =
exports.utcToISODateTime =
exports.isoToUTCDateTime =
exports.parseTimeFromTimeObject =
exports.parseTimeObject =
exports.getMonitorRelativeURL =
exports.genSecret =
exports.getCryptoRandomInt =
exports.getRandomInt =
exports.getRandomArbitrary =
exports.TimeLogger =
exports.polyfill =
exports.log =
exports.debug =
exports.ucfirst =
exports.sleep =
exports.flipStatus =
exports.badgeConstants =
exports.CONSOLE_STYLE_BgGray =
exports.CONSOLE_STYLE_BgWhite =
exports.CONSOLE_STYLE_BgCyan =
exports.CONSOLE_STYLE_BgMagenta =
exports.CONSOLE_STYLE_BgBlue =
exports.CONSOLE_STYLE_BgYellow =
exports.CONSOLE_STYLE_BgGreen =
exports.CONSOLE_STYLE_BgRed =
exports.CONSOLE_STYLE_BgBlack =
void 0;
exports.CONSOLE_STYLE_FgViolet = exports.CONSOLE_STYLE_FgLightBlue = exports.CONSOLE_STYLE_FgLightGreen = exports.CONSOLE_STYLE_FgOrange = exports.CONSOLE_STYLE_FgGray = exports.CONSOLE_STYLE_FgWhite = exports.CONSOLE_STYLE_FgCyan = exports.CONSOLE_STYLE_FgMagenta = exports.CONSOLE_STYLE_FgBlue = exports.CONSOLE_STYLE_FgYellow = exports.CONSOLE_STYLE_FgGreen = exports.CONSOLE_STYLE_FgRed = exports.CONSOLE_STYLE_FgBlack = exports.CONSOLE_STYLE_Hidden = exports.CONSOLE_STYLE_Reverse = exports.CONSOLE_STYLE_Blink = exports.CONSOLE_STYLE_Underscore = exports.CONSOLE_STYLE_Dim = exports.CONSOLE_STYLE_Bright = exports.CONSOLE_STYLE_Reset = exports.RESPONSE_BODY_LENGTH_MAX = exports.RESPONSE_BODY_LENGTH_DEFAULT = exports.PING_PER_REQUEST_TIMEOUT_DEFAULT = exports.PING_PER_REQUEST_TIMEOUT_MAX = exports.PING_PER_REQUEST_TIMEOUT_MIN = exports.PING_COUNT_DEFAULT = exports.PING_COUNT_MAX = exports.PING_COUNT_MIN = exports.PING_GLOBAL_TIMEOUT_DEFAULT = exports.PING_GLOBAL_TIMEOUT_MAX = exports.PING_GLOBAL_TIMEOUT_MIN = exports.PING_PACKET_SIZE_DEFAULT = exports.PING_PACKET_SIZE_MAX = exports.PING_PACKET_SIZE_MIN = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isNode = exports.isDev = void 0;
exports.TYPES_WITH_DOMAIN_EXPIRY_SUPPORT_VIA_FIELD = exports.evaluateJsonQuery = exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.badgeConstants = exports.CONSOLE_STYLE_BgGray = exports.CONSOLE_STYLE_BgWhite = exports.CONSOLE_STYLE_BgCyan = exports.CONSOLE_STYLE_BgMagenta = exports.CONSOLE_STYLE_BgBlue = exports.CONSOLE_STYLE_BgYellow = exports.CONSOLE_STYLE_BgGreen = exports.CONSOLE_STYLE_BgRed = exports.CONSOLE_STYLE_BgBlack = exports.CONSOLE_STYLE_FgPink = exports.CONSOLE_STYLE_FgBrown = void 0;
const dayjs_1 = require("dayjs");
const jsonata = require("jsonata");
exports.isDev = process.env.NODE_ENV === "development";
exports.isNode =
typeof process !== "undefined" &&
((_a = process === null || process === void 0 ? void 0 : process.versions) === null || _a === void 0
? void 0
: _a.node);
exports.isNode = typeof process !== "undefined" && ((_a = process === null || process === void 0 ? void 0 : process.versions) === null || _a === void 0 ? void 0 : _a.node);
const dayjs = exports.isNode ? require("dayjs") : dayjs_1.default;
exports.appName = "Uptime Kuma";
exports.DOWN = 0;
@ -128,6 +43,8 @@ exports.PING_COUNT_DEFAULT = 1;
exports.PING_PER_REQUEST_TIMEOUT_MIN = 1;
exports.PING_PER_REQUEST_TIMEOUT_MAX = 60;
exports.PING_PER_REQUEST_TIMEOUT_DEFAULT = 2;
exports.RESPONSE_BODY_LENGTH_DEFAULT = 1024;
exports.RESPONSE_BODY_LENGTH_MAX = 1024 * 1024;
exports.CONSOLE_STYLE_Reset = "\x1b[0m";
exports.CONSOLE_STYLE_Bright = "\x1b[1m";
exports.CONSOLE_STYLE_Dim = "\x1b[2m";
@ -253,7 +170,8 @@ class Logger {
let now;
if (dayjs.tz) {
now = dayjs.tz(new Date()).format();
} else {
}
else {
now = dayjs().format();
}
const levelColor = consoleLevelColors[level];
@ -272,7 +190,8 @@ class Logger {
}
modulePart = "[" + moduleColor + module + exports.CONSOLE_STYLE_Reset + "]";
levelPart = levelColor + `${level}:` + exports.CONSOLE_STYLE_Reset;
} else {
}
else {
timePart = now;
modulePart = `[${module}]`;
levelPart = `${level}:`;
@ -346,21 +265,21 @@ function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
exports.getRandomInt = getRandomInt;
const getRandomBytes = (
typeof window !== "undefined" && window.crypto
? function () {
return (numBytes) => {
const randomBytes = new Uint8Array(numBytes);
for (let i = 0; i < numBytes; i += 65536) {
window.crypto.getRandomValues(randomBytes.subarray(i, i + Math.min(numBytes - i, 65536)));
}
return randomBytes;
};
}
: function () {
return require("crypto").randomBytes;
}
)();
const getRandomBytes = (typeof window !== "undefined" && window.crypto
?
function () {
return (numBytes) => {
const randomBytes = new Uint8Array(numBytes);
for (let i = 0; i < numBytes; i += 65536) {
window.crypto.getRandomValues(randomBytes.subarray(i, i + Math.min(numBytes - i, 65536)));
}
return randomBytes;
};
}
:
function () {
return require("crypto").randomBytes;
})();
function getCryptoRandomInt(min, max) {
const range = max - min;
if (range >= Math.pow(2, 32)) {
@ -386,7 +305,8 @@ function getCryptoRandomInt(min, max) {
randomValue = randomValue & mask;
if (randomValue <= range) {
return min + randomValue;
} else {
}
else {
return getCryptoRandomInt(min, max);
}
}
@ -467,7 +387,8 @@ async function evaluateJsonQuery(data, jsonPath, jsonPathOperator, expectedValue
let response;
try {
response = JSON.parse(data);
} catch (_a) {
}
catch (_a) {
response =
(typeof data === "object" || typeof data === "number") && !Buffer.isBuffer(data) ? data : data.toString();
}
@ -479,17 +400,13 @@ async function evaluateJsonQuery(data, jsonPath, jsonPathOperator, expectedValue
if (Array.isArray(response)) {
const responseStr = JSON.stringify(response);
const truncatedResponse = responseStr.length > 25 ? responseStr.substring(0, 25) + "...]" : responseStr;
throw new Error(
"JSON query returned the array " +
truncatedResponse +
", but a primitive value is required. " +
"Modify your query to return a single value via [0] to get the first element or use an aggregation like $count(), $sum() or $boolean()."
);
throw new Error("JSON query returned the array " +
truncatedResponse +
", but a primitive value is required. " +
"Modify your query to return a single value via [0] to get the first element or use an aggregation like $count(), $sum() or $boolean().");
}
if (typeof response === "object" || response instanceof Date || typeof response === "function") {
throw new Error(
`The post-JSON query evaluated response from the server is of type ${typeof response}, which cannot be directly compared to the expected value`
);
throw new Error(`The post-JSON query evaluated response from the server is of type ${typeof response}, which cannot be directly compared to the expected value`);
}
let jsonQueryExpression;
switch (jsonPathOperator) {
@ -517,15 +434,14 @@ async function evaluateJsonQuery(data, jsonPath, jsonPathOperator, expectedValue
expected: expectedValue.toString(),
});
if (status === undefined) {
throw new Error(
"Query evaluation returned undefined. Check query syntax and the structure of the response data"
);
throw new Error("Query evaluation returned undefined. Check query syntax and the structure of the response data");
}
return {
status,
response,
};
} catch (err) {
}
catch (err) {
response = JSON.stringify(response);
response = response && response.length > 50 ? `${response.substring(0, 100)}… (truncated)` : response;
throw new Error(`Error evaluating JSON query: ${err.message}. Response from server was: ${response}`);

View File

@ -66,6 +66,17 @@ export const PING_PER_REQUEST_TIMEOUT_MIN = 1;
export const PING_PER_REQUEST_TIMEOUT_MAX = 60;
export const PING_PER_REQUEST_TIMEOUT_DEFAULT = 2;
/**
* Response body length cutoff used by default (10kb)
* (measured in bytes)
*/
export const RESPONSE_BODY_LENGTH_DEFAULT = 1024;
/**
* Maximum allowed response body length to store (1mb)
* (measured in bytes)
*/
export const RESPONSE_BODY_LENGTH_MAX = 1024 * 1024;
// Console colors
// https://stackoverflow.com/questions/9781218/how-to-change-node-jss-console-font-color
export const CONSOLE_STYLE_Reset = "\x1b[0m";

View File

@ -1,16 +1,16 @@
const { describe, it } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const fs = require("fs/promises");
const path = require("path");
/**
* Recursively walks a directory and yields file paths.
* @param {string} dir The directory to walk.
* @yields {string} The path to a file.
* @returns {Generator<string>} A generator that yields file paths.
* @returns {AsyncGenerator<string>} A generator that yields file paths.
*/
function* walk(dir) {
const files = fs.readdirSync(dir, { withFileTypes: true });
async function* walk(dir) {
const files = await fs.readdir(dir, { withFileTypes: true });
for (const file of files) {
if (file.isDirectory()) {
yield* walk(path.join(dir, file.name));
@ -20,6 +20,29 @@ function* walk(dir) {
}
}
const UPSTREAM_EN_JSON = "https://raw.githubusercontent.com/louislam/uptime-kuma/refs/heads/master/src/lang/en.json";
/**
* Extract `{placeholders}` from a translation string.
* @param {string} value The translation string to extract placeholders from.
* @returns {Set<string>} A set of placeholder names.
*/
function extractParams(value) {
if (typeof value !== "string") {
return new Set();
}
const regex = /\{([^}]+)\}/g;
const params = new Set();
let match;
while ((match = regex.exec(value)) !== null) {
params.add(match[1]);
}
return params;
}
/**
* Fallback to get start/end indices of a key within a line.
* @param {string} line - Line of text to search in.
@ -35,8 +58,8 @@ function getStartEnd(line, key) {
}
describe("Check Translations", () => {
it("should not have missing translation keys", () => {
const enTranslations = JSON.parse(fs.readFileSync("src/lang/en.json", "utf-8"));
it("should not have missing translation keys", async () => {
const enTranslations = JSON.parse(await fs.readFile("src/lang/en.json", "utf-8"));
// this is a resonably crude check, you can get around this trivially
/// this check is just to save on maintainer energy to explain this on every review ^^
@ -50,9 +73,9 @@ describe("Check Translations", () => {
const roots = ["src", "server"];
for (const root of roots) {
for (const filePath of walk(root)) {
for await (const filePath of walk(root)) {
if (filePath.endsWith(".vue") || filePath.endsWith(".js")) {
const lines = fs.readFileSync(filePath, "utf-8").split("\n");
const lines = (await fs.readFile(filePath, "utf-8")).split("\n");
lines.forEach((line, lineNum) => {
let match;
// front-end style keys ($t / i18n-t)
@ -112,4 +135,38 @@ describe("Check Translations", () => {
assert.fail(report);
}
});
it("en.json translations must not change placeholder parameters", async () => {
// Load local reference (the one translators are synced against)
const enTranslations = JSON.parse(await fs.readFile("src/lang/en.json", "utf-8"));
// Fetch upstream version
const res = await fetch(UPSTREAM_EN_JSON);
assert.equal(res.ok, true, "Failed to fetch upstream en.json");
const upstreamEn = await res.json();
for (const [key, upstreamValue] of Object.entries(upstreamEn)) {
if (!(key in enTranslations)) {
// deleted keys are fine
continue;
}
const localParams = extractParams(enTranslations[key]);
const upstreamParams = extractParams(upstreamValue);
assert.deepEqual(
localParams,
upstreamParams,
[
`Translation key "${key}" changed placeholder parameters.`,
`This is a breaking change for existing translations.`,
`Please rename the translation key instead of changing placeholders.`,
``,
`your version: ${[...localParams].join(", ")}`,
`on master: ${[...upstreamParams].join(", ")}`,
].join("\n")
);
}
});
});

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

@ -0,0 +1,34 @@
const { describe, test } = require("node:test");
const assert = require("node:assert");
const NotificationProvider = require("../../../server/notification-providers/notification-provider");
describe("NotificationProvider.throwGeneralAxiosError()", () => {
const provider = new NotificationProvider();
test("expands AggregateError causes", () => {
let err1 = new Error("connect ECONNREFUSED 127.0.0.1:443");
err1.code = "ECONNREFUSED";
let err2 = new Error("connect ECONNREFUSED ::1:443");
err2.code = "ECONNREFUSED";
let aggErr = new AggregateError([err1, err2], "AggregateError");
assert.throws(() => provider.throwGeneralAxiosError(aggErr), {
message: /^AggregateError - caused by: .+/,
});
});
test("expands AggregateError wrapped in error.cause", () => {
let innerErr = new Error("connect ETIMEDOUT 10.0.0.1:443");
innerErr.code = "ETIMEDOUT";
let aggErr = new AggregateError([innerErr], "AggregateError");
let outerErr = new Error("Request failed");
outerErr.cause = aggErr;
assert.throws(() => provider.throwGeneralAxiosError(outerErr), {
message: /^Request failed - caused by: .+/,
});
});
});

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

View File

@ -0,0 +1,36 @@
const { describe, test } = require("node:test");
const assert = require("node:assert");
const Monitor = require("../../server/model/monitor");
const Heartbeat = require("../../server/model/heartbeat");
const { RESPONSE_BODY_LENGTH_DEFAULT } = require("../../src/util");
describe("Monitor response saving", () => {
test("getSaveResponse and getSaveErrorResponse parse booleans", () => {
const monitor = Object.create(Monitor.prototype);
monitor.save_response = 1;
monitor.save_error_response = 0;
assert.strictEqual(monitor.getSaveResponse(), true);
assert.strictEqual(monitor.getSaveErrorResponse(), false);
});
test("saveResponseData stores and truncates response", async () => {
const monitor = Object.create(Monitor.prototype);
monitor.response_max_length = 5;
const bean = {};
await monitor.saveResponseData(bean, "abcdef");
assert.strictEqual(await Heartbeat.decodeResponseValue(bean.response), "abcde... (truncated)");
});
test("saveResponseData stringifies objects", async () => {
const monitor = Object.create(Monitor.prototype);
monitor.response_max_length = RESPONSE_BODY_LENGTH_DEFAULT;
const bean = {};
await monitor.saveResponseData(bean, { ok: true });
assert.strictEqual(await Heartbeat.decodeResponseValue(bean.response), JSON.stringify({ ok: true }));
});
});

View File

@ -0,0 +1,57 @@
const { describe, test } = require("node:test");
const assert = require("node:assert");
const { pingAsync } = require("../../server/util-server");
describe("Server Utilities: pingAsync", () => {
test("should convert IDN domains to Punycode before pinging", async () => {
const idnDomain = "münchen.de";
const punycodeDomain = "xn--mnchen-3ya.de";
await assert.rejects(pingAsync(idnDomain, false, 1, "", true, 56, 1, 1), (err) => {
if (err.message.includes("Parameter string not correctly encoded")) {
assert.fail("Ping failed with encoding error: IDN was not converted");
}
assert.ok(
err.message.includes(punycodeDomain),
`Error message should contain the Punycode domain "${punycodeDomain}". Got: ${err.message}`
);
return true;
});
});
test("should strip brackets from IPv6 addresses before pinging", async () => {
const ipv6WithBrackets = "[2606:4700:4700::1111]";
const ipv6Raw = "2606:4700:4700::1111";
await assert.rejects(pingAsync(ipv6WithBrackets, true, 1, "", true, 56, 1, 1), (err) => {
assert.strictEqual(
err.message.includes(ipv6WithBrackets),
false,
"Error message should not contain brackets"
);
// Allow either the IP in the message (local) OR "Network is unreachable"
const containsIP = err.message.includes(ipv6Raw);
const isUnreachable =
err.message.includes("Network is unreachable") || err.message.includes("Network unreachable");
// macOS error when IPv6 stack is missing
const isMacOSError = err.message.includes("nodename nor servname provided");
assert.ok(
containsIP || isUnreachable || isMacOSError,
`Ping failed correctly, but error message format was unexpected.\nGot: "${err.message}"\nExpected to contain IP "${ipv6Raw}" OR be a standard network error.`
);
return true;
});
});
test("should handle standard ASCII domains correctly", async () => {
const domain = "invalid-domain.test";
await assert.rejects(pingAsync(domain, false, 1, "", true, 56, 1, 1), (err) => {
assert.strictEqual(err.message.includes("Parameter string not correctly encoded"), false);
assert.ok(
err.message.includes(domain),
`Error message should contain the domain "${domain}". Got: ${err.message}`
);
return true;
});
});
});

View File

@ -105,4 +105,34 @@ test.describe("Monitor Form", () => {
await screenshot(testInfo, page);
});
test("save response settings persist", async ({ page }, testInfo) => {
await page.goto("./add");
await login(page);
await selectMonitorType(page, "http");
const friendlyName = "Example HTTP Save Response";
await page.getByTestId("friendly-name-input").fill(friendlyName);
await page.getByTestId("url-input").fill("https://www.example.com/");
// Expect error response save enabled by default
await expect(page.getByLabel("Save HTTP Error Response for Notifications")).toBeChecked();
await page.getByLabel("Save HTTP Success Response for Notifications").check();
await page.getByLabel("Save HTTP Error Response for Notifications").uncheck();
await page.getByLabel("Response Max Length (bytes)").fill("2048");
await screenshot(testInfo, page);
await page.getByTestId("save-button").click();
await page.waitForURL("/dashboard/*");
await page.getByRole("link", { name: "Edit" }).click();
await page.waitForURL("/edit/*");
await expect(page.getByLabel("Save HTTP Success Response for Notifications")).toBeHidden();
await expect(page.getByLabel("Save HTTP Error Response for Notifications")).not.toBeChecked();
await expect(page.getByLabel("Response Max Length (bytes)")).toHaveValue("2048");
await screenshot(testInfo, page);
});
});