Merge branch 'master' into feature/monitor-postgres-query-result

This commit is contained in:
Dalton Pearson 2026-01-14 15:48:32 -05:00 committed by GitHub
commit cf6658f929
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 2038 additions and 1025 deletions

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

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

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

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

@ -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();
@ -203,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) {
@ -386,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
@ -620,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) {
@ -931,6 +962,10 @@ 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) {
@ -1024,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.`
);
}
}
}
@ -1114,6 +1157,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
@ -1417,7 +1489,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) {
@ -1435,7 +1507,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.
@ -1642,6 +1714,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)) {

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

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

@ -863,6 +863,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;

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

@ -83,7 +83,7 @@
</div>
</div>
<span class="selected-count ms-2">
{{ $tc("selectedMonitorCountMsg", selectedMonitorCount) }}
{{ $t("selectedMonitorCountMsg", selectedMonitorCount) }}
</span>
</div>
</div>
@ -360,7 +360,7 @@ export default {
this.bulkActionInProgress = true;
activeMonitors.forEach((id) => this.$root.getSocket().emit("pauseMonitor", id, () => {}));
this.$root.toastSuccess(this.$tc("pausedMonitorsMsg", activeMonitors.length));
this.$root.toastSuccess(this.$t("pausedMonitorsMsg", activeMonitors.length));
this.bulkActionInProgress = false;
this.cancelSelectMode();
},
@ -384,7 +384,7 @@ export default {
this.bulkActionInProgress = true;
inactiveMonitors.forEach((id) => this.$root.getSocket().emit("resumeMonitor", id, () => {}));
this.$root.toastSuccess(this.$tc("resumedMonitorsMsg", inactiveMonitors.length));
this.$root.toastSuccess(this.$t("resumedMonitorsMsg", inactiveMonitors.length));
this.bulkActionInProgress = false;
this.cancelSelectMode();
},
@ -424,10 +424,10 @@ export default {
this.bulkActionInProgress = false;
if (successCount > 0) {
this.$root.toastSuccess(this.$tc("deletedMonitorsMsg", successCount));
this.$root.toastSuccess(this.$t("deletedMonitorsMsg", successCount));
}
if (errorCount > 0) {
this.$root.toastError(this.$tc("bulkDeleteErrorMsg", errorCount));
this.$root.toastError(this.$t("bulkDeleteErrorMsg", errorCount));
}
this.cancelSelectMode();

View File

@ -1,5 +1,5 @@
<template>
<div class="d-flex align-items-center">
<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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@
"General": "General",
"Game": "Game",
"Primary Base URL": "Primary Base URL",
"Version": "Version",
"versionIs": "Version: {version}",
"Check Update On GitHub": "Check Update On GitHub",
"List": "List",
"Home": "Home",
@ -33,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",
@ -52,12 +53,14 @@
"Current": "Current",
"Uptime": "Uptime",
"Cert Exp.": "Cert Exp.",
"Monitor": "Monitor | Monitors",
"Monitors": "{n} Monitor | {n} Monitors",
"now": "now",
"time ago": "{0} ago",
"day": "day | days",
"hour": "hour | hours",
"year": "year | years",
"days": "{n} day | {n} days",
"hours": "{n} hour | {n} hours",
"minutes": "{n} minute | {n} minutes",
"minuteShort": "{n} min | {n} min",
"years": "{n} year | {n} years",
"Response": "Response",
"Ping": "Ping",
"Monitor Type": "Monitor Type",
@ -100,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.",
@ -138,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",
@ -273,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",
@ -313,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",
@ -349,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",
@ -396,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",
@ -470,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.",
@ -485,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.",
@ -496,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",
@ -663,6 +672,8 @@
"recurringIntervalMessage": "Run once every day | Run once every {0} days",
"affectedMonitorsDescription": "Select monitors that are affected by current maintenance",
"affectedStatusPages": "Show this maintenance message on selected status pages",
"Sets end time based on start time": "Sets end time based on start time",
"Please set start time first": "Please set start time first",
"noMonitorsSelectedWarning": "You are creating a maintenance without any affected monitors. Are you sure you want to continue?",
"noMonitorsOrStatusPagesSelectedError": "Cannot create maintenance without affected monitors or status pages",
"passwordNotMatchMsg": "The repeat password does not match.",
@ -673,7 +684,6 @@
"backupDescription": "You can backup all monitors and notifications into a JSON file.",
"backupDescription2": "Note: history and event data is not included.",
"backupDescription3": "Sensitive data such as notification tokens are included in the export file; please store export securely.",
"endpoint": "endpoint",
"octopushAPIKey": "\"API key\" from HTTP API credentials in control panel",
"octopushLogin": "\"Login\" from HTTP API credentials in control panel",
"promosmsLogin": "API Login Name",
@ -725,7 +735,7 @@
"apiCredentials": "API credentials",
"octopushLegacyHint": "Do you use the legacy version of Octopush (2011-2020) or the new version?",
"Check octopush prices": "Check octopush prices {0}.",
"octopushPhoneNumber": "Phone number (intl format, eg : +33612345678) ",
"octopushPhoneNumber": "Phone number (intl format, eg : +33612345678)",
"octopushSMSSender": "SMS Sender Name : 3-11 alphanumeric characters and space (a-zA-Z0-9)",
"LunaSea Device ID": "LunaSea Device ID",
"Apprise URL": "Apprise URL",
@ -875,7 +885,8 @@
"From Name/Number": "From Name/Number",
"Leave blank to use a shared sender number.": "Leave blank to use a shared sender number.",
"Octopush API Version": "Octopush API Version",
"Legacy Octopush-DM": "Legacy Octopush-DM",
"octopushEndpoint": "octopush (endpoint: {url})",
"legacyOctopushEndpoint": "Legacy Octopush-DM (endpoint: {url})",
"ntfy Topic": "ntfy Topic",
"Server URL should not contain the nfty topic": "Server URL should not contain the nfty topic",
"onebotHttpAddress": "OneBot HTTP Address",
@ -1254,6 +1265,8 @@
"Matomo": "Matomo",
"Umami": "Umami",
"Disable URL in Notification": "Disable URL in Notification",
"Suppress Notifications": "Suppress Notifications",
"discordSuppressNotificationsHelptext": "When enabled, messages will be posted to the channel but won't trigger push or desktop notifications for recipients.",
"Ip Family": "IP Family",
"ipFamilyDescriptionAutoSelect": "Uses the {happyEyeballs} for determining the IP family.",
"Happy Eyeballs algorithm": "Happy Eyeballs algorithm",
@ -1284,9 +1297,8 @@
"domainExpiryDescription": "Trigger notification when domain names expires in:",
"domain_expiry_unsupported_monitor_type": "Domain expiry monitoring is not supported for this monitor type",
"domain_expiry_unsupported_missing_target": "No valid domain or hostname is configured for this monitor",
"domain_expiry_unsupported_invalid_domain": "The configured value \"{hostname}\" is not a valid domain name",
"domain_expiry_public_suffix_too_short": "\".{publicSuffix}\" is too short for a top level domain",
"domain_expiry_unsupported_public_suffix": "The domain \"{domain}\" does not have a valid public suffix",
"domain_expiry_unsupported_is_icann": "The domain \"{domain}\" is not a candidate for domain expiry monitoring, because its public suffix \".{publicSuffix}\" is not ICAN",
"domain_expiry_unsupported_is_ip": "\"{hostname}\" is an IP address. Domain expiry monitoring requires a domain name",
"domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint": "Domain expiry monitoring is not available for \".{publicSuffix}\" because no RDAP service is listed by IANA",
"minimumIntervalWarning": "Intervals below 20 seconds may result in poor performance.",

View File

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

@ -1514,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") }}
@ -2184,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

@ -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,8 +10,8 @@
*/
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";
@ -43,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";

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

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

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