Compare commits
4 Commits
master
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a59af96ae2 | ||
|
|
c82b612763 | ||
|
|
533abf08f7 | ||
|
|
1d817b6269 |
4
.github/workflows/auto-test.yml
vendored
4
.github/workflows/auto-test.yml
vendored
@ -1,9 +1,5 @@
|
||||
name: Auto Test
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-server
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, 1.23.X, 3.0.0]
|
||||
|
||||
4
.github/workflows/autofix.yml
vendored
4
.github/workflows/autofix.yml
vendored
@ -30,10 +30,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Update RDAP DNS data from IANA
|
||||
run: wget -O server/model/rdap-dns.json https://data.iana.org/rdap/dns.json
|
||||
continue-on-error: true
|
||||
|
||||
- name: Auto-fix JavaScript/Vue linting issues
|
||||
run: npm run lint-fix:js
|
||||
continue-on-error: true
|
||||
|
||||
3
.github/workflows/beta-release.yml
vendored
3
.github/workflows/beta-release.yml
vendored
@ -50,8 +50,7 @@ jobs:
|
||||
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
|
||||
# Create new branch from master
|
||||
git checkout -b "release-${VERSION}"
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
48
.github/workflows/build-docker-base.yml
vendored
48
.github/workflows/build-docker-base.yml
vendored
@ -1,48 +0,0 @@
|
||||
name: Build Docker Base Images
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
build-docker-base:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with: { persist-credentials: false }
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ secrets.GHCR_USERNAME }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Build and push base2-slim image
|
||||
run: npm run build-docker-base-slim
|
||||
|
||||
- name: Build and push base2 image
|
||||
run: npm run build-docker-base
|
||||
@ -1,65 +0,0 @@
|
||||
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"
|
||||
@ -36,9 +36,6 @@ export default defineConfig({
|
||||
srcDir: "src",
|
||||
filename: "serviceWorker.ts",
|
||||
strategies: "injectManifest",
|
||||
injectManifest: {
|
||||
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, // 3 MiB
|
||||
},
|
||||
}),
|
||||
],
|
||||
css: {
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
exports.up = async function (knex) {
|
||||
await knex.schema.alterTable("monitor", (table) => {
|
||||
table.string("snmp_v3_username", 255);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
await knex.schema.alterTable("monitor", (table) => {
|
||||
table.dropColumn("snmp_v3_username");
|
||||
});
|
||||
};
|
||||
@ -1,11 +0,0 @@
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.alterTable("monitor", function (table) {
|
||||
table.integer("screenshot_delay").notNullable().unsigned().defaultTo(0);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.alterTable("monitor", function (table) {
|
||||
table.dropColumn("screenshot_delay");
|
||||
});
|
||||
};
|
||||
@ -1,6 +1,3 @@
|
||||
import { createRequire } from "module";
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const pkg = require("../../package.json");
|
||||
const fs = require("fs");
|
||||
const childProcess = require("child_process");
|
||||
@ -61,13 +58,8 @@ function commit(version) {
|
||||
throw new Error("commit error");
|
||||
}
|
||||
|
||||
// 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" });
|
||||
res = childProcess.spawnSync("git", ["push", "origin", "master"]);
|
||||
console.log(res.stdout.toString().trim());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
import * as childProcess from "child_process";
|
||||
|
||||
const ignoreList = ["louislam", "CommanderStorm", "UptimeKumaBot", "weblate", "Copilot", "@autofix-ci[bot]"];
|
||||
const ignoreList = ["louislam", "CommanderStorm", "UptimeKumaBot", "weblate", "Copilot"];
|
||||
|
||||
const mergeList = ["Translations Update from Weblate", "Update dependencies"];
|
||||
|
||||
|
||||
@ -48,7 +48,7 @@ checkDocker();
|
||||
await checkTagExists(repoNames, version);
|
||||
|
||||
// node extra/beta/update-version.js
|
||||
await import("../beta/update-version.mjs");
|
||||
execSync("node ./extra/beta/update-version.js");
|
||||
|
||||
// Create Pull Request (gh pr create will handle pushing the branch)
|
||||
await createReleasePR(version, previousVersion, dryRun, branchName, githubRunId);
|
||||
|
||||
1875
package-lock.json
generated
1875
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uptime-kuma",
|
||||
"version": "2.1.0-beta.2",
|
||||
"version": "2.1.0-beta.1",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -122,6 +122,7 @@
|
||||
"nanoid": "~3.3.4",
|
||||
"net-snmp": "^3.11.2",
|
||||
"node-cloudflared-tunnel": "~1.0.9",
|
||||
"node-fetch-cache": "^5.1.0",
|
||||
"node-radius-utils": "~1.2.0",
|
||||
"nodemailer": "~7.0.12",
|
||||
"nostr-tools": "^2.17.0",
|
||||
@ -158,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.1.3",
|
||||
"@fortawesome/vue-fontawesome": "~3.0.0-5",
|
||||
"@playwright/test": "~1.39.0",
|
||||
"@popperjs/core": "~2.10.2",
|
||||
"@testcontainers/hivemq": "^10.13.1",
|
||||
@ -193,8 +194,8 @@
|
||||
"favico.js": "~0.3.10",
|
||||
"get-port-please": "^3.1.1",
|
||||
"node-ssh": "~13.1.0",
|
||||
"postcss-html": "~1.8.1",
|
||||
"postcss-rtlcss": "~5.7.1",
|
||||
"postcss-html": "~1.5.0",
|
||||
"postcss-rtlcss": "~3.7.2",
|
||||
"postcss-scss": "~4.0.4",
|
||||
"prettier": "^3.7.4",
|
||||
"prismjs": "~1.30.0",
|
||||
@ -206,17 +207,17 @@
|
||||
"stylelint-config-standard": "~25.0.0",
|
||||
"terser": "~5.15.0",
|
||||
"test": "~3.3.0",
|
||||
"testcontainers": "^11.5.0",
|
||||
"testcontainers": "^10.13.1",
|
||||
"typescript": "~4.4.4",
|
||||
"v-pagination-3": "~0.1.7",
|
||||
"vite": "~5.4.15",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vue": "~3.5.26",
|
||||
"vue": "~3.4.2",
|
||||
"vue-chartjs": "~5.2.0",
|
||||
"vue-confirm-dialog": "~1.0.2",
|
||||
"vue-contenteditable": "~3.0.4",
|
||||
"vue-i18n": "~11.2.8",
|
||||
"vue-i18n": "~9.14.3",
|
||||
"vue-image-crop-upload": "~3.0.3",
|
||||
"vue-multiselect": "~3.0.0-alpha.2",
|
||||
"vue-prism-editor": "~2.0.0-alpha.2",
|
||||
|
||||
@ -5,7 +5,7 @@ const { escape } = require("html-escaper");
|
||||
* Returns a string that represents the javascript that is required to insert the Plausible Analytics script
|
||||
* into a webpage.
|
||||
* @param {string} scriptUrl the Plausible Analytics script url.
|
||||
* @param {string} domainsToMonitor Domains to track separated by a ',' to add Plausible Analytics script.
|
||||
* @param {string} domainsToMonitor Domains to track seperated by a ',' to add Plausible Analytics script.
|
||||
* @returns {string} HTML script tags to inject into page
|
||||
*/
|
||||
function getPlausibleAnalyticsScript(scriptUrl, domainsToMonitor) {
|
||||
|
||||
@ -165,7 +165,7 @@ class Database {
|
||||
* Read the database config
|
||||
* @throws {Error} If the config is invalid
|
||||
* @typedef {string|undefined} envString
|
||||
* @returns {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString, socketPath:envString}} Database config
|
||||
* @returns {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} Database config
|
||||
*/
|
||||
static readDBConfig() {
|
||||
let dbConfig;
|
||||
@ -185,7 +185,7 @@ class Database {
|
||||
|
||||
/**
|
||||
* @typedef {string|undefined} envString
|
||||
* @param {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString, socketPath:envString}} dbConfig the database configuration that should be written
|
||||
* @param {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} dbConfig the database configuration that should be written
|
||||
* @returns {void}
|
||||
*/
|
||||
static writeDBConfig(dbConfig) {
|
||||
@ -284,7 +284,6 @@ class Database {
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
socketPath: dbConfig.socketPath,
|
||||
...(dbConfig.ssl
|
||||
? {
|
||||
ssl: {
|
||||
@ -310,7 +309,6 @@ class Database {
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.dbName,
|
||||
socketPath: dbConfig.socketPath,
|
||||
timezone: "Z",
|
||||
typeCast: function (field, next) {
|
||||
if (field.type === "DATETIME") {
|
||||
|
||||
@ -4,25 +4,36 @@ const { log, TYPES_WITH_DOMAIN_EXPIRY_SUPPORT_VIA_FIELD } = require("../../src/u
|
||||
const { parse: parseTld } = require("tldts");
|
||||
const { setting, setSetting } = require("../util-server");
|
||||
const { Notification } = require("../notification");
|
||||
const { default: NodeFetchCache, MemoryCache } = require("node-fetch-cache");
|
||||
const TranslatableError = require("../translatable-error");
|
||||
const dayjs = require("dayjs");
|
||||
|
||||
// Load static RDAP DNS data from local file (auto-updated by CI)
|
||||
const rdapDnsData = require("./rdap-dns.json");
|
||||
const cachedFetch = process.env.NODE_ENV
|
||||
? NodeFetchCache.create({
|
||||
// cache for 8h
|
||||
cache: new MemoryCache({ ttl: 1000 * 60 * 60 * 8 }),
|
||||
})
|
||||
: fetch;
|
||||
|
||||
/**
|
||||
* Find the RDAP server for a given TLD
|
||||
* @param {string} tld TLD
|
||||
* @returns {string|null} First RDAP server found
|
||||
* @returns {Promise<string>} First RDAP server found
|
||||
*/
|
||||
function getRdapServer(tld) {
|
||||
const services = rdapDnsData["services"] ?? [];
|
||||
const rootTld = tld?.split(".").pop();
|
||||
if (rootTld) {
|
||||
for (const [tlds, urls] of services) {
|
||||
if (tlds.includes(rootTld)) {
|
||||
return urls[0];
|
||||
}
|
||||
async function getRdapServer(tld) {
|
||||
let rdapList;
|
||||
try {
|
||||
const res = await cachedFetch("https://data.iana.org/rdap/dns.json");
|
||||
rdapList = await res.json();
|
||||
} catch (error) {
|
||||
log.debug("rdap", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const service of rdapList["services"]) {
|
||||
const [tlds, urls] = service;
|
||||
if (tlds.includes(tld)) {
|
||||
return urls[0];
|
||||
}
|
||||
}
|
||||
log.debug("rdap", `No RDAP server found for TLD ${tld}`);
|
||||
@ -36,7 +47,7 @@ function getRdapServer(tld) {
|
||||
*/
|
||||
async function getRdapDomainExpiryDate(domain) {
|
||||
const tld = DomainExpiry.parseTld(domain).publicSuffix;
|
||||
const rdapServer = getRdapServer(tld);
|
||||
const rdapServer = await getRdapServer(tld);
|
||||
if (rdapServer === null) {
|
||||
log.warn("rdap", `No RDAP server found, TLD ${tld} not supported.`);
|
||||
return null;
|
||||
@ -148,32 +159,39 @@ 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 publicSuffix = tld.publicSuffix;
|
||||
const rootTld = publicSuffix.split(".").pop();
|
||||
const rdap = getRdapServer(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,
|
||||
publicSuffix: tld.publicSuffix,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
domain: tld.domain,
|
||||
tld: rootTld,
|
||||
tld: tld.publicSuffix,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,21 +1,9 @@
|
||||
const { BeanModel } = require("redbean-node/dist/bean-model");
|
||||
const { R } = require("redbean-node");
|
||||
const dayjs = require("dayjs");
|
||||
|
||||
class Incident extends BeanModel {
|
||||
/**
|
||||
* Resolve the incident and mark it as inactive
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async resolve() {
|
||||
this.active = false;
|
||||
this.pin = false;
|
||||
this.last_updated_date = R.isoDateTime(dayjs.utc());
|
||||
await R.store(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an object that ready to parse to JSON for public
|
||||
* Only show necessary data to public
|
||||
* @returns {object} Object ready to parse
|
||||
*/
|
||||
toPublicJSON() {
|
||||
@ -24,11 +12,9 @@ class Incident extends BeanModel {
|
||||
style: this.style,
|
||||
title: this.title,
|
||||
content: this.content,
|
||||
pin: !!this.pin,
|
||||
active: !!this.active,
|
||||
createdDate: this.created_date,
|
||||
lastUpdatedDate: this.last_updated_date,
|
||||
status_page_id: this.status_page_id,
|
||||
pin: this.pin,
|
||||
createdDate: this.createdDate,
|
||||
lastUpdatedDate: this.lastUpdatedDate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1059,15 +1059,7 @@ class Monitor extends BeanModel {
|
||||
log.debug("monitor", `Failed getting expiration date for domain ${supportInfo.domain}`);
|
||||
}
|
||||
} catch (error) {
|
||||
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.`
|
||||
);
|
||||
}
|
||||
// purposely not logged due to noise. Is accessible via checkMointor
|
||||
}
|
||||
}
|
||||
|
||||
@ -1505,46 +1497,24 @@ class Monitor extends BeanModel {
|
||||
|
||||
let msg = `[${monitor.name}] [${text}] ${bean.msg}`;
|
||||
|
||||
const heartbeatJSON = await bean.toJSONAsync({ decodeResponse: true });
|
||||
const monitorData = [{ id: monitor.id, active: monitor.active, name: monitor.name }];
|
||||
const preloadData = await Monitor.preparePreloadData(monitorData);
|
||||
// Prevent if the msg is undefined, notifications such as Discord cannot send out.
|
||||
if (!heartbeatJSON["msg"]) {
|
||||
heartbeatJSON["msg"] = "N/A";
|
||||
}
|
||||
|
||||
// Also provide the time in server timezone
|
||||
heartbeatJSON["timezone"] = await UptimeKumaServer.getInstance().getTimezone();
|
||||
heartbeatJSON["timezoneOffset"] = UptimeKumaServer.getInstance().getTimezoneOffset();
|
||||
heartbeatJSON["localDateTime"] = dayjs
|
||||
.utc(heartbeatJSON["time"])
|
||||
.tz(heartbeatJSON["timezone"])
|
||||
.format(SQL_DATETIME_FORMAT);
|
||||
|
||||
// Calculate downtime tracking information when service comes back up
|
||||
// This makes downtime information available to all notification providers
|
||||
if (bean.status === UP && monitor.id) {
|
||||
try {
|
||||
const lastDownHeartbeat = await R.getRow(
|
||||
"SELECT time FROM heartbeat WHERE monitor_id = ? AND status = ? ORDER BY time DESC LIMIT 1",
|
||||
[monitor.id, DOWN]
|
||||
);
|
||||
|
||||
if (lastDownHeartbeat && lastDownHeartbeat.time) {
|
||||
heartbeatJSON["lastDownTime"] = lastDownHeartbeat.time;
|
||||
}
|
||||
} catch (error) {
|
||||
// If we can't calculate downtime, just continue without it
|
||||
// Silently fail to avoid disrupting notification sending
|
||||
log.debug(
|
||||
"monitor",
|
||||
`[${monitor.name}] Could not calculate downtime information: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (let notification of notificationList) {
|
||||
try {
|
||||
const heartbeatJSON = await bean.toJSONAsync({ decodeResponse: true });
|
||||
const monitorData = [{ id: monitor.id, active: monitor.active, name: monitor.name }];
|
||||
const preloadData = await Monitor.preparePreloadData(monitorData);
|
||||
// Prevent if the msg is undefined, notifications such as Discord cannot send out.
|
||||
if (!heartbeatJSON["msg"]) {
|
||||
heartbeatJSON["msg"] = "N/A";
|
||||
}
|
||||
|
||||
// Also provide the time in server timezone
|
||||
heartbeatJSON["timezone"] = await UptimeKumaServer.getInstance().getTimezone();
|
||||
heartbeatJSON["timezoneOffset"] = UptimeKumaServer.getInstance().getTimezoneOffset();
|
||||
heartbeatJSON["localDateTime"] = dayjs
|
||||
.utc(heartbeatJSON["time"])
|
||||
.tz(heartbeatJSON["timezone"])
|
||||
.format(SQL_DATETIME_FORMAT);
|
||||
|
||||
await Notification.send(
|
||||
JSON.parse(notification.config),
|
||||
msg,
|
||||
@ -1746,55 +1716,6 @@ class Monitor extends BeanModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate JSON fields to prevent invalid JSON from being stored in database
|
||||
if (this.kafkaProducerBrokers) {
|
||||
try {
|
||||
JSON.parse(this.kafkaProducerBrokers);
|
||||
} catch (e) {
|
||||
throw new Error(`Kafka Producer Brokers must be valid JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.kafkaProducerSaslOptions) {
|
||||
try {
|
||||
JSON.parse(this.kafkaProducerSaslOptions);
|
||||
} catch (e) {
|
||||
throw new Error(`Kafka Producer SASL Options must be valid JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.rabbitmqNodes) {
|
||||
try {
|
||||
JSON.parse(this.rabbitmqNodes);
|
||||
} catch (e) {
|
||||
throw new Error(`RabbitMQ Nodes must be valid JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.conditions) {
|
||||
try {
|
||||
JSON.parse(this.conditions);
|
||||
} catch (e) {
|
||||
throw new Error(`Conditions must be valid JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.headers) {
|
||||
try {
|
||||
JSON.parse(this.headers);
|
||||
} catch (e) {
|
||||
throw new Error(`Headers must be valid JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.accepted_statuscodes_json) {
|
||||
try {
|
||||
JSON.parse(this.accepted_statuscodes_json);
|
||||
} catch (e) {
|
||||
throw new Error(`Accepted status codes must be valid JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.type === "ping") {
|
||||
// ping parameters validation
|
||||
if (this.packetSize && (this.packetSize < PING_PACKET_SIZE_MIN || this.packetSize > PING_PACKET_SIZE_MAX)) {
|
||||
@ -1835,28 +1756,6 @@ class Monitor extends BeanModel {
|
||||
this.timeout = pingGlobalTimeout;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.type === "real-browser") {
|
||||
// screenshot_delay validation
|
||||
if (this.screenshot_delay !== undefined && this.screenshot_delay !== null) {
|
||||
const delay = Number(this.screenshot_delay);
|
||||
if (isNaN(delay) || delay < 0) {
|
||||
throw new Error("Screenshot delay must be a non-negative number");
|
||||
}
|
||||
|
||||
// Must not exceed 0.8 * timeout (page.goto timeout is interval * 1000 * 0.8)
|
||||
const maxDelayFromTimeout = this.interval * 1000 * 0.8;
|
||||
if (delay >= maxDelayFromTimeout) {
|
||||
throw new Error(`Screenshot delay must be less than ${maxDelayFromTimeout}ms (0.8 × interval)`);
|
||||
}
|
||||
|
||||
// Must not exceed 0.5 * interval to prevent blocking next check
|
||||
const maxDelayFromInterval = this.interval * 1000 * 0.5;
|
||||
if (delay >= maxDelayFromInterval) {
|
||||
throw new Error(`Screenshot delay must be less than ${maxDelayFromInterval}ms (0.5 × interval)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -7,8 +7,8 @@ const analytics = require("../analytics/analytics");
|
||||
const { marked } = require("marked");
|
||||
const { Feed } = require("feed");
|
||||
const config = require("../config");
|
||||
|
||||
const { setting } = require("../util-server");
|
||||
|
||||
const {
|
||||
STATUS_PAGE_ALL_DOWN,
|
||||
STATUS_PAGE_ALL_UP,
|
||||
@ -17,7 +17,6 @@ const {
|
||||
UP,
|
||||
MAINTENANCE,
|
||||
DOWN,
|
||||
INCIDENT_PAGE_SIZE,
|
||||
} = require("../../src/util");
|
||||
|
||||
class StatusPage extends BeanModel {
|
||||
@ -308,13 +307,12 @@ class StatusPage extends BeanModel {
|
||||
static async getStatusPageData(statusPage) {
|
||||
const config = await statusPage.toPublicJSON();
|
||||
|
||||
// All active incidents
|
||||
let incidents = await R.find(
|
||||
"incident",
|
||||
" pin = 1 AND active = 1 AND status_page_id = ? ORDER BY created_date DESC",
|
||||
[statusPage.id]
|
||||
);
|
||||
incidents = incidents.map((i) => i.toPublicJSON());
|
||||
// Incident
|
||||
let incident = await R.findOne("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [statusPage.id]);
|
||||
|
||||
if (incident) {
|
||||
incident = incident.toPublicJSON();
|
||||
}
|
||||
|
||||
let maintenanceList = await StatusPage.getMaintenanceList(statusPage.id);
|
||||
|
||||
@ -332,7 +330,7 @@ class StatusPage extends BeanModel {
|
||||
// Response
|
||||
return {
|
||||
config,
|
||||
incidents,
|
||||
incident,
|
||||
publicGroupList,
|
||||
maintenanceList,
|
||||
};
|
||||
@ -501,54 +499,6 @@ class StatusPage extends BeanModel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated incident history for a status page using cursor-based pagination
|
||||
* @param {number} statusPageId ID of the status page
|
||||
* @param {string|null} cursor ISO date string cursor (created_date of last item from previous page)
|
||||
* @param {boolean} isPublic Whether to return public or admin data
|
||||
* @returns {Promise<object>} Paginated incident data with cursor
|
||||
*/
|
||||
static async getIncidentHistory(statusPageId, cursor = null, isPublic = true) {
|
||||
let incidents;
|
||||
|
||||
if (cursor) {
|
||||
incidents = await R.find(
|
||||
"incident",
|
||||
" status_page_id = ? AND created_date < ? ORDER BY created_date DESC LIMIT ? ",
|
||||
[statusPageId, cursor, INCIDENT_PAGE_SIZE]
|
||||
);
|
||||
} else {
|
||||
incidents = await R.find("incident", " status_page_id = ? ORDER BY created_date DESC LIMIT ? ", [
|
||||
statusPageId,
|
||||
INCIDENT_PAGE_SIZE,
|
||||
]);
|
||||
}
|
||||
|
||||
const total = await R.count("incident", " status_page_id = ? ", [statusPageId]);
|
||||
|
||||
const lastIncident = incidents[incidents.length - 1];
|
||||
let nextCursor = null;
|
||||
let hasMore = false;
|
||||
|
||||
if (lastIncident) {
|
||||
const moreCount = await R.count("incident", " status_page_id = ? AND created_date < ? ", [
|
||||
statusPageId,
|
||||
lastIncident.created_date,
|
||||
]);
|
||||
hasMore = moreCount > 0;
|
||||
if (hasMore) {
|
||||
nextCursor = lastIncident.created_date;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
incidents: incidents.map((i) => i.toPublicJSON()),
|
||||
total,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of maintenances
|
||||
* @param {number} statusPageId ID of status page to get maintenance for
|
||||
|
||||
@ -3,61 +3,26 @@ 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);
|
||||
|
||||
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}`);
|
||||
}
|
||||
heartbeat.msg = "";
|
||||
heartbeat.status = UP;
|
||||
heartbeat.ping = dayjs().valueOf() - startTime;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -111,75 +76,6 @@ 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 = {
|
||||
|
||||
@ -269,11 +269,6 @@ class RealBrowserMonitorType extends MonitorType {
|
||||
timeout: monitor.interval * 1000 * 0.8,
|
||||
});
|
||||
|
||||
// Wait for additional time before taking screenshot if configured
|
||||
if (monitor.screenshot_delay > 0) {
|
||||
await page.waitForTimeout(monitor.screenshot_delay);
|
||||
}
|
||||
|
||||
let filename = jwt.sign(monitor.id, server.jwtSecret) + ".png";
|
||||
|
||||
await page.screenshot({
|
||||
|
||||
@ -17,22 +17,7 @@ class SNMPMonitorType extends MonitorType {
|
||||
timeout: monitor.timeout * 1000,
|
||||
version: snmp.Version[monitor.snmpVersion],
|
||||
};
|
||||
|
||||
if (monitor.snmpVersion === "3") {
|
||||
if (!monitor.snmp_v3_username) {
|
||||
throw new Error("SNMPv3 username is required");
|
||||
}
|
||||
// SNMPv3 currently defaults to noAuthNoPriv.
|
||||
// Supporting authNoPriv / authPriv requires additional inputs
|
||||
// (auth/priv protocols, passwords), validation, secure storage,
|
||||
// and database migrations, which is intentionally left for
|
||||
// a follow-up PR to keep this change scoped.
|
||||
sessionOptions.securityLevel = snmp.SecurityLevel.noAuthNoPriv;
|
||||
sessionOptions.username = monitor.snmp_v3_username;
|
||||
session = snmp.createV3Session(monitor.hostname, monitor.snmp_v3_username, sessionOptions);
|
||||
} else {
|
||||
session = snmp.createSession(monitor.hostname, monitor.radiusPassword, sessionOptions);
|
||||
}
|
||||
session = snmp.createSession(monitor.hostname, monitor.radiusPassword, sessionOptions);
|
||||
|
||||
// Handle errors during session creation
|
||||
session.on("error", (error) => {
|
||||
|
||||
@ -11,11 +11,6 @@ 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";
|
||||
@ -46,9 +41,6 @@ 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;
|
||||
}
|
||||
@ -56,8 +48,6 @@ class Discord extends NotificationProvider {
|
||||
// If heartbeatJSON is not null, we go into the normal alerting loop.
|
||||
let addess = this.extractAddress(monitorJSON);
|
||||
if (heartbeatJSON["status"] === DOWN) {
|
||||
const wentOfflineTimestamp = Math.floor(new Date(heartbeatJSON["time"]).getTime() / 1000);
|
||||
|
||||
let discorddowndata = {
|
||||
username: discordDisplayName,
|
||||
embeds: [
|
||||
@ -78,11 +68,6 @@ class Discord extends NotificationProvider {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "Went Offline",
|
||||
// F for full date/time
|
||||
value: `<t:${wentOfflineTimestamp}:F>`,
|
||||
},
|
||||
{
|
||||
name: `Time (${heartbeatJSON["timezone"]})`,
|
||||
value: heartbeatJSON["localDateTime"],
|
||||
@ -104,21 +89,10 @@ 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;
|
||||
} else if (heartbeatJSON["status"] === UP) {
|
||||
const backOnlineTimestamp = Math.floor(new Date(heartbeatJSON["time"]).getTime() / 1000);
|
||||
let downtimeDuration = null;
|
||||
let wentOfflineTimestamp = null;
|
||||
if (heartbeatJSON["lastDownTime"]) {
|
||||
wentOfflineTimestamp = Math.floor(new Date(heartbeatJSON["lastDownTime"]).getTime() / 1000);
|
||||
downtimeDuration = this.formatDuration(backOnlineTimestamp - wentOfflineTimestamp);
|
||||
}
|
||||
|
||||
let discordupdata = {
|
||||
username: discordDisplayName,
|
||||
embeds: [
|
||||
@ -139,23 +113,10 @@ class Discord extends NotificationProvider {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(wentOfflineTimestamp
|
||||
? [
|
||||
{
|
||||
name: "Went Offline",
|
||||
// F for full date/time
|
||||
value: `<t:${wentOfflineTimestamp}:F>`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(downtimeDuration
|
||||
? [
|
||||
{
|
||||
name: "Downtime Duration",
|
||||
value: downtimeDuration,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: `Time (${heartbeatJSON["timezone"]})`,
|
||||
value: heartbeatJSON["localDateTime"],
|
||||
},
|
||||
...(heartbeatJSON["ping"] != null
|
||||
? [
|
||||
{
|
||||
@ -179,9 +140,6 @@ 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;
|
||||
@ -190,32 +148,6 @@ class Discord extends NotificationProvider {
|
||||
this.throwGeneralAxiosError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration as human-readable string (e.g., "1h 23m", "45m 30s")
|
||||
* TODO: Update below to `Intl.DurationFormat("en", { style: "short" }).format(duration)` once we are on a newer node version
|
||||
* @param {number} timeInSeconds The time in seconds to format a duration for
|
||||
* @returns {string} The formatted duration
|
||||
*/
|
||||
formatDuration(timeInSeconds) {
|
||||
const hours = Math.floor(timeInSeconds / 3600);
|
||||
const minutes = Math.floor((timeInSeconds % 3600) / 60);
|
||||
const seconds = timeInSeconds % 60;
|
||||
|
||||
const durationParts = [];
|
||||
if (hours > 0) {
|
||||
durationParts.push(`${hours}h`);
|
||||
}
|
||||
if (minutes > 0) {
|
||||
durationParts.push(`${minutes}m`);
|
||||
}
|
||||
if (seconds > 0 && hours === 0) {
|
||||
// Only show seconds if less than an hour
|
||||
durationParts.push(`${seconds}s`);
|
||||
}
|
||||
|
||||
return durationParts.length > 0 ? durationParts.join(" ") : "0s";
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Discord;
|
||||
|
||||
@ -57,19 +57,6 @@ class Ntfy extends NotificationProvider {
|
||||
status = "Up";
|
||||
}
|
||||
}
|
||||
|
||||
// Include monitor's assigned tags
|
||||
if (monitorJSON && monitorJSON.tags && Array.isArray(monitorJSON.tags)) {
|
||||
const monitorTagNames = monitorJSON.tags.map((tag) => {
|
||||
// Include value if it exists
|
||||
if (tag.value) {
|
||||
return `${tag.name}: ${tag.value}`;
|
||||
}
|
||||
return tag.name;
|
||||
});
|
||||
tags = tags.concat(monitorTagNames);
|
||||
}
|
||||
|
||||
let data = {
|
||||
topic: notification.ntfytopic,
|
||||
message: heartbeatJSON.msg,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
const nodemailer = require("nodemailer");
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const { log } = require("../../src/util");
|
||||
|
||||
class SMTP extends NotificationProvider {
|
||||
name = "smtp";
|
||||
@ -15,24 +14,10 @@ class SMTP extends NotificationProvider {
|
||||
host: notification.smtpHost,
|
||||
port: notification.smtpPort,
|
||||
secure: notification.smtpSecure,
|
||||
};
|
||||
|
||||
// Handle TLS/STARTTLS options
|
||||
if (!notification.smtpSecure && notification.smtpIgnoreSTARTTLS) {
|
||||
// Disable STARTTLS completely for servers that don't support it
|
||||
// Connection will remain unencrypted
|
||||
log.warn(
|
||||
"notification",
|
||||
`SMTP notification using unencrypted connection (STARTTLS disabled) to ${notification.smtpHost}:${notification.smtpPort}`
|
||||
);
|
||||
config.ignoreTLS = true;
|
||||
} else {
|
||||
// SMTPS (implicit TLS on port 465)
|
||||
// or STARTTLS (default behavior for ports 25, 587)
|
||||
config.tls = {
|
||||
tls: {
|
||||
rejectUnauthorized: !notification.smtpIgnoreTLSError || false,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Fix #1129
|
||||
if (notification.smtpDkimDomain) {
|
||||
|
||||
@ -18,7 +18,7 @@ class WeCom extends NotificationProvider {
|
||||
},
|
||||
};
|
||||
config = this.getAxiosConfigWithProxy(config);
|
||||
let body = this.composeMessage(notification, heartbeatJSON, msg);
|
||||
let body = this.composeMessage(heartbeatJSON, msg);
|
||||
await axios.post(
|
||||
`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${notification.weComBotKey}`,
|
||||
body,
|
||||
@ -32,12 +32,11 @@ class WeCom extends NotificationProvider {
|
||||
|
||||
/**
|
||||
* Generate the message to send
|
||||
* @param {object} notification Notification configuration
|
||||
* @param {object} heartbeatJSON Heartbeat details (For Up/Down only)
|
||||
* @param {string} msg General message
|
||||
* @returns {object} Message
|
||||
*/
|
||||
composeMessage(notification, heartbeatJSON, msg) {
|
||||
composeMessage(heartbeatJSON, msg) {
|
||||
let title = "UptimeKuma Message";
|
||||
if (msg != null && heartbeatJSON != null && heartbeatJSON["status"] === UP) {
|
||||
title = "UptimeKuma Monitor Up";
|
||||
@ -45,26 +44,11 @@ class WeCom extends NotificationProvider {
|
||||
if (msg != null && heartbeatJSON != null && heartbeatJSON["status"] === DOWN) {
|
||||
title = "UptimeKuma Monitor Down";
|
||||
}
|
||||
|
||||
let textObj = {
|
||||
content: title + "\n" + msg,
|
||||
};
|
||||
|
||||
// Handle mentioned_mobile_list if configured
|
||||
if (notification.weComMentionedMobileList?.trim()) {
|
||||
let mentionedMobiles = notification.weComMentionedMobileList
|
||||
.split(",")
|
||||
.map((mobile) => mobile.trim())
|
||||
.filter((mobile) => mobile.length > 0);
|
||||
|
||||
if (mentionedMobiles.length > 0) {
|
||||
textObj.mentioned_mobile_list = mentionedMobiles;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
msgtype: "text",
|
||||
text: textObj,
|
||||
text: {
|
||||
content: title + "\n" + msg,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,30 +142,6 @@ router.get("/api/status-page/:slug/manifest.json", cache("1440 minutes"), async
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/api/status-page/:slug/incident-history", cache("5 minutes"), async (request, response) => {
|
||||
allowDevAllOrigin(response);
|
||||
|
||||
try {
|
||||
let slug = request.params.slug;
|
||||
slug = slug.toLowerCase();
|
||||
let statusPageID = await StatusPage.slugToID(slug);
|
||||
|
||||
if (!statusPageID) {
|
||||
sendHttpError(response, "Status Page Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
const cursor = request.query.cursor || null;
|
||||
const result = await StatusPage.getIncidentHistory(statusPageID, cursor, true);
|
||||
response.json({
|
||||
ok: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
sendHttpError(response, error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// overall status-page status badge
|
||||
router.get("/api/status-page/:slug/badge", cache("5 minutes"), async (request, response) => {
|
||||
allowDevAllOrigin(response);
|
||||
|
||||
@ -102,7 +102,6 @@ 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.socketPath = process.env.UPTIME_KUMA_DB_SOCKET?.trim();
|
||||
dbConfig.ssl = getEnvOrFile("UPTIME_KUMA_DB_SSL")?.toLowerCase() === "true";
|
||||
dbConfig.ca = getEnvOrFile("UPTIME_KUMA_DB_CA");
|
||||
Database.writeDBConfig(dbConfig);
|
||||
@ -161,7 +160,6 @@ class SetupDatabase {
|
||||
runningSetup: this.runningSetup,
|
||||
needSetup: this.needSetup,
|
||||
isEnabledEmbeddedMariaDB: this.isEnabledEmbeddedMariaDB(),
|
||||
isEnabledMariaDBSocket: process.env.UPTIME_KUMA_DB_SOCKET?.trim().length > 0,
|
||||
});
|
||||
});
|
||||
|
||||
@ -204,22 +202,16 @@ class SetupDatabase {
|
||||
|
||||
// External MariaDB
|
||||
if (dbConfig.type === "mariadb") {
|
||||
// If socketPath is provided and not empty, validate it
|
||||
if (process.env.UPTIME_KUMA_DB_SOCKET?.trim().length > 0) {
|
||||
dbConfig.socketPath = process.env.UPTIME_KUMA_DB_SOCKET.trim();
|
||||
} else {
|
||||
// socketPath not provided, hostname and port are required
|
||||
if (!dbConfig.hostname) {
|
||||
response.status(400).json("Hostname is required");
|
||||
this.runningSetup = false;
|
||||
return;
|
||||
}
|
||||
if (!dbConfig.hostname) {
|
||||
response.status(400).json("Hostname is required");
|
||||
this.runningSetup = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dbConfig.port) {
|
||||
response.status(400).json("Port is required");
|
||||
this.runningSetup = false;
|
||||
return;
|
||||
}
|
||||
if (!dbConfig.port) {
|
||||
response.status(400).json("Port is required");
|
||||
this.runningSetup = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dbConfig.dbName) {
|
||||
@ -249,7 +241,6 @@ class SetupDatabase {
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.dbName,
|
||||
socketPath: dbConfig.socketPath,
|
||||
...(dbConfig.ssl
|
||||
? {
|
||||
ssl: {
|
||||
|
||||
@ -8,21 +8,6 @@ const apicache = require("../modules/apicache");
|
||||
const StatusPage = require("../model/status_page");
|
||||
const { UptimeKumaServer } = require("../uptime-kuma-server");
|
||||
|
||||
/**
|
||||
* Validates incident data
|
||||
* @param {object} incident - The incident object
|
||||
* @returns {void}
|
||||
* @throws {Error} If validation fails
|
||||
*/
|
||||
function validateIncident(incident) {
|
||||
if (!incident.title || incident.title.trim() === "") {
|
||||
throw new Error("Please input title");
|
||||
}
|
||||
if (!incident.content || incident.content.trim() === "") {
|
||||
throw new Error("Please input content");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket handlers for status page
|
||||
* @param {Socket} socket Socket.io instance to add listeners on
|
||||
@ -40,6 +25,8 @@ module.exports.statusPageSocketHandler = (socket) => {
|
||||
throw new Error("slug is not found");
|
||||
}
|
||||
|
||||
await R.exec("UPDATE incident SET pin = 0 WHERE status_page_id = ? ", [statusPageID]);
|
||||
|
||||
let incidentBean;
|
||||
|
||||
if (incident.id) {
|
||||
@ -57,13 +44,12 @@ module.exports.statusPageSocketHandler = (socket) => {
|
||||
incidentBean.content = incident.content;
|
||||
incidentBean.style = incident.style;
|
||||
incidentBean.pin = true;
|
||||
incidentBean.active = true;
|
||||
incidentBean.status_page_id = statusPageID;
|
||||
|
||||
if (incident.id) {
|
||||
incidentBean.last_updated_date = R.isoDateTime(dayjs.utc());
|
||||
incidentBean.lastUpdatedDate = R.isoDateTime(dayjs.utc());
|
||||
} else {
|
||||
incidentBean.created_date = R.isoDateTime(dayjs.utc());
|
||||
incidentBean.createdDate = R.isoDateTime(dayjs.utc());
|
||||
}
|
||||
|
||||
await R.store(incidentBean);
|
||||
@ -99,171 +85,6 @@ module.exports.statusPageSocketHandler = (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("getIncidentHistory", async (slug, cursor, callback) => {
|
||||
try {
|
||||
let statusPageID = await StatusPage.slugToID(slug);
|
||||
if (!statusPageID) {
|
||||
throw new Error("slug is not found");
|
||||
}
|
||||
|
||||
const isPublic = !socket.userID;
|
||||
const result = await StatusPage.getIncidentHistory(statusPageID, cursor, isPublic);
|
||||
callback({
|
||||
ok: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("editIncident", async (slug, incidentID, incident, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
let statusPageID = await StatusPage.slugToID(slug);
|
||||
if (!statusPageID) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: "slug is not found",
|
||||
msgi18n: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let bean = await R.findOne("incident", " id = ? AND status_page_id = ? ", [incidentID, statusPageID]);
|
||||
if (!bean) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: "Incident not found or access denied",
|
||||
msgi18n: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
validateIncident(incident);
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
msgi18n: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const validStyles = ["info", "warning", "danger", "primary", "light", "dark"];
|
||||
if (!validStyles.includes(incident.style)) {
|
||||
incident.style = "warning";
|
||||
}
|
||||
|
||||
bean.title = incident.title;
|
||||
bean.content = incident.content;
|
||||
bean.style = incident.style;
|
||||
bean.pin = incident.pin !== false;
|
||||
bean.lastUpdatedDate = R.isoDateTime(dayjs.utc());
|
||||
|
||||
await R.store(bean);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Saved.",
|
||||
msgi18n: true,
|
||||
incident: bean.toPublicJSON(),
|
||||
});
|
||||
} catch (error) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: error.message,
|
||||
msgi18n: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("deleteIncident", async (slug, incidentID, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
let statusPageID = await StatusPage.slugToID(slug);
|
||||
if (!statusPageID) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: "slug is not found",
|
||||
msgi18n: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let bean = await R.findOne("incident", " id = ? AND status_page_id = ? ", [incidentID, statusPageID]);
|
||||
if (!bean) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: "Incident not found or access denied",
|
||||
msgi18n: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await R.trash(bean);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "successDeleted",
|
||||
msgi18n: true,
|
||||
});
|
||||
} catch (error) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: error.message,
|
||||
msgi18n: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("resolveIncident", async (slug, incidentID, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
let statusPageID = await StatusPage.slugToID(slug);
|
||||
if (!statusPageID) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: "slug is not found",
|
||||
msgi18n: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let bean = await R.findOne("incident", " id = ? AND status_page_id = ? ", [incidentID, statusPageID]);
|
||||
if (!bean) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: "Incident not found or access denied",
|
||||
msgi18n: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await bean.resolve();
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Resolved",
|
||||
msgi18n: true,
|
||||
incident: bean.toPublicJSON(),
|
||||
});
|
||||
} catch (error) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: error.message,
|
||||
msgi18n: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("getStatusPage", async (slug, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -40,13 +40,14 @@
|
||||
required
|
||||
/>
|
||||
|
||||
<i18n-t tag="div" keypath="Examples:" class="form-text">
|
||||
<div class="form-text">
|
||||
{{ $t("Examples") }}:
|
||||
<ul>
|
||||
<li><code>/var/run/docker.sock</code></li>
|
||||
<li><code>http://localhost:2375</code></li>
|
||||
<li><code>https://localhost:2376 (TLS)</code></li>
|
||||
<li>/var/run/docker.sock</li>
|
||||
<li>http://localhost:2375</li>
|
||||
<li>https://localhost:2376 (TLS)</li>
|
||||
</ul>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,119 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="shadow-box alert mb-4 p-4 incident"
|
||||
role="alert"
|
||||
:class="'bg-' + modelValue.style"
|
||||
data-testid="incident-edit"
|
||||
>
|
||||
<strong>{{ $t("Title") }}:</strong>
|
||||
<Editable
|
||||
:model-value="modelValue.title"
|
||||
tag="h4"
|
||||
:contenteditable="true"
|
||||
:noNL="true"
|
||||
class="alert-heading"
|
||||
data-testid="incident-title"
|
||||
@update:model-value="updateField('title', $event)"
|
||||
/>
|
||||
|
||||
<strong>{{ $t("Content") }}:</strong>
|
||||
<Editable
|
||||
:model-value="modelValue.content"
|
||||
tag="div"
|
||||
:contenteditable="true"
|
||||
class="content"
|
||||
data-testid="incident-content-editable"
|
||||
@update:model-value="updateField('content', $event)"
|
||||
/>
|
||||
<div class="form-text">
|
||||
{{ $t("markdownSupported") }}
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-light me-2" data-testid="post-incident-button" @click="$emit('post')">
|
||||
<font-awesome-icon icon="bullhorn" />
|
||||
{{ $t("Post") }}
|
||||
</button>
|
||||
|
||||
<button class="btn btn-light me-2" @click="$emit('cancel')">
|
||||
<font-awesome-icon icon="times" />
|
||||
{{ $t("Cancel") }}
|
||||
</button>
|
||||
|
||||
<div class="dropdown d-inline-block me-2">
|
||||
<button
|
||||
id="dropdownMenuButton1"
|
||||
class="btn btn-secondary dropdown-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
{{ $t("Style") }}: {{ $t(modelValue.style) }}
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'info')">
|
||||
{{ $t("info") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'warning')">
|
||||
{{ $t("warning") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'danger')">
|
||||
{{ $t("danger") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'primary')">
|
||||
{{ $t("primary") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'light')">
|
||||
{{ $t("light") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'dark')">
|
||||
{{ $t("dark") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "IncidentEditForm",
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "post", "cancel"],
|
||||
methods: {
|
||||
updateField(field, value) {
|
||||
this.$emit("update:modelValue", {
|
||||
...this.modelValue,
|
||||
[field]: value,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.incident {
|
||||
.content {
|
||||
&[contenteditable="true"] {
|
||||
min-height: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,154 +0,0 @@
|
||||
<template>
|
||||
<div class="incident-group" data-testid="incident-group">
|
||||
<div v-if="loading && incidents.length === 0" class="text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">{{ $t("Loading...") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="incidents.length === 0" class="text-center py-4 text-muted">
|
||||
{{ $t("No incidents recorded") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="incident-list">
|
||||
<div
|
||||
v-for="incident in incidents"
|
||||
:key="incident.id"
|
||||
class="incident-item"
|
||||
:class="{ resolved: !incident.active }"
|
||||
>
|
||||
<div class="incident-style-indicator" :class="`bg-${incident.style}`"></div>
|
||||
<div class="incident-body">
|
||||
<div class="incident-header d-flex justify-content-between align-items-start">
|
||||
<h5 class="incident-title mb-0">{{ incident.title }}</h5>
|
||||
<div v-if="editMode" class="incident-actions">
|
||||
<button
|
||||
v-if="incident.active"
|
||||
class="btn btn-success btn-sm me-1"
|
||||
:title="$t('Resolve')"
|
||||
@click="$emit('resolve-incident', incident)"
|
||||
>
|
||||
<font-awesome-icon icon="check" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm me-1"
|
||||
:title="$t('Edit')"
|
||||
@click="$emit('edit-incident', incident)"
|
||||
>
|
||||
<font-awesome-icon icon="edit" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
:title="$t('Delete')"
|
||||
@click="$emit('delete-incident', incident)"
|
||||
>
|
||||
<font-awesome-icon icon="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vue/no-v-html-->
|
||||
<div class="incident-content mt-1" v-html="getIncidentHTML(incident.content)"></div>
|
||||
<div class="incident-meta text-muted small mt-2">
|
||||
<div>{{ $t("createdAt", { date: datetime(incident.createdDate) }) }}</div>
|
||||
<div v-if="incident.lastUpdatedDate">
|
||||
{{ $t("lastUpdatedAt", { date: datetime(incident.lastUpdatedDate) }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import datetimeMixin from "../mixins/datetime";
|
||||
|
||||
export default {
|
||||
name: "IncidentHistory",
|
||||
mixins: [datetimeMixin],
|
||||
props: {
|
||||
incidents: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
editMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["edit-incident", "delete-incident", "resolve-incident"],
|
||||
methods: {
|
||||
/**
|
||||
* Get sanitized HTML for incident content
|
||||
* @param {string} content - Markdown content
|
||||
* @returns {string} Sanitized HTML
|
||||
*/
|
||||
getIncidentHTML(content) {
|
||||
if (content != null) {
|
||||
return DOMPurify.sanitize(marked(content));
|
||||
}
|
||||
return "";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../assets/vars.scss";
|
||||
|
||||
.incident-group {
|
||||
padding: 10px;
|
||||
|
||||
.incident-list {
|
||||
.incident-item {
|
||||
display: flex;
|
||||
padding: 13px 15px 10px 15px;
|
||||
border-radius: 10px;
|
||||
transition: all ease-in-out 0.15s;
|
||||
|
||||
&:hover {
|
||||
background-color: $highlight-white;
|
||||
}
|
||||
|
||||
&.resolved {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.incident-style-indicator {
|
||||
width: 6px;
|
||||
min-height: 100%;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.incident-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.incident-meta {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.incident-group {
|
||||
.incident-list {
|
||||
.incident-item {
|
||||
&:hover {
|
||||
background-color: $dark-bg2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,204 +0,0 @@
|
||||
<template>
|
||||
<div ref="modal" class="modal fade" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
{{ $t("Edit Incident") }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form @submit.prevent="submit">
|
||||
<div class="mb-3">
|
||||
<label for="incident-title" class="form-label">{{ $t("Title") }}</label>
|
||||
<input
|
||||
id="incident-title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:placeholder="$t('Incident title')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="incident-content" class="form-label">{{ $t("Content") }}</label>
|
||||
<textarea
|
||||
id="incident-content"
|
||||
v-model="form.content"
|
||||
class="form-control"
|
||||
rows="4"
|
||||
:placeholder="$t('Incident description')"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="incident-style" class="form-label">{{ $t("Style") }}</label>
|
||||
<select id="incident-style" v-model="form.style" class="form-select">
|
||||
<option value="info">{{ $t("info") }}</option>
|
||||
<option value="warning">
|
||||
{{ $t("warning") }}
|
||||
</option>
|
||||
<option value="danger">
|
||||
{{ $t("danger") }}
|
||||
</option>
|
||||
<option value="primary">
|
||||
{{ $t("primary") }}
|
||||
</option>
|
||||
<option value="light">{{ $t("light") }}</option>
|
||||
<option value="dark">{{ $t("dark") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input id="incident-pin" v-model="form.pin" type="checkbox" class="form-check-input" />
|
||||
<label for="incident-pin" class="form-check-label">
|
||||
{{ $t("Pin this incident") }}
|
||||
</label>
|
||||
<div class="form-text">
|
||||
{{ $t("Pinned incidents are shown prominently on the status page") }}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
{{ $t("Cancel") }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="processing" @click="submit">
|
||||
<span v-if="processing" class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
{{ $t("Save") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Confirm
|
||||
ref="confirmDelete"
|
||||
btn-style="btn-danger"
|
||||
:yes-text="$t('Yes')"
|
||||
:no-text="$t('No')"
|
||||
@yes="confirmDeleteIncident"
|
||||
>
|
||||
{{ $t("deleteIncidentMsg") }}
|
||||
</Confirm>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Modal } from "bootstrap";
|
||||
import Confirm from "./Confirm.vue";
|
||||
|
||||
export default {
|
||||
name: "IncidentManageModal",
|
||||
components: {
|
||||
Confirm,
|
||||
},
|
||||
props: {
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["incident-updated"],
|
||||
data() {
|
||||
return {
|
||||
modal: null,
|
||||
processing: false,
|
||||
incidentId: null,
|
||||
pendingDeleteIncident: null,
|
||||
form: {
|
||||
title: "",
|
||||
content: "",
|
||||
style: "warning",
|
||||
pin: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.modal = new Modal(this.$refs.modal);
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Show the modal for editing an existing incident
|
||||
* @param {object} incident - The incident to edit
|
||||
* @returns {void}
|
||||
*/
|
||||
showEdit(incident) {
|
||||
this.incidentId = incident.id;
|
||||
this.form = {
|
||||
title: incident.title,
|
||||
content: incident.content,
|
||||
style: incident.style || "warning",
|
||||
pin: !!incident.pin,
|
||||
};
|
||||
this.modal.show();
|
||||
},
|
||||
|
||||
/**
|
||||
* Show delete confirmation dialog
|
||||
* @param {object} incident - The incident to delete
|
||||
* @returns {void}
|
||||
*/
|
||||
showDelete(incident) {
|
||||
this.pendingDeleteIncident = incident;
|
||||
this.$refs.confirmDelete.show();
|
||||
},
|
||||
|
||||
/**
|
||||
* Submit the form to edit the incident
|
||||
* @returns {void}
|
||||
*/
|
||||
submit() {
|
||||
if (!this.form.title || this.form.title.trim() === "") {
|
||||
this.$root.toastError(this.$t("Please input title"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.form.content || this.form.content.trim() === "") {
|
||||
this.$root.toastError(this.$t("Please input content"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
|
||||
this.$root.getSocket().emit("editIncident", this.slug, this.incidentId, this.form, (res) => {
|
||||
this.processing = false;
|
||||
this.$root.toastRes(res);
|
||||
if (res.ok) {
|
||||
this.modal.hide();
|
||||
this.$emit("incident-updated");
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Confirm and delete the incident
|
||||
* @returns {void}
|
||||
*/
|
||||
confirmDeleteIncident() {
|
||||
if (!this.pendingDeleteIncident) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$root.getSocket().emit("deleteIncident", this.slug, this.pendingDeleteIncident.id, (res) => {
|
||||
this.$root.toastRes(res);
|
||||
if (res.ok) {
|
||||
this.$emit("incident-updated");
|
||||
}
|
||||
this.pendingDeleteIncident = null;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal-body {
|
||||
.form-text {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,24 +1,8 @@
|
||||
<template>
|
||||
<div class="shadow-box mb-3 p-0" :style="boxStyle">
|
||||
<div class="shadow-box mb-3" :style="boxStyle">
|
||||
<div class="list-header">
|
||||
<!-- Line 1: Checkbox + Status + Tags + Search Bar -->
|
||||
<div class="filter-row">
|
||||
<div class="search-wrapper">
|
||||
<a v-if="searchText != ''" class="search-icon" @click="clearSearchText">
|
||||
<font-awesome-icon icon="times" />
|
||||
</a>
|
||||
<form @submit.prevent>
|
||||
<input
|
||||
v-model="searchText"
|
||||
class="form-control search-input"
|
||||
:placeholder="$t('Search...')"
|
||||
:aria-label="$t('Search monitored sites')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="filters-group">
|
||||
<div class="header-top">
|
||||
<div class="select-checkbox-wrapper">
|
||||
<input
|
||||
v-if="!selectMode"
|
||||
v-model="selectMode"
|
||||
@ -34,23 +18,33 @@
|
||||
type="checkbox"
|
||||
:aria-label="selectAll ? $t('deselectAllMonitorsAria') : $t('selectAllMonitorsAria')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MonitorListFilter
|
||||
:filterState="filterState"
|
||||
:allCollapsed="allGroupsCollapsed"
|
||||
:hasGroups="groupMonitors.length >= 2"
|
||||
@update-filter="updateFilter"
|
||||
@toggle-collapse-all="toggleCollapseAll"
|
||||
/>
|
||||
<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>
|
||||
<form>
|
||||
<input
|
||||
v-model="searchText"
|
||||
class="form-control search-input"
|
||||
:placeholder="$t('Search...')"
|
||||
:aria-label="$t('Search monitored sites')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line 2: Cancel + Actions (shown when selection mode is active) -->
|
||||
<div v-if="selectMode && selectedMonitorCount > 0" class="selection-row">
|
||||
<div v-if="selectMode && selectedMonitorCount > 0" class="selected-count-row">
|
||||
<button class="btn btn-outline-normal" @click="cancelSelectMode">
|
||||
{{ $t("Cancel") }}
|
||||
</button>
|
||||
<div class="actions-wrapper">
|
||||
<div class="actions-wrapper ms-2">
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-outline-normal dropdown-toggle"
|
||||
@ -88,14 +82,14 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<span class="selected-count">
|
||||
{{ $t("selectedMonitorCountMsg", selectedMonitorCount) }}
|
||||
<span class="selected-count ms-2">
|
||||
{{ $tc("selectedMonitorCountMsg", selectedMonitorCount) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="monitorList"
|
||||
class="monitor-list px-2"
|
||||
class="monitor-list"
|
||||
:class="{ scrollbar: scrollbar }"
|
||||
:style="monitorListStyle"
|
||||
data-testid="monitor-list"
|
||||
@ -106,8 +100,8 @@
|
||||
</div>
|
||||
|
||||
<MonitorListItem
|
||||
v-for="item in sortedMonitorList"
|
||||
:key="`${item.id}-${collapseKey}`"
|
||||
v-for="(item, index) in sortedMonitorList"
|
||||
:key="index"
|
||||
:monitor="item"
|
||||
:isSelectMode="selectMode"
|
||||
:isSelected="isSelected"
|
||||
@ -160,7 +154,6 @@ export default {
|
||||
active: null,
|
||||
tags: null,
|
||||
},
|
||||
collapseKey: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -236,38 +229,6 @@ export default {
|
||||
this.searchText !== ""
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets all group monitors at root level that have children
|
||||
* @returns {Array} Array of group monitors with children
|
||||
*/
|
||||
groupMonitors() {
|
||||
const monitors = Object.values(this.$root.monitorList);
|
||||
return monitors.filter(
|
||||
(m) => m.type === "group" && m.parent === null && monitors.some((child) => child.parent === m.id)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines if all groups are collapsed.
|
||||
* Note: collapseKey is included to force re-computation when toggleCollapseAll()
|
||||
* updates localStorage, since Vue cannot detect localStorage changes.
|
||||
* @returns {boolean} True if all groups are collapsed
|
||||
*/
|
||||
allGroupsCollapsed() {
|
||||
// collapseKey forces this computed to re-evaluate after localStorage updates
|
||||
if (this.collapseKey < 0 || this.groupMonitors.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const storage = window.localStorage.getItem("monitorCollapsed");
|
||||
if (storage === null) {
|
||||
return true; // Default is collapsed
|
||||
}
|
||||
|
||||
const storageObject = JSON.parse(storage);
|
||||
return this.groupMonitors.every((group) => storageObject[`monitor_${group.id}`] !== false);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
searchText() {
|
||||
@ -342,26 +303,6 @@ export default {
|
||||
updateFilter(newFilter) {
|
||||
this.filterState = newFilter;
|
||||
},
|
||||
/**
|
||||
* Toggle collapse state for all group monitors
|
||||
* @returns {void}
|
||||
*/
|
||||
toggleCollapseAll() {
|
||||
const shouldCollapse = !this.allGroupsCollapsed;
|
||||
|
||||
let storageObject = {};
|
||||
const storage = window.localStorage.getItem("monitorCollapsed");
|
||||
if (storage !== null) {
|
||||
storageObject = JSON.parse(storage);
|
||||
}
|
||||
|
||||
this.groupMonitors.forEach((group) => {
|
||||
storageObject[`monitor_${group.id}`] = shouldCollapse;
|
||||
});
|
||||
|
||||
window.localStorage.setItem("monitorCollapsed", JSON.stringify(storageObject));
|
||||
this.collapseKey++;
|
||||
},
|
||||
/**
|
||||
* Deselect a monitor
|
||||
* @param {number} id ID of monitor
|
||||
@ -419,7 +360,7 @@ export default {
|
||||
|
||||
this.bulkActionInProgress = true;
|
||||
activeMonitors.forEach((id) => this.$root.getSocket().emit("pauseMonitor", id, () => {}));
|
||||
this.$root.toastSuccess(this.$t("pausedMonitorsMsg", activeMonitors.length));
|
||||
this.$root.toastSuccess(this.$tc("pausedMonitorsMsg", activeMonitors.length));
|
||||
this.bulkActionInProgress = false;
|
||||
this.cancelSelectMode();
|
||||
},
|
||||
@ -443,7 +384,7 @@ export default {
|
||||
|
||||
this.bulkActionInProgress = true;
|
||||
inactiveMonitors.forEach((id) => this.$root.getSocket().emit("resumeMonitor", id, () => {}));
|
||||
this.$root.toastSuccess(this.$t("resumedMonitorsMsg", inactiveMonitors.length));
|
||||
this.$root.toastSuccess(this.$tc("resumedMonitorsMsg", inactiveMonitors.length));
|
||||
this.bulkActionInProgress = false;
|
||||
this.cancelSelectMode();
|
||||
},
|
||||
@ -483,10 +424,10 @@ export default {
|
||||
this.bulkActionInProgress = false;
|
||||
|
||||
if (successCount > 0) {
|
||||
this.$root.toastSuccess(this.$t("deletedMonitorsMsg", successCount));
|
||||
this.$root.toastSuccess(this.$tc("deletedMonitorsMsg", successCount));
|
||||
}
|
||||
if (errorCount > 0) {
|
||||
this.$root.toastError(this.$t("bulkDeleteErrorMsg", errorCount));
|
||||
this.$root.toastError(this.$tc("bulkDeleteErrorMsg", errorCount));
|
||||
}
|
||||
|
||||
this.cancelSelectMode();
|
||||
@ -595,11 +536,9 @@ export default {
|
||||
.list-header {
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
border-radius: 10px 10px 0 0;
|
||||
margin: -10px;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.dark & {
|
||||
background-color: $dark-header-bg;
|
||||
@ -607,26 +546,37 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
.search-row {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
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;
|
||||
margin-left: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filters-group {
|
||||
.header-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions-wrapper {
|
||||
@ -693,13 +643,6 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.selection-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
white-space: nowrap;
|
||||
font-size: 0.9em;
|
||||
@ -710,7 +653,8 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
.selected-count-row {
|
||||
padding: 5px 10px 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@ -733,29 +677,11 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 975px) {
|
||||
.filter-row {
|
||||
flex-direction: column-reverse;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin-left: 0 !important;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.filters-group {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 770px) {
|
||||
.list-header {
|
||||
margin: -20px;
|
||||
margin-bottom: 10px;
|
||||
padding: 20px;
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -763,14 +689,15 @@ export default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 300px;
|
||||
margin-left: auto;
|
||||
order: 1;
|
||||
|
||||
form {
|
||||
@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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -788,9 +715,13 @@ export default {
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 15em;
|
||||
padding-right: 30px;
|
||||
transition: none !important;
|
||||
|
||||
@media (max-width: 549px), (min-width: 770px) and (max-width: 1149px), (min-width: 1200px) and (max-width: 1499px) {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.monitor-item {
|
||||
|
||||
@ -1,151 +1,139 @@
|
||||
<template>
|
||||
<MonitorListFilterDropdown :filterActive="filterState.status?.length > 0 || filterState.active?.length > 0">
|
||||
<template #status>
|
||||
<Status
|
||||
v-if="filterState.status?.length === 1 && !filterState.active?.length"
|
||||
:status="filterState.status[0]"
|
||||
/>
|
||||
<span
|
||||
v-else-if="!filterState.status?.length && filterState.active?.length === 1"
|
||||
class="badge status-pill"
|
||||
:class="filterState.active[0] ? 'running' : 'paused'"
|
||||
>
|
||||
<font-awesome-icon :icon="filterState.active[0] ? 'play' : 'pause'" class="icon-small" />
|
||||
{{ filterState.active[0] ? $t("Running") : $t("filterActivePaused") }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t("Status") }}
|
||||
</span>
|
||||
</template>
|
||||
<template #dropdown>
|
||||
<li>
|
||||
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(1)">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<Status :status="1" />
|
||||
<span class="ps-3">
|
||||
{{ $root.stats.up }}
|
||||
<span v-if="filterState.status?.includes(1)" class="px-1 filter-active">
|
||||
<font-awesome-icon icon="check" />
|
||||
<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]" />
|
||||
<span v-else>
|
||||
{{ $t("Status") }}
|
||||
</span>
|
||||
</template>
|
||||
<template #dropdown>
|
||||
<li>
|
||||
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(1)">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<Status :status="1" />
|
||||
<span class="ps-3">
|
||||
{{ $root.stats.up }}
|
||||
<span v-if="filterState.status?.includes(1)" class="px-1 filter-active">
|
||||
<font-awesome-icon icon="check" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(0)">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<Status :status="0" />
|
||||
<span class="ps-3">
|
||||
{{ $root.stats.down }}
|
||||
<span v-if="filterState.status?.includes(0)" class="px-1 filter-active">
|
||||
<font-awesome-icon icon="check" />
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(0)">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<Status :status="0" />
|
||||
<span class="ps-3">
|
||||
{{ $root.stats.down }}
|
||||
<span v-if="filterState.status?.includes(0)" class="px-1 filter-active">
|
||||
<font-awesome-icon icon="check" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(2)">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<Status :status="2" />
|
||||
<span class="ps-3">
|
||||
{{ $root.stats.pending }}
|
||||
<span v-if="filterState.status?.includes(2)" class="px-1 filter-active">
|
||||
<font-awesome-icon icon="check" />
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(2)">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<Status :status="2" />
|
||||
<span class="ps-3">
|
||||
{{ $root.stats.pending }}
|
||||
<span v-if="filterState.status?.includes(2)" class="px-1 filter-active">
|
||||
<font-awesome-icon icon="check" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(3)">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<Status :status="3" />
|
||||
<span class="ps-3">
|
||||
{{ $root.stats.maintenance }}
|
||||
<span v-if="filterState.status?.includes(3)" class="px-1 filter-active">
|
||||
<font-awesome-icon icon="check" />
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(3)">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<Status :status="3" />
|
||||
<span class="ps-3">
|
||||
{{ $root.stats.maintenance }}
|
||||
<span v-if="filterState.status?.includes(3)" class="px-1 filter-active">
|
||||
<font-awesome-icon icon="check" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
<div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(true)">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<span class="badge status-pill running">
|
||||
<font-awesome-icon icon="play" class="icon-small" />
|
||||
{{ $t("Running") }}
|
||||
</span>
|
||||
<span class="ps-3">
|
||||
{{ $root.stats.active }}
|
||||
<span v-if="filterState.active?.includes(true)" class="px-1 filter-active">
|
||||
<font-awesome-icon icon="check" />
|
||||
</li>
|
||||
</template>
|
||||
</MonitorListFilterDropdown>
|
||||
<MonitorListFilterDropdown :filterActive="filterState.active?.length > 0">
|
||||
<template #status>
|
||||
<span v-if="filterState.active?.length === 1">
|
||||
<span v-if="filterState.active[0]">{{ $t("Running") }}</span>
|
||||
<span v-else>{{ $t("filterActivePaused") }}</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t("filterActive") }}
|
||||
</span>
|
||||
</template>
|
||||
<template #dropdown>
|
||||
<li>
|
||||
<div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(true)">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<span>{{ $t("Running") }}</span>
|
||||
<span class="ps-3">
|
||||
{{ $root.stats.active }}
|
||||
<span v-if="filterState.active?.includes(true)" class="px-1 filter-active">
|
||||
<font-awesome-icon icon="check" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(false)">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<span class="badge status-pill paused">
|
||||
<font-awesome-icon icon="pause" class="icon-small" />
|
||||
{{ $t("filterActivePaused") }}
|
||||
</span>
|
||||
<span class="ps-3">
|
||||
{{ $root.stats.pause }}
|
||||
<span v-if="filterState.active?.includes(false)" class="px-1 filter-active">
|
||||
<font-awesome-icon icon="check" />
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(false)">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<span>{{ $t("filterActivePaused") }}</span>
|
||||
<span class="ps-3">
|
||||
{{ $root.stats.pause }}
|
||||
<span v-if="filterState.active?.includes(false)" class="px-1 filter-active">
|
||||
<font-awesome-icon icon="check" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</MonitorListFilterDropdown>
|
||||
<MonitorListFilterDropdown :filterActive="filterState.tags?.length > 0">
|
||||
<template #status>
|
||||
<Tag
|
||||
v-if="filterState.tags?.length === 1"
|
||||
:item="tagsList.find((tag) => tag.id === filterState.tags[0])"
|
||||
:size="'sm'"
|
||||
/>
|
||||
<span v-else>
|
||||
{{ $t("Tags") }}
|
||||
</span>
|
||||
</template>
|
||||
<template #dropdown>
|
||||
<li v-for="tag in tagsList" :key="tag.id">
|
||||
<div class="dropdown-item" tabindex="0" @click.stop="toggleTagFilter(tag)">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<span><Tag :item="tag" :size="'sm'" /></span>
|
||||
<span class="ps-3">
|
||||
{{ getTaggedMonitorCount(tag) }}
|
||||
<span v-if="filterState.tags?.includes(tag.id)" class="px-1 filter-active">
|
||||
<font-awesome-icon icon="check" />
|
||||
</li>
|
||||
</template>
|
||||
</MonitorListFilterDropdown>
|
||||
<MonitorListFilterDropdown :filterActive="filterState.tags?.length > 0">
|
||||
<template #status>
|
||||
<Tag
|
||||
v-if="filterState.tags?.length === 1"
|
||||
:item="tagsList.find((tag) => tag.id === filterState.tags[0])"
|
||||
:size="'sm'"
|
||||
/>
|
||||
<span v-else>
|
||||
{{ $t("Tags") }}
|
||||
</span>
|
||||
</template>
|
||||
<template #dropdown>
|
||||
<li v-for="tag in tagsList" :key="tag.id">
|
||||
<div class="dropdown-item" tabindex="0" @click.stop="toggleTagFilter(tag)">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<span><Tag :item="tag" :size="'sm'" /></span>
|
||||
<span class="ps-3">
|
||||
{{ getTaggedMonitorCount(tag) }}
|
||||
<span v-if="filterState.tags?.includes(tag.id)" class="px-1 filter-active">
|
||||
<font-awesome-icon icon="check" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="tagsList.length === 0">
|
||||
<div class="dropdown-item disabled px-3">
|
||||
{{ $t("No tags found.") }}
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</MonitorListFilterDropdown>
|
||||
<button
|
||||
v-if="hasGroups"
|
||||
type="button"
|
||||
class="btn btn-outline-normal btn-collapse-all"
|
||||
:title="allCollapsed ? $t('Expand All Groups') : $t('Collapse All Groups')"
|
||||
@click="$emit('toggle-collapse-all')"
|
||||
>
|
||||
<font-awesome-icon :icon="allCollapsed ? 'folder' : 'folder-open'" fixed-width />
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="tagsList.length === 0">
|
||||
<div class="dropdown-item disabled px-3">
|
||||
{{ $t("No tags found.") }}
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</MonitorListFilterDropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -164,16 +152,8 @@ export default {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
allCollapsed: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasGroups: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["updateFilter", "toggle-collapse-all"],
|
||||
emits: ["updateFilter"],
|
||||
data() {
|
||||
return {
|
||||
tagsList: [],
|
||||
@ -272,17 +252,6 @@ export default {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.simple-status {
|
||||
min-width: 64px;
|
||||
border: 1px solid #d1d5db;
|
||||
background-color: transparent !important;
|
||||
color: inherit !important;
|
||||
|
||||
.dark & {
|
||||
border-color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
font-size: 0.8em;
|
||||
margin-right: 5px;
|
||||
@ -306,41 +275,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
margin: 0.5rem 0;
|
||||
border-top: 1px solid #d1d5db;
|
||||
|
||||
.dark & {
|
||||
border-top-color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
min-width: 64px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
|
||||
&.running,
|
||||
&.paused {
|
||||
background-color: white !important;
|
||||
border: 1px solid #d1d5db;
|
||||
color: inherit;
|
||||
|
||||
.dark & {
|
||||
background-color: transparent !important;
|
||||
border-color: #6b7280;
|
||||
color: $dark-font-color;
|
||||
}
|
||||
|
||||
.icon-small {
|
||||
font-size: 0.75em;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-collapse-all {
|
||||
transition: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -103,7 +103,7 @@ export default {
|
||||
@extend .btn-outline-normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 0;
|
||||
margin-left: 5px;
|
||||
color: $link-color;
|
||||
|
||||
.dark & {
|
||||
|
||||
@ -24,12 +24,12 @@
|
||||
<router-link :to="monitorURL(monitor.id)" class="item" :class="{ disabled: !monitor.active }">
|
||||
<div class="row">
|
||||
<div
|
||||
class="col-9 col-xl-6 small-padding d-flex align-items-center"
|
||||
class="col-9 col-xl-6 small-padding"
|
||||
:class="{
|
||||
'monitor-item': $root.userHeartbeatBar == 'bottom' || $root.userHeartbeatBar == 'none',
|
||||
}"
|
||||
>
|
||||
<div class="info d-flex align-items-center gap-2">
|
||||
<div class="info">
|
||||
<Uptime :monitor="monitor" type="24" :pill="true" />
|
||||
<span v-if="hasChildren" class="collapse-padding" @click.prevent="changeCollapsed">
|
||||
<font-awesome-icon
|
||||
@ -383,7 +383,6 @@ export default {
|
||||
|
||||
/* We don't want the padding change due to the border animated */
|
||||
.item {
|
||||
padding: 12px 15px;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
|
||||
@ -231,11 +231,15 @@ export default {
|
||||
*/
|
||||
formattedCertExpiryMessage(monitor) {
|
||||
if (monitor?.element?.validCert && monitor?.element?.certExpiryDaysRemaining) {
|
||||
return this.$t("days", monitor.element.certExpiryDaysRemaining);
|
||||
return (
|
||||
monitor.element.certExpiryDaysRemaining +
|
||||
" " +
|
||||
this.$tc("day", monitor.element.certExpiryDaysRemaining)
|
||||
);
|
||||
} else if (monitor?.element?.validCert === false) {
|
||||
return this.$t("noOrBadCertificate");
|
||||
} else {
|
||||
return this.$t("unknownDays");
|
||||
return this.$t("Unknown") + " " + this.$tc("day", 2);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -31,9 +31,12 @@
|
||||
required
|
||||
/>
|
||||
|
||||
<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 class="form-text mt-3">
|
||||
{{ $t("Examples") }}:
|
||||
<ul>
|
||||
<li>ws://chrome.browserless.io/playwright?token=YOUR-API-TOKEN</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -68,7 +68,7 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="tag-monitors" class="form-label">
|
||||
{{ $t("Monitors", selectedMonitors.length) }}
|
||||
{{ $tc("Monitor", selectedMonitors.length) }}
|
||||
</label>
|
||||
<div class="tag-monitors-list">
|
||||
<router-link
|
||||
|
||||
@ -85,12 +85,12 @@ export default {
|
||||
|
||||
title() {
|
||||
if (this.type === "1y") {
|
||||
return this.$t("years", 1);
|
||||
return `1 ${this.$tc("year", 1)}`;
|
||||
}
|
||||
if (this.type === "720") {
|
||||
return this.$t("days", 30);
|
||||
return `30 ${this.$tc("day", 30)}`;
|
||||
}
|
||||
return this.$t("hours", 24);
|
||||
return `24 ${this.$tc("hour", 24)}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -102,24 +102,6 @@
|
||||
<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 {
|
||||
@ -130,9 +112,6 @@ 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>
|
||||
|
||||
@ -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">{{ $t("octopushEndpoint", { url: "api.octopush.com" }) }}</option>
|
||||
<option value="1">{{ $t("legacyOctopushEndpoint", { url: "www.octopush-dm.com" }) }}</option>
|
||||
<option value="2">{{ "octopush" }} ({{ $t("endpoint") }}: api.octopush.com)</option>
|
||||
<option value="1">{{ $t("Legacy Octopush-DM") }} ({{ $t("endpoint") }}: www.octopush-dm.com)</option>
|
||||
</select>
|
||||
<div class="form-text">
|
||||
{{ $t("octopushLegacyHint") }}
|
||||
|
||||
@ -56,24 +56,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!$parent.notification.smtpSecure" class="mb-3">
|
||||
<div class="form-check">
|
||||
<input
|
||||
id="ignore-starttls"
|
||||
v-model="$parent.notification.smtpIgnoreSTARTTLS"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
value=""
|
||||
/>
|
||||
<label class="form-check-label" for="ignore-starttls">
|
||||
{{ $t("Disable STARTTLS") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
{{ $t("disableSTARTTLSDescription") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">{{ $t("Username") }}</label>
|
||||
<input
|
||||
|
||||
@ -23,16 +23,4 @@
|
||||
</a>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="wecom-mentioned-mobile-list" class="form-label">{{ $t("WeCom Mentioned Mobile List") }}</label>
|
||||
<input
|
||||
id="wecom-mentioned-mobile-list"
|
||||
v-model="$parent.notification.weComMentionedMobileList"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="13800001111,13900002222,@all"
|
||||
/>
|
||||
<p class="form-text">{{ $t("WeCom Mentioned Mobile List Description") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
<div class="status">
|
||||
{{ $t("apiKey-" + item.status) }}
|
||||
</div>
|
||||
<div class="date">{{ $t("createdAt", { date: item.createdDate }) }}</div>
|
||||
<div class="date">{{ $t("Created") }}: {{ item.createdDate }}</div>
|
||||
<div class="date">
|
||||
{{ $t("Expires") }}:
|
||||
{{ item.expires || $t("Never") }}
|
||||
|
||||
@ -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("versionIs", { version: $root.info.version }) }}</div>
|
||||
<div class="frontend-version">{{ $t("frontendVersionIs", { version: $root.frontendVersion }) }}</div>
|
||||
<div>{{ $t("Version") }}: {{ $root.info.version }}</div>
|
||||
<div class="frontend-version">{{ $t("Frontend 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!") }}
|
||||
|
||||
@ -64,7 +64,7 @@
|
||||
:key="day"
|
||||
class="d-flex align-items-center justify-content-between cert-exp-day-row py-2"
|
||||
>
|
||||
<span>{{ $t("days", day) }}</span>
|
||||
<span>{{ day }} {{ $tc("day", 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('days', 1)"
|
||||
:placeholder="$t('day')"
|
||||
: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>{{ $t("days", day) }}</span>
|
||||
<span>{{ day }} {{ $tc("day", 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('days', 1)"
|
||||
:placeholder="$t('day')"
|
||||
:icon="'plus'"
|
||||
:action="() => addDomainExpiryNotifDay(domainExpiryNotifInput)"
|
||||
:action-aria-label="$t('Add a new expiry notification day')"
|
||||
|
||||
@ -4,13 +4,15 @@
|
||||
<!-- 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("logoutCurrentUser", { username: $root.username }) }}
|
||||
{{ $t("Logout") }}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<Tag :item="tag" />
|
||||
</div>
|
||||
<div class="col-5 px-1 d-none d-sm-block">
|
||||
<div>{{ $t("Monitors", monitorsByTag(tag.id).length) }}</div>
|
||||
<div>{{ monitorsByTag(tag.id).length }} {{ $tc("Monitor", monitorsByTag(tag.id).length) }}</div>
|
||||
</div>
|
||||
<div class="col-2 pe-2 pe-lg-3 d-flex justify-content-end">
|
||||
<button
|
||||
|
||||
@ -53,8 +53,6 @@ import {
|
||||
faInfoCircle,
|
||||
faClone,
|
||||
faCertificate,
|
||||
faFolder,
|
||||
faFolderOpen,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
library.add(
|
||||
@ -105,8 +103,6 @@ library.add(
|
||||
faInfoCircle,
|
||||
faClone,
|
||||
faCertificate,
|
||||
faFolder,
|
||||
faFolderOpen,
|
||||
);
|
||||
|
||||
export { FontAwesomeIcon };
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
"General": "General",
|
||||
"Game": "Game",
|
||||
"Primary Base URL": "Primary Base URL",
|
||||
"versionIs": "Version: {version}",
|
||||
"Version": "Version",
|
||||
"Check Update On GitHub": "Check Update On GitHub",
|
||||
"List": "List",
|
||||
"Home": "Home",
|
||||
@ -33,22 +33,18 @@
|
||||
"statusMaintenance": "Maintenance",
|
||||
"Maintenance": "Maintenance",
|
||||
"Unknown": "Unknown",
|
||||
"unknownDays": "Unknown days",
|
||||
"Cannot connect to the socket server": "Cannot connect to the socket server",
|
||||
"Reconnecting...": "Reconnecting...",
|
||||
"General Monitor Type": "General Monitor Type",
|
||||
"Passive Monitor Type": "Passive Monitor Type",
|
||||
"Specific Monitor Type": "Specific Monitor Type",
|
||||
"markdownSupported": "Markdown syntax supported. If using HTML, avoid leading spaces to prevent formatting issues.",
|
||||
"markdownSupported": "Markdown syntax supported",
|
||||
"pauseDashboardHome": "Pause",
|
||||
"Pause": "Pause",
|
||||
"Name": "Name",
|
||||
"Status": "Status",
|
||||
"DateTime": "DateTime",
|
||||
"Message": "Message",
|
||||
"No incidents recorded": "No incidents recorded",
|
||||
"Load More": "Load More",
|
||||
"Loading...": "Loading...",
|
||||
"No important events": "No important events",
|
||||
"Resume": "Resume",
|
||||
"Edit": "Edit",
|
||||
@ -56,16 +52,13 @@
|
||||
"Current": "Current",
|
||||
"Uptime": "Uptime",
|
||||
"Cert Exp.": "Cert Exp.",
|
||||
"Monitors": "{n} Monitor | {n} Monitors",
|
||||
"Monitor": "Monitor | Monitors",
|
||||
"now": "now",
|
||||
"time ago": "{0} ago",
|
||||
"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",
|
||||
"day": "day | days",
|
||||
"hour": "hour | hours",
|
||||
"year": "year | years",
|
||||
"Response": "Response",
|
||||
"Pin this incident": "Pin this incident",
|
||||
"Ping": "Ping",
|
||||
"Monitor Type": "Monitor Type",
|
||||
"Keyword": "Keyword",
|
||||
@ -150,7 +143,6 @@
|
||||
"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",
|
||||
@ -171,10 +163,6 @@
|
||||
"Last Result": "Last Result",
|
||||
"Create your admin account": "Create your admin account",
|
||||
"Repeat Password": "Repeat Password",
|
||||
"Incident description": "Incident description",
|
||||
"Incident not found or access denied": "Incident not found or access denied",
|
||||
"Past Incidents": "Past Incidents",
|
||||
"Incident title": "Incident title",
|
||||
"Import Backup": "Import Backup",
|
||||
"Export Backup": "Export Backup",
|
||||
"Export": "Export",
|
||||
@ -231,7 +219,6 @@
|
||||
"Blue": "Blue",
|
||||
"Indigo": "Indigo",
|
||||
"Purple": "Purple",
|
||||
"Pinned incidents are shown prominently on the status page": "Pinned incidents are shown prominently on the status page",
|
||||
"Pink": "Pink",
|
||||
"Custom": "Custom",
|
||||
"Search...": "Search…",
|
||||
@ -247,7 +234,6 @@
|
||||
"Degraded Service": "Degraded Service",
|
||||
"Add Group": "Add Group",
|
||||
"Add a monitor": "Add a monitor",
|
||||
"Edit Incident": "Edit Incident",
|
||||
"Edit Status Page": "Edit Status Page",
|
||||
"Go to Dashboard": "Go to Dashboard",
|
||||
"Status Page": "Status Page",
|
||||
@ -292,6 +278,7 @@
|
||||
"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",
|
||||
@ -306,8 +293,6 @@
|
||||
"successKeyword": "Success Keyword",
|
||||
"successKeywordExplanation": "MQTT Keyword that will be considered as success",
|
||||
"recent": "Recent",
|
||||
"Resolve": "Resolve",
|
||||
"Resolved": "Resolved",
|
||||
"Reset Token": "Reset Token",
|
||||
"Done": "Done",
|
||||
"Info": "Info",
|
||||
@ -333,9 +318,8 @@
|
||||
"dark": "dark",
|
||||
"Post": "Post",
|
||||
"Please input title and content": "Please input title and content",
|
||||
"createdAt": "Created: {date}",
|
||||
"lastUpdatedAt": "Last Updated: {date}",
|
||||
"lastUpdatedAtFromNow": "Last Updated: {date} ({fromNow})",
|
||||
"Created": "Created",
|
||||
"Last Updated": "Last Updated",
|
||||
"Switch to Light Theme": "Switch to Light Theme",
|
||||
"Switch to Dark Theme": "Switch to Dark Theme",
|
||||
"Show Tags": "Show Tags",
|
||||
@ -361,7 +345,6 @@
|
||||
"Customize": "Customize",
|
||||
"Custom Footer": "Custom Footer",
|
||||
"Custom CSS": "Custom CSS",
|
||||
"deleteIncidentMsg": "Are you sure you want to delete this incident?",
|
||||
"deleteStatusPageMsg": "Are you sure want to delete this status page?",
|
||||
"Proxies": "Proxies",
|
||||
"default": "Default",
|
||||
@ -371,7 +354,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",
|
||||
@ -384,7 +367,6 @@
|
||||
"Stop": "Stop",
|
||||
"Add New Status Page": "Add New Status Page",
|
||||
"Slug": "Slug",
|
||||
"slug is not found": "Slug is not found",
|
||||
"Accept characters:": "Accept characters:",
|
||||
"startOrEndWithOnly": "Start or end with {0} only",
|
||||
"No consecutive dashes": "No consecutive dashes",
|
||||
@ -408,8 +390,6 @@
|
||||
"Trust Proxy": "Trust Proxy",
|
||||
"Other Software": "Other Software",
|
||||
"For example: nginx, Apache and Traefik.": "For example: nginx, Apache and Traefik.",
|
||||
"Please input content": "Please input content",
|
||||
"Please input title": "Please input title",
|
||||
"Please read": "Please read",
|
||||
"Subject:": "Subject:",
|
||||
"Valid To:": "Valid To:",
|
||||
@ -421,7 +401,7 @@
|
||||
"Add a new expiry notification day": "Add a new expiry notification day",
|
||||
"Remove the expiry notification": "Remove the expiry notification day",
|
||||
"Proxy": "Proxy",
|
||||
"dateCreatedAtFromNow": "Date Created: {date} ({fromNow})",
|
||||
"Date Created": "Date Created",
|
||||
"Footer Text": "Footer Text",
|
||||
"RSS Title": "RSS Title",
|
||||
"Leave blank to use status page title": "Leave blank to use status page title",
|
||||
@ -495,13 +475,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: {0}",
|
||||
"Examples": "Examples",
|
||||
"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.",
|
||||
@ -510,7 +490,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.",
|
||||
"frontendVersionIs": "Frontend Version: {version}",
|
||||
"Frontend Version": "Frontend 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.",
|
||||
@ -521,7 +501,7 @@
|
||||
"startDateTime": "Start Date/Time",
|
||||
"endDateTime": "End Date/Time",
|
||||
"cronExpression": "Cron Expression",
|
||||
"cronScheduleDescription": "Schedule: {description}",
|
||||
"cronSchedule": "Schedule: ",
|
||||
"Duration (Minutes)": "Duration (Minutes)",
|
||||
"invalidCronExpression": "Invalid Cron Expression: {0}",
|
||||
"recurringInterval": "Interval",
|
||||
@ -601,8 +581,6 @@
|
||||
"secureOptionNone": "None / STARTTLS (25, 587)",
|
||||
"secureOptionTLS": "TLS (465)",
|
||||
"Ignore TLS Error": "Ignore TLS Error",
|
||||
"Disable STARTTLS": "Disable STARTTLS",
|
||||
"disableSTARTTLSDescription": "Enable this option for SMTP servers that do not support STARTTLS. This will send emails over an unencrypted connection.",
|
||||
"From Email": "From Email",
|
||||
"emailCustomisableContent": "Customisable content",
|
||||
"smtpLiquidIntroduction": "The following two fields are templatable via the Liquid templating Language. Please refer to the {0} for usage instructions. These are the available variables:",
|
||||
@ -690,8 +668,6 @@
|
||||
"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.",
|
||||
@ -702,6 +678,7 @@
|
||||
"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",
|
||||
@ -753,7 +730,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",
|
||||
@ -811,8 +788,6 @@
|
||||
"Retry": "Retry",
|
||||
"Topic": "Topic",
|
||||
"WeCom Bot Key": "WeCom Bot Key",
|
||||
"WeCom Mentioned Mobile List": "WeCom Mentioned Mobile List",
|
||||
"WeCom Mentioned Mobile List Description": "Enter phone numbers to mention. Separate multiple numbers with commas. Use {'@'}all to mention everyone.",
|
||||
"Setup Proxy": "Set Up Proxy",
|
||||
"Proxy Protocol": "Proxy Protocol",
|
||||
"Proxy Server": "Proxy Server",
|
||||
@ -905,8 +880,7 @@
|
||||
"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",
|
||||
"octopushEndpoint": "octopush (endpoint: {url})",
|
||||
"legacyOctopushEndpoint": "Legacy Octopush-DM (endpoint: {url})",
|
||||
"Legacy Octopush-DM": "Legacy Octopush-DM",
|
||||
"ntfy Topic": "ntfy Topic",
|
||||
"Server URL should not contain the nfty topic": "Server URL should not contain the nfty topic",
|
||||
"onebotHttpAddress": "OneBot HTTP Address",
|
||||
@ -1075,10 +1049,6 @@
|
||||
"remoteBrowserToggle": "By default Chromium runs inside the Uptime Kuma container. You can use a remote browser by toggling this switch.",
|
||||
"useRemoteBrowser": "Use a Remote Browser",
|
||||
"deleteRemoteBrowserMessage": "Are you sure want to delete this Remote Browser for all monitors?",
|
||||
"Screenshot Delay": "Screenshot Delay (waits {milliseconds})",
|
||||
"milliseconds": "{n} millisecond | {n} milliseconds",
|
||||
"screenshotDelayDescription": "Optionally wait this many milliseconds before taking the screenshot. Maximum: {maxValueMs}ms (0.5 × interval).",
|
||||
"screenshotDelayWarning": "Higher values keep the browser open longer, which may increase memory usage with many concurrent monitors.",
|
||||
"GrafanaOncallUrl": "Grafana Oncall URL",
|
||||
"systemService": "System Service",
|
||||
"systemServiceName": "Service Name",
|
||||
@ -1129,7 +1099,6 @@
|
||||
"snmpCommunityStringHelptext": "This string functions as a password to authenticate and control access to SNMP-enabled devices. Match it with your SNMP device's configuration.",
|
||||
"OID (Object Identifier)": "OID (Object Identifier)",
|
||||
"snmpOIDHelptext": "Enter the OID for the sensor or status you want to monitor. Use network management tools like MIB browsers or SNMP software if you're unsure about the OID.",
|
||||
"snmpV3Username": "SNMPv3 Username",
|
||||
"Condition": "Condition",
|
||||
"SNMP Version": "SNMP Version",
|
||||
"Please enter a valid OID.": "Please enter a valid OID.",
|
||||
@ -1290,8 +1259,6 @@
|
||||
"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",
|
||||
@ -1322,8 +1289,9 @@
|
||||
"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_is_icann": "The domain \"{domain}\" is not a candidate for domain expiry monitoring, because its public suffix \".{publicSuffix}\" is not ICAN",
|
||||
"domain_expiry_unsupported_public_suffix": "The domain \"{domain}\" does not have a valid public suffix",
|
||||
"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.",
|
||||
@ -1386,8 +1354,5 @@
|
||||
"Expected TLS Alert": "Expected TLS Alert",
|
||||
"None (Successful Connection)": "None (Successful Connection)",
|
||||
"expectedTlsAlertDescription": "Select the TLS alert you expect the server to return. Use {code} to verify mTLS endpoints reject connections without client certificates. See {link} for details.",
|
||||
"TLS Alert Spec": "RFC 8446",
|
||||
"mariadbSocketPathDetectedHelptext": "Connecting to the database as specified via the {0} environment variable.",
|
||||
"Expand All Groups": "Expand All Groups",
|
||||
"Collapse All Groups": "Collapse All Groups"
|
||||
"TLS Alert Spec": "RFC 8446"
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div v-if="!$root.isMobile" class="col-12 col-md-5 col-xl-4 ps-0">
|
||||
<div v-if="!$root.isMobile" class="col-12 col-md-5 col-xl-4">
|
||||
<div>
|
||||
<router-link to="/add" class="btn btn-primary mb-3">
|
||||
<font-awesome-icon icon="plus" />
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
{{ $t("Quick Stats") }}
|
||||
</h1>
|
||||
|
||||
<div class="shadow-box big-padding text-center mb-3">
|
||||
<div class="shadow-box big-padding text-center mb-4">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3>{{ $t("Up") }}</h3>
|
||||
|
||||
@ -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">({{ $t("hours", 24) }})</p>
|
||||
<p class="col-4 col-sm-12 mb-0 mb-sm-2">({{ 24 }} {{ $tc("hour", 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">({{ $t("hours", 24) }})</p>
|
||||
<p class="col-4 col-sm-12 mb-0 mb-sm-2">({{ 24 }} {{ $tc("hour", 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">({{ $t("days", 30) }})</p>
|
||||
<p class="col-4 col-sm-12 mb-0 mb-sm-2">({{ 30 }} {{ $tc("day", 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">({{ $t("years", 1) }})</p>
|
||||
<p class="col-4 col-sm-12 mb-0 mb-sm-2">({{ 1 }} {{ $tc("year", 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">
|
||||
{{ $t("days", tlsInfo.certInfo.daysRemaining) }}
|
||||
{{ tlsInfo.certInfo.daysRemaining }} {{ $tc("day", 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">
|
||||
{{ $t("days", domainInfo.daysRemaining) }}
|
||||
{{ domainInfo.daysRemaining }} {{ $tc("day", domainInfo.daysRemaining) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -374,7 +374,7 @@
|
||||
type="checkbox"
|
||||
/>
|
||||
<label class="form-check-label" for="delete-children-checkbox">
|
||||
{{ $t("deleteChildrenMonitors", childrenCount) }}
|
||||
{{ $t("deleteChildrenMonitors", childrenCount, { count: childrenCount }) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -123,13 +123,16 @@
|
||||
</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("cronScheduleDescription", { description: cronDescription }) }}</p>
|
||||
<p>{{ $t("cronSchedule") }}{{ cronDescription }}</p>
|
||||
<input
|
||||
id="cron"
|
||||
v-model="maintenance.cron"
|
||||
@ -164,7 +167,7 @@
|
||||
|
||||
<template v-if="maintenance.intervalDay >= 1">
|
||||
({{
|
||||
$t("recurringIntervalMessage", maintenance.intervalDay, [
|
||||
$tc("recurringIntervalMessage", maintenance.intervalDay, [
|
||||
maintenance.intervalDay,
|
||||
])
|
||||
}})
|
||||
@ -328,102 +331,6 @@
|
||||
</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>
|
||||
|
||||
@ -604,22 +511,6 @@ 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"() {
|
||||
@ -679,19 +570,6 @@ 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: "",
|
||||
@ -700,7 +578,7 @@ export default {
|
||||
cron: "30 3 * * *",
|
||||
durationMinutes: 60,
|
||||
intervalDay: 1,
|
||||
dateRange: [formatDateTime(now), formatDateTime(oneHourLater)],
|
||||
dateRange: [],
|
||||
timeRange: [
|
||||
{
|
||||
hours: 2,
|
||||
@ -713,7 +591,7 @@ export default {
|
||||
],
|
||||
weekdays: [],
|
||||
daysOfMonth: [],
|
||||
timezoneOption: "SAME_AS_SERVER",
|
||||
timezoneOption: null,
|
||||
};
|
||||
} else if (this.isEdit || this.isClone) {
|
||||
this.$root.getSocket().emit("getMaintenance", this.$route.params.id, (res) => {
|
||||
@ -777,30 +655,6 @@ 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}
|
||||
|
||||
@ -511,23 +511,8 @@
|
||||
<select id="snmp_version" v-model="monitor.snmpVersion" class="form-select">
|
||||
<option value="1">SNMPv1</option>
|
||||
<option value="2c">SNMPv2c</option>
|
||||
<option value="3">SNMPv3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="monitor.type === 'snmp' && monitor.snmpVersion === '3'" class="my-3">
|
||||
<label for="snmp_v3_username" class="form-label">
|
||||
{{ $t("snmpV3Username") }}
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="snmp_v3_username"
|
||||
v-model="monitor.snmpV3Username"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="SNMPv3 username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="monitor.type === 'smtp'" class="my-3">
|
||||
<label for="smtp_security" class="form-label">{{ $t("SMTP Security") }}</label>
|
||||
@ -1278,36 +1263,6 @@
|
||||
<div class="form-text"></div>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot Delay - Real Browser only -->
|
||||
<div v-if="monitor.type === 'real-browser'" class="my-3">
|
||||
<label for="screenshot-delay" class="form-label">
|
||||
{{
|
||||
$t("Screenshot Delay", {
|
||||
milliseconds: $t("milliseconds", monitor.screenshot_delay),
|
||||
})
|
||||
}}
|
||||
</label>
|
||||
<input
|
||||
id="screenshot-delay"
|
||||
v-model="monitor.screenshot_delay"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="0"
|
||||
:max="Math.floor(monitor.interval * 1000 * 0.5)"
|
||||
step="100"
|
||||
/>
|
||||
<div class="form-text">
|
||||
{{
|
||||
$t("screenshotDelayDescription", {
|
||||
maxValueMs: Math.floor(monitor.interval * 1000 * 0.5),
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div v-if="monitor.screenshot_delay" class="form-text text-warning">
|
||||
{{ $t("screenshotDelayWarning") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showDomainExpiryNotification" class="my-3 form-check">
|
||||
<input
|
||||
id="domain-expiry-notification"
|
||||
@ -2338,7 +2293,6 @@ const monitorDefaults = {
|
||||
kafkaProducerAllowAutoTopicCreation: false,
|
||||
gamedigGivenPortOnly: true,
|
||||
remote_browser: null,
|
||||
screenshot_delay: 0,
|
||||
rabbitmqNodes: [],
|
||||
rabbitmqUsername: "",
|
||||
rabbitmqPassword: "",
|
||||
@ -2801,7 +2755,7 @@ message HealthCheckResponse {
|
||||
this.monitor.jsonPath = "$";
|
||||
}
|
||||
|
||||
// Set default condition for jsonPathOperator
|
||||
// Set default condition for for jsonPathOperator
|
||||
if (!this.monitor.jsonPathOperator) {
|
||||
this.monitor.jsonPathOperator = "==";
|
||||
}
|
||||
|
||||
@ -79,7 +79,7 @@
|
||||
</div>
|
||||
|
||||
<template v-if="dbConfig.type === 'mariadb'">
|
||||
<div v-if="!isProvidedMariaDBSocket" class="form-floating mt-3 short">
|
||||
<div class="form-floating mt-3 short">
|
||||
<input
|
||||
id="floatingInput"
|
||||
v-model="dbConfig.hostname"
|
||||
@ -90,19 +90,11 @@
|
||||
<label for="floatingInput">{{ $t("Hostname") }}</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!isProvidedMariaDBSocket" class="form-floating mt-3 short">
|
||||
<div class="form-floating mt-3 short">
|
||||
<input id="floatingInput" v-model="dbConfig.port" type="text" class="form-control" required />
|
||||
<label for="floatingInput">{{ $t("Port") }}</label>
|
||||
</div>
|
||||
|
||||
<div v-if="isProvidedMariaDBSocket" class="mt-1 short text-start">
|
||||
<i18n-t keypath="mariadbSocketPathDetectedHelptext" tag="div" class="form-text">
|
||||
<code>UPTIME_KUMA_DB_SOCKET</code>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<hr v-if="isProvidedMariaDBSocket" class="mt-3 mb-2 short" />
|
||||
|
||||
<div class="form-floating mt-3 short">
|
||||
<input
|
||||
id="floatingInput"
|
||||
@ -206,9 +198,6 @@ export default {
|
||||
disabledButton() {
|
||||
return this.dbConfig.type === undefined || this.info.runningSetup;
|
||||
},
|
||||
isProvidedMariaDBSocket() {
|
||||
return this.info.isEnabledMariaDBSocket;
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
let res = await axios.get("/setup-database-info");
|
||||
|
||||
@ -25,7 +25,9 @@
|
||||
class="form-control"
|
||||
data-testid="description-input"
|
||||
></textarea>
|
||||
<div class="form-text">{{ $t("markdownSupported") }}</div>
|
||||
<div class="form-text">
|
||||
{{ $t("markdownSupported") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Text -->
|
||||
@ -37,7 +39,9 @@
|
||||
class="form-control"
|
||||
data-testid="footer-text-input"
|
||||
></textarea>
|
||||
<div class="form-text">{{ $t("markdownSupported") }}</div>
|
||||
<div class="form-text">
|
||||
{{ $t("markdownSupported") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
@ -297,83 +301,125 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incident Edit Form -->
|
||||
<IncidentEditForm
|
||||
v-if="
|
||||
editIncidentMode &&
|
||||
incident !== null &&
|
||||
(!incident.id || !activeIncidents.some((i) => i.id === incident.id))
|
||||
"
|
||||
v-model="incident"
|
||||
@post="postIncident"
|
||||
@cancel="cancelIncident"
|
||||
/>
|
||||
|
||||
<!-- Active Pinned Incidents -->
|
||||
<template v-for="activeIncident in activeIncidents" :key="activeIncident.id">
|
||||
<!-- Edit mode for this specific incident -->
|
||||
<IncidentEditForm
|
||||
v-if="editIncidentMode && incident !== null && incident.id === activeIncident.id"
|
||||
v-model="incident"
|
||||
@post="postIncident"
|
||||
@cancel="cancelIncident"
|
||||
<!-- Incident -->
|
||||
<div
|
||||
v-if="incident !== null"
|
||||
class="shadow-box alert mb-4 p-4 incident"
|
||||
role="alert"
|
||||
:class="incidentClass"
|
||||
data-testid="incident"
|
||||
>
|
||||
<strong v-if="editIncidentMode">{{ $t("Title") }}:</strong>
|
||||
<Editable
|
||||
v-model="incident.title"
|
||||
tag="h4"
|
||||
:contenteditable="editIncidentMode"
|
||||
:noNL="true"
|
||||
class="alert-heading"
|
||||
data-testid="incident-title"
|
||||
/>
|
||||
|
||||
<!-- Display mode for this incident -->
|
||||
<div
|
||||
v-else
|
||||
class="shadow-box alert mb-4 p-4 incident"
|
||||
role="alert"
|
||||
:class="'bg-' + activeIncident.style"
|
||||
data-testid="incident"
|
||||
>
|
||||
<h4 class="alert-heading" data-testid="incident-title">{{ activeIncident.title }}</h4>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
class="content"
|
||||
data-testid="incident-content"
|
||||
v-html="getIncidentHTML(activeIncident.content)"
|
||||
></div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
|
||||
<!-- Incident Date -->
|
||||
<div class="date mt-3">
|
||||
{{
|
||||
$t("dateCreatedAtFromNow", {
|
||||
date: $root.datetime(activeIncident.createdDate),
|
||||
fromNow: dateFromNow(activeIncident.createdDate),
|
||||
})
|
||||
}}
|
||||
<br />
|
||||
<span v-if="activeIncident.lastUpdatedDate">
|
||||
{{
|
||||
$t("lastUpdatedAtFromNow", {
|
||||
date: $root.datetime(activeIncident.lastUpdatedDate),
|
||||
fromNow: dateFromNow(activeIncident.lastUpdatedDate),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="editMode" class="mt-3">
|
||||
<button class="btn btn-light me-2" @click="resolveIncident(activeIncident)">
|
||||
<font-awesome-icon icon="check" />
|
||||
{{ $t("Resolve") }}
|
||||
</button>
|
||||
<button class="btn btn-light me-2" @click="editIncident(activeIncident)">
|
||||
<font-awesome-icon icon="edit" />
|
||||
{{ $t("Edit") }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-light me-2"
|
||||
@click="$refs.incidentManageModal.showDelete(activeIncident)"
|
||||
>
|
||||
<font-awesome-icon icon="unlink" />
|
||||
{{ $t("Delete") }}
|
||||
</button>
|
||||
</div>
|
||||
<strong v-if="editIncidentMode">{{ $t("Content") }}:</strong>
|
||||
<Editable
|
||||
v-if="editIncidentMode"
|
||||
v-model="incident.content"
|
||||
tag="div"
|
||||
:contenteditable="editIncidentMode"
|
||||
class="content"
|
||||
data-testid="incident-content-editable"
|
||||
/>
|
||||
<div v-if="editIncidentMode" class="form-text">
|
||||
{{ $t("markdownSupported") }}
|
||||
</div>
|
||||
</template>
|
||||
<!-- eslint-disable vue/no-v-html-->
|
||||
<div
|
||||
v-if="!editIncidentMode"
|
||||
class="content"
|
||||
data-testid="incident-content"
|
||||
v-html="incidentHTML"
|
||||
></div>
|
||||
<!-- eslint-enable vue/no-v-html-->
|
||||
|
||||
<!-- Incident Date -->
|
||||
<div class="date mt-3">
|
||||
{{ $t("Date Created") }}: {{ $root.datetime(incident.createdDate) }} ({{
|
||||
dateFromNow(incident.createdDate)
|
||||
}})
|
||||
<br />
|
||||
<span v-if="incident.lastUpdatedDate">
|
||||
{{ $t("Last Updated") }}: {{ $root.datetime(incident.lastUpdatedDate) }} ({{
|
||||
dateFromNow(incident.lastUpdatedDate)
|
||||
}})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="editMode" class="mt-3">
|
||||
<button
|
||||
v-if="editIncidentMode"
|
||||
class="btn btn-light me-2"
|
||||
data-testid="post-incident-button"
|
||||
@click="postIncident"
|
||||
>
|
||||
<font-awesome-icon icon="bullhorn" />
|
||||
{{ $t("Post") }}
|
||||
</button>
|
||||
|
||||
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="editIncident">
|
||||
<font-awesome-icon icon="edit" />
|
||||
{{ $t("Edit") }}
|
||||
</button>
|
||||
|
||||
<button v-if="editIncidentMode" class="btn btn-light me-2" @click="cancelIncident">
|
||||
<font-awesome-icon icon="times" />
|
||||
{{ $t("Cancel") }}
|
||||
</button>
|
||||
|
||||
<div v-if="editIncidentMode" class="dropdown d-inline-block me-2">
|
||||
<button
|
||||
id="dropdownMenuButton1"
|
||||
class="btn btn-secondary dropdown-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
{{ $t("Style") }}: {{ $t(incident.style) }}
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click="incident.style = 'info'">{{ $t("info") }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click="incident.style = 'warning'">
|
||||
{{ $t("warning") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click="incident.style = 'danger'">
|
||||
{{ $t("danger") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click="incident.style = 'primary'">
|
||||
{{ $t("primary") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click="incident.style = 'light'">
|
||||
{{ $t("light") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click="incident.style = 'dark'">{{ $t("dark") }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="unpinIncident">
|
||||
<font-awesome-icon icon="unlink" />
|
||||
{{ $t("Delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overall Status -->
|
||||
<div class="shadow-box list p-4 overall-status mb-4">
|
||||
@ -496,56 +542,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Past Incidents -->
|
||||
<div v-if="pastIncidentCount > 0" class="past-incidents-section mb-4">
|
||||
<h2 class="past-incidents-title mb-3">
|
||||
{{ $t("Past Incidents") }}
|
||||
</h2>
|
||||
|
||||
<div class="past-incidents-content">
|
||||
<div
|
||||
v-for="(dateGroup, dateKey) in groupedIncidentHistory"
|
||||
:key="dateKey"
|
||||
class="incident-date-group mb-4"
|
||||
>
|
||||
<h4 class="incident-date-header">{{ dateKey }}</h4>
|
||||
<div class="shadow-box incident-list-box">
|
||||
<IncidentHistory
|
||||
:incidents="dateGroup"
|
||||
:edit-mode="enableEditMode"
|
||||
:loading="incidentHistoryLoading"
|
||||
@edit-incident="$refs.incidentManageModal.showEdit($event)"
|
||||
@delete-incident="$refs.incidentManageModal.showDelete($event)"
|
||||
@resolve-incident="resolveIncident"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="incidentHistoryHasMore" class="load-more-controls d-flex justify-content-center mt-3">
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
:disabled="incidentHistoryLoading"
|
||||
@click="loadMoreIncidentHistory"
|
||||
>
|
||||
<span
|
||||
v-if="incidentHistoryLoading"
|
||||
class="spinner-border spinner-border-sm me-1"
|
||||
role="status"
|
||||
></span>
|
||||
{{ $t("Load More") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incident Manage Modal -->
|
||||
<IncidentManageModal
|
||||
v-if="enableEditMode"
|
||||
ref="incidentManageModal"
|
||||
:slug="slug"
|
||||
@incident-updated="loadIncidentHistory"
|
||||
/>
|
||||
|
||||
<footer class="mt-5 mb-4">
|
||||
<div class="custom-footer-text text-start">
|
||||
<strong v-if="enableEditMode">{{ $t("Custom Footer") }}:</strong>
|
||||
@ -576,9 +572,9 @@
|
||||
</p>
|
||||
|
||||
<div class="refresh-info mb-2">
|
||||
<div>{{ $t("lastUpdatedAt", { date: lastUpdateTimeDisplay }) }}</div>
|
||||
<div>{{ $t("Last Updated") }}: {{ lastUpdateTimeDisplay }}</div>
|
||||
<div data-testid="update-countdown-text">
|
||||
{{ $t("statusPageRefreshIn", [updateCountdownText]) }}
|
||||
{{ $tc("statusPageRefreshIn", [updateCountdownText]) }}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@ -619,9 +615,6 @@ import DOMPurify from "dompurify";
|
||||
import Confirm from "../components/Confirm.vue";
|
||||
import PublicGroupList from "../components/PublicGroupList.vue";
|
||||
import MaintenanceTime from "../components/MaintenanceTime.vue";
|
||||
import IncidentHistory from "../components/IncidentHistory.vue";
|
||||
import IncidentManageModal from "../components/IncidentManageModal.vue";
|
||||
import IncidentEditForm from "../components/IncidentEditForm.vue";
|
||||
import { getResBaseURL } from "../util-frontend";
|
||||
import {
|
||||
STATUS_PAGE_ALL_DOWN,
|
||||
@ -655,9 +648,6 @@ export default {
|
||||
MaintenanceTime,
|
||||
Tag,
|
||||
VueMultiselect,
|
||||
IncidentHistory,
|
||||
IncidentManageModal,
|
||||
IncidentEditForm,
|
||||
},
|
||||
|
||||
// Leave Page for vue route change
|
||||
@ -703,10 +693,6 @@ export default {
|
||||
updateCountdown: null,
|
||||
updateCountdownText: null,
|
||||
loading: true,
|
||||
incidentHistory: [],
|
||||
incidentHistoryLoading: false,
|
||||
incidentHistoryNextCursor: null,
|
||||
incidentHistoryHasMore: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -832,7 +818,7 @@ export default {
|
||||
},
|
||||
|
||||
incidentHTML() {
|
||||
if (this.incident && this.incident.content != null) {
|
||||
if (this.incident.content != null) {
|
||||
return DOMPurify.sanitize(marked(this.incident.content));
|
||||
} else {
|
||||
return "";
|
||||
@ -858,40 +844,6 @@ export default {
|
||||
lastUpdateTimeDisplay() {
|
||||
return this.$root.datetime(this.lastUpdateTime);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all active pinned incidents for display at the top
|
||||
* @returns {object[]} List of active pinned incidents
|
||||
*/
|
||||
activeIncidents() {
|
||||
return this.incidentHistory.filter((i) => i.active && i.pin);
|
||||
},
|
||||
|
||||
/**
|
||||
* Count of past incidents (non-active or unpinned)
|
||||
* @returns {number} Number of past incidents
|
||||
*/
|
||||
pastIncidentCount() {
|
||||
return this.incidentHistory.filter((i) => !(i.active && i.pin)).length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Group past incidents (non-active or unpinned) by date for display
|
||||
* Active+pinned incidents are shown separately at the top, not in this section
|
||||
* @returns {object} Incidents grouped by date string
|
||||
*/
|
||||
groupedIncidentHistory() {
|
||||
const groups = {};
|
||||
const pastIncidents = this.incidentHistory.filter((i) => !(i.active && i.pin));
|
||||
for (const incident of pastIncidents) {
|
||||
const dateKey = this.formatDateKey(incident.createdDate);
|
||||
if (!groups[dateKey]) {
|
||||
groups[dateKey] = [];
|
||||
}
|
||||
groups[dateKey].push(incident);
|
||||
}
|
||||
return groups;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
/**
|
||||
@ -994,18 +946,6 @@ export default {
|
||||
this.imgDataUrl = this.config.icon;
|
||||
}
|
||||
|
||||
this.maintenanceList = res.data.maintenanceList;
|
||||
this.$root.publicGroupList = res.data.publicGroupList;
|
||||
|
||||
this.loading = false;
|
||||
|
||||
feedInterval = setInterval(
|
||||
() => {
|
||||
this.updateHeartbeatList();
|
||||
},
|
||||
Math.max(5, this.config.autoRefreshInterval) * 1000
|
||||
);
|
||||
|
||||
this.incident = res.data.incident;
|
||||
this.maintenanceList = res.data.maintenanceList;
|
||||
this.$root.publicGroupList = res.data.publicGroupList;
|
||||
@ -1030,7 +970,6 @@ export default {
|
||||
});
|
||||
|
||||
this.updateHeartbeatList();
|
||||
this.loadIncidentHistory();
|
||||
|
||||
// Go to edit page if ?edit present
|
||||
// null means ?edit present, but no value
|
||||
@ -1303,8 +1242,7 @@ export default {
|
||||
this.$root.getSocket().emit("postIncident", this.slug, this.incident, (res) => {
|
||||
if (res.ok) {
|
||||
this.enableEditIncidentMode = false;
|
||||
this.incident = null;
|
||||
this.loadIncidentHistory();
|
||||
this.incident = res.incident;
|
||||
} else {
|
||||
this.$root.toastError(res.msg);
|
||||
}
|
||||
@ -1312,14 +1250,12 @@ export default {
|
||||
},
|
||||
|
||||
/**
|
||||
* Edit an incident inline
|
||||
* @param {object} incident - The incident to edit
|
||||
* Click Edit Button
|
||||
* @returns {void}
|
||||
*/
|
||||
editIncident(incident) {
|
||||
this.previousIncident = this.incident;
|
||||
this.incident = { ...incident };
|
||||
editIncident() {
|
||||
this.enableEditIncidentMode = true;
|
||||
this.previousIncident = Object.assign({}, this.incident);
|
||||
},
|
||||
|
||||
/**
|
||||
@ -1345,18 +1281,6 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get HTML for incident content
|
||||
* @param {string} content - Markdown content
|
||||
* @returns {string} Sanitized HTML
|
||||
*/
|
||||
getIncidentHTML(content) {
|
||||
if (content != null) {
|
||||
return DOMPurify.sanitize(marked(content));
|
||||
}
|
||||
return "";
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the relative time difference of a date from now
|
||||
* @param {any} date Date to get time difference
|
||||
@ -1387,105 +1311,6 @@ export default {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load incident history for the status page
|
||||
* @returns {void}
|
||||
*/
|
||||
loadIncidentHistory() {
|
||||
this.loadIncidentHistoryWithCursor(null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Load incident history using cursor-based pagination
|
||||
* @param {string|null} cursor - Cursor for pagination (created_date of last item)
|
||||
* @param {boolean} append - Whether to append to existing list
|
||||
* @returns {void}
|
||||
*/
|
||||
loadIncidentHistoryWithCursor(cursor, append = false) {
|
||||
this.incidentHistoryLoading = true;
|
||||
|
||||
if (this.enableEditMode) {
|
||||
this.$root.getSocket().emit("getIncidentHistory", this.slug, cursor, (res) => {
|
||||
this.incidentHistoryLoading = false;
|
||||
if (res.ok) {
|
||||
if (append) {
|
||||
this.incidentHistory = [...this.incidentHistory, ...res.incidents];
|
||||
} else {
|
||||
this.incidentHistory = res.incidents;
|
||||
}
|
||||
this.incidentHistoryNextCursor = res.nextCursor;
|
||||
this.incidentHistoryHasMore = res.hasMore;
|
||||
} else {
|
||||
console.error("Failed to load incident history:", res.msg);
|
||||
this.$root.toastError(res.msg);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const url = cursor
|
||||
? `/api/status-page/${this.slug}/incident-history?cursor=${encodeURIComponent(cursor)}`
|
||||
: `/api/status-page/${this.slug}/incident-history`;
|
||||
axios
|
||||
.get(url)
|
||||
.then((res) => {
|
||||
this.incidentHistoryLoading = false;
|
||||
if (res.data.ok) {
|
||||
if (append) {
|
||||
this.incidentHistory = [...this.incidentHistory, ...res.data.incidents];
|
||||
} else {
|
||||
this.incidentHistory = res.data.incidents;
|
||||
}
|
||||
this.incidentHistoryNextCursor = res.data.nextCursor;
|
||||
this.incidentHistoryHasMore = res.data.hasMore;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.incidentHistoryLoading = false;
|
||||
console.error("Failed to load incident history:", error);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load more incident history using cursor-based pagination
|
||||
* @returns {void}
|
||||
*/
|
||||
loadMoreIncidentHistory() {
|
||||
if (this.incidentHistoryHasMore && this.incidentHistoryNextCursor) {
|
||||
this.loadIncidentHistoryWithCursor(this.incidentHistoryNextCursor, true);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format date key for grouping (e.g., "December 8, 2025")
|
||||
* @param {string} dateStr - ISO date string
|
||||
* @returns {string} Formatted date key
|
||||
*/
|
||||
formatDateKey(dateStr) {
|
||||
if (!dateStr) {
|
||||
return this.$t("Unknown");
|
||||
}
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve an incident
|
||||
* @param {object} incident - The incident to resolve
|
||||
* @returns {void}
|
||||
*/
|
||||
resolveIncident(incident) {
|
||||
this.$root.getSocket().emit("resolveIncident", this.slug, incident.id, (res) => {
|
||||
this.$root.toastRes(res);
|
||||
if (res.ok) {
|
||||
this.loadIncidentHistory();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@ -1604,14 +1429,12 @@ footer {
|
||||
|
||||
/* Reset button placed at top-left of the logo */
|
||||
.reset-top-left {
|
||||
transition:
|
||||
transform $easing-in 0.18s,
|
||||
box-shadow $easing-in 0.18s,
|
||||
background-color $easing-in 0.18s;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -15px;
|
||||
z-index: 2;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -1620,6 +1443,11 @@ footer {
|
||||
border: none;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition:
|
||||
transform $easing-in 0.18s,
|
||||
box-shadow $easing-in 0.18s,
|
||||
background-color $easing-in 0.18s;
|
||||
transform-origin: center;
|
||||
|
||||
&:hover {
|
||||
@ -1756,28 +1584,4 @@ footer {
|
||||
.refresh-info {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.past-incidents-title {
|
||||
font-size: 26px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.past-incidents-section {
|
||||
.past-incidents-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.incident-date-group {
|
||||
.incident-date-header {
|
||||
font-size: 1rem;
|
||||
font-weight: normal;
|
||||
color: var(--bs-secondary);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.incident-list-box {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -10,8 +10,8 @@
|
||||
*/
|
||||
var _a;
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.CONSOLE_STYLE_FgLightBlue = exports.CONSOLE_STYLE_FgLightGreen = exports.CONSOLE_STYLE_FgOrange = exports.CONSOLE_STYLE_FgGray = exports.CONSOLE_STYLE_FgWhite = exports.CONSOLE_STYLE_FgCyan = exports.CONSOLE_STYLE_FgMagenta = exports.CONSOLE_STYLE_FgBlue = exports.CONSOLE_STYLE_FgYellow = exports.CONSOLE_STYLE_FgGreen = exports.CONSOLE_STYLE_FgRed = exports.CONSOLE_STYLE_FgBlack = exports.CONSOLE_STYLE_Hidden = exports.CONSOLE_STYLE_Reverse = exports.CONSOLE_STYLE_Blink = exports.CONSOLE_STYLE_Underscore = exports.CONSOLE_STYLE_Dim = exports.CONSOLE_STYLE_Bright = exports.CONSOLE_STYLE_Reset = exports.RESPONSE_BODY_LENGTH_MAX = exports.RESPONSE_BODY_LENGTH_DEFAULT = exports.PING_PER_REQUEST_TIMEOUT_DEFAULT = exports.PING_PER_REQUEST_TIMEOUT_MAX = exports.PING_PER_REQUEST_TIMEOUT_MIN = exports.PING_COUNT_DEFAULT = exports.PING_COUNT_MAX = exports.PING_COUNT_MIN = exports.PING_GLOBAL_TIMEOUT_DEFAULT = exports.PING_GLOBAL_TIMEOUT_MAX = exports.PING_GLOBAL_TIMEOUT_MIN = exports.PING_PACKET_SIZE_DEFAULT = exports.PING_PACKET_SIZE_MAX = exports.PING_PACKET_SIZE_MIN = exports.INCIDENT_PAGE_SIZE = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isNode = exports.isDev = void 0;
|
||||
exports.TYPES_WITH_DOMAIN_EXPIRY_SUPPORT_VIA_FIELD = exports.evaluateJsonQuery = exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.badgeConstants = exports.CONSOLE_STYLE_BgGray = exports.CONSOLE_STYLE_BgWhite = exports.CONSOLE_STYLE_BgCyan = exports.CONSOLE_STYLE_BgMagenta = exports.CONSOLE_STYLE_BgBlue = exports.CONSOLE_STYLE_BgYellow = exports.CONSOLE_STYLE_BgGreen = exports.CONSOLE_STYLE_BgRed = exports.CONSOLE_STYLE_BgBlack = exports.CONSOLE_STYLE_FgPink = exports.CONSOLE_STYLE_FgBrown = exports.CONSOLE_STYLE_FgViolet = void 0;
|
||||
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";
|
||||
@ -31,7 +31,6 @@ exports.SQL_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
|
||||
exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = "YYYY-MM-DD HH:mm";
|
||||
exports.MAX_INTERVAL_SECOND = 2073600;
|
||||
exports.MIN_INTERVAL_SECOND = 1;
|
||||
exports.INCIDENT_PAGE_SIZE = 10;
|
||||
exports.PING_PACKET_SIZE_MIN = 1;
|
||||
exports.PING_PACKET_SIZE_MAX = 65500;
|
||||
exports.PING_PACKET_SIZE_DEFAULT = 56;
|
||||
|
||||
@ -46,8 +46,6 @@ export const SQL_DATETIME_FORMAT_WITHOUT_SECOND = "YYYY-MM-DD HH:mm";
|
||||
export const MAX_INTERVAL_SECOND = 2073600; // 24 days
|
||||
export const MIN_INTERVAL_SECOND = 1; // 1 second
|
||||
|
||||
export const INCIDENT_PAGE_SIZE = 10;
|
||||
|
||||
// Packet Size limits
|
||||
export const PING_PACKET_SIZE_MIN = 1;
|
||||
export const PING_PACKET_SIZE_MAX = 65500;
|
||||
|
||||
@ -49,203 +49,5 @@ 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@ -96,26 +96,58 @@ describe("Domain Expiry", () => {
|
||||
});
|
||||
|
||||
describe("Domain Parsing", () => {
|
||||
test("throws error for IP address (isIp check)", async () => {
|
||||
test("throws error for invalid domain (no domain part)", async () => {
|
||||
const monitor = {
|
||||
type: "http",
|
||||
url: "https://127.0.0.1",
|
||||
url: "https://",
|
||||
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_ip");
|
||||
assert.strictEqual(error.message, "domain_expiry_unsupported_invalid_domain");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("throws error for too short suffix(example.a)", async () => {
|
||||
test("throws error for IPv4 address instead of domain", async () => {
|
||||
const monitor = {
|
||||
type: "http",
|
||||
url: "https://example.a",
|
||||
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",
|
||||
domainExpiryNotification: true,
|
||||
};
|
||||
await assert.rejects(
|
||||
@ -127,22 +159,6 @@ 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", () => {
|
||||
@ -157,17 +173,6 @@ describe("Domain Expiry", () => {
|
||||
assert.strictEqual(supportInfo.tld, "com");
|
||||
});
|
||||
|
||||
test("supports multi-level public suffix via RDAP fallback (e.g. com.br)", async () => {
|
||||
const monitor = {
|
||||
type: "http",
|
||||
url: "https://record.com.br",
|
||||
domainExpiryNotification: true,
|
||||
};
|
||||
const supportInfo = await DomainExpiry.checkSupport(monitor);
|
||||
assert.strictEqual(supportInfo.domain, "record.com.br");
|
||||
assert.strictEqual(supportInfo.tld, "br");
|
||||
});
|
||||
|
||||
test("handles complex subdomain correctly", async () => {
|
||||
const monitor = {
|
||||
type: "http",
|
||||
@ -200,6 +205,22 @@ 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;
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,128 +0,0 @@
|
||||
const { describe, test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { GenericContainer } = require("testcontainers");
|
||||
const { SNMPMonitorType } = require("../../server/monitor-types/snmp");
|
||||
const { UP } = require("../../src/util");
|
||||
const snmp = require("net-snmp");
|
||||
|
||||
describe("SNMPMonitorType", () => {
|
||||
test(
|
||||
"check() sets heartbeat to UP when SNMP agent responds",
|
||||
{
|
||||
skip: !!process.env.CI && (process.platform !== "linux" || process.arch !== "x64"),
|
||||
},
|
||||
async () => {
|
||||
const container = await new GenericContainer("polinux/snmpd").withExposedPorts("161/udp").start();
|
||||
|
||||
try {
|
||||
// Get the mapped UDP port
|
||||
const hostPort = container.getMappedPort("161/udp");
|
||||
const hostIp = container.getHost();
|
||||
|
||||
// UDP service small wait to ensure snmpd is ready inside container
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
|
||||
const monitor = {
|
||||
type: "snmp",
|
||||
hostname: hostIp,
|
||||
port: hostPort,
|
||||
snmpVersion: "2c",
|
||||
radiusPassword: "public",
|
||||
snmpOid: "1.3.6.1.2.1.1.1.0",
|
||||
timeout: 5,
|
||||
maxretries: 1,
|
||||
jsonPath: "$",
|
||||
jsonPathOperator: "!=",
|
||||
expectedValue: "",
|
||||
};
|
||||
|
||||
const snmpMonitor = new SNMPMonitorType();
|
||||
const heartbeat = {};
|
||||
|
||||
await snmpMonitor.check(monitor, heartbeat);
|
||||
|
||||
assert.strictEqual(heartbeat.status, UP);
|
||||
assert.match(heartbeat.msg, /JSON query passes/);
|
||||
} finally {
|
||||
await container.stop();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"check() throws when SNMP agent does not respond",
|
||||
{
|
||||
skip: !!process.env.CI && (process.platform !== "linux" || process.arch !== "x64"),
|
||||
},
|
||||
async () => {
|
||||
const monitor = {
|
||||
type: "snmp",
|
||||
hostname: "127.0.0.1",
|
||||
port: 65530, // Assuming no SNMP agent is running here
|
||||
snmpVersion: "2c",
|
||||
radiusPassword: "public",
|
||||
snmpOid: "1.3.6.1.2.1.1.1.0",
|
||||
timeout: 1,
|
||||
maxretries: 1,
|
||||
};
|
||||
|
||||
const snmpMonitor = new SNMPMonitorType();
|
||||
const heartbeat = {};
|
||||
|
||||
await assert.rejects(() => snmpMonitor.check(monitor, heartbeat), /timeout|RequestTimedOutError/i);
|
||||
}
|
||||
);
|
||||
|
||||
test("check() uses SNMPv3 noAuthNoPriv session when version is 3", async () => {
|
||||
const originalCreateV3Session = snmp.createV3Session;
|
||||
const originalCreateSession = snmp.createSession;
|
||||
|
||||
let createV3Called = false;
|
||||
let createSessionCalled = false;
|
||||
let receivedOptions = null;
|
||||
|
||||
// Stub createV3Session
|
||||
snmp.createV3Session = function (_host, _username, options) {
|
||||
createV3Called = true;
|
||||
receivedOptions = options;
|
||||
|
||||
return {
|
||||
on: () => {},
|
||||
close: () => {},
|
||||
// Stop execution after session creation to avoid real network I/O.
|
||||
get: (_oids, cb) => cb(new Error("stop test here")),
|
||||
};
|
||||
};
|
||||
|
||||
// Stub createSession
|
||||
snmp.createSession = function () {
|
||||
createSessionCalled = true;
|
||||
return {};
|
||||
};
|
||||
|
||||
const monitor = {
|
||||
type: "snmp",
|
||||
hostname: "127.0.0.1",
|
||||
port: 161,
|
||||
timeout: 5,
|
||||
maxretries: 1,
|
||||
snmpVersion: "3",
|
||||
snmp_v3_username: "testuser",
|
||||
snmpOid: "1.3.6.1.2.1.1.1.0",
|
||||
};
|
||||
|
||||
const snmpMonitor = new SNMPMonitorType();
|
||||
const heartbeat = {};
|
||||
|
||||
await assert.rejects(() => snmpMonitor.check(monitor, heartbeat), /stop test here/);
|
||||
|
||||
// Assertions
|
||||
assert.strictEqual(createV3Called, true);
|
||||
assert.strictEqual(createSessionCalled, false);
|
||||
assert.strictEqual(receivedOptions.securityLevel, snmp.SecurityLevel.noAuthNoPriv);
|
||||
|
||||
// Restore originals
|
||||
snmp.createV3Session = originalCreateV3Session;
|
||||
snmp.createSession = originalCreateSession;
|
||||
});
|
||||
});
|
||||
@ -1,150 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { login, restoreSqliteSnapshot, screenshot } from "../util-test";
|
||||
|
||||
test.describe("Incident History", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await restoreSqliteSnapshot(page);
|
||||
});
|
||||
|
||||
test("past incidents section is hidden when no incidents exist", async ({ page }, testInfo) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
await page.goto("./add");
|
||||
await login(page);
|
||||
await expect(page.getByTestId("monitor-type-select")).toBeVisible();
|
||||
|
||||
await page.goto("./add-status-page");
|
||||
await page.getByTestId("name-input").fill("Empty Test");
|
||||
await page.getByTestId("slug-input").fill("empty-test");
|
||||
await page.getByTestId("submit-button").click();
|
||||
await page.waitForURL("/status/empty-test?edit");
|
||||
|
||||
await page.getByTestId("save-button").click();
|
||||
await expect(page.getByTestId("edit-sidebar")).toHaveCount(0);
|
||||
|
||||
const pastIncidentsSection = page.locator(".past-incidents-section");
|
||||
await expect(pastIncidentsSection).toHaveCount(0);
|
||||
|
||||
await screenshot(testInfo, page);
|
||||
});
|
||||
|
||||
test("active pinned incidents are shown at top and not in past incidents", async ({ page }, testInfo) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
await page.goto("./add");
|
||||
await login(page);
|
||||
await expect(page.getByTestId("monitor-type-select")).toBeVisible();
|
||||
|
||||
await page.goto("./add-status-page");
|
||||
await page.getByTestId("name-input").fill("Dedup Test");
|
||||
await page.getByTestId("slug-input").fill("dedup-test");
|
||||
await page.getByTestId("submit-button").click();
|
||||
await page.waitForURL("/status/dedup-test?edit");
|
||||
|
||||
await page.getByTestId("create-incident-button").click();
|
||||
await page.getByTestId("incident-title").fill("Active Incident");
|
||||
await page.getByTestId("incident-content-editable").fill("This is an active incident");
|
||||
await page.getByTestId("post-incident-button").click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.getByTestId("save-button").click();
|
||||
await expect(page.getByTestId("edit-sidebar")).toHaveCount(0);
|
||||
|
||||
const activeIncident = page.getByTestId("incident").filter({ hasText: "Active Incident" });
|
||||
await expect(activeIncident).toBeVisible();
|
||||
|
||||
const pastIncidentsSection = page.locator(".past-incidents-section");
|
||||
await expect(pastIncidentsSection).toHaveCount(0);
|
||||
|
||||
await screenshot(testInfo, page);
|
||||
});
|
||||
|
||||
test("resolved incidents appear in past incidents section", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
await page.goto("./add");
|
||||
await login(page);
|
||||
await expect(page.getByTestId("monitor-type-select")).toBeVisible();
|
||||
|
||||
await page.goto("./add-status-page");
|
||||
await page.getByTestId("name-input").fill("Resolve Test");
|
||||
await page.getByTestId("slug-input").fill("resolve-test");
|
||||
await page.getByTestId("submit-button").click();
|
||||
await page.waitForURL("/status/resolve-test?edit");
|
||||
|
||||
await page.getByTestId("create-incident-button").click();
|
||||
await page.getByTestId("incident-title").fill("Resolved Incident");
|
||||
await page.getByTestId("incident-content-editable").fill("This incident will be resolved");
|
||||
await page.getByTestId("post-incident-button").click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const activeIncidentBanner = page.getByTestId("incident").filter({ hasText: "Resolved Incident" });
|
||||
await expect(activeIncidentBanner).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const resolveButton = activeIncidentBanner.locator("button", { hasText: "Resolve" });
|
||||
await expect(resolveButton).toBeVisible();
|
||||
await resolveButton.click();
|
||||
|
||||
await expect(activeIncidentBanner).toHaveCount(0, { timeout: 10000 });
|
||||
|
||||
await page.getByTestId("save-button").click();
|
||||
await expect(page.getByTestId("edit-sidebar")).toHaveCount(0);
|
||||
|
||||
await page.goto("./status/resolve-test");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const pastIncidentsSection = page.locator(".past-incidents-section");
|
||||
await expect(pastIncidentsSection).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const resolvedIncidentTitle = pastIncidentsSection.locator(".incident-title");
|
||||
await expect(resolvedIncidentTitle).toContainText("Resolved Incident", { timeout: 15000 });
|
||||
|
||||
await screenshot(testInfo, page);
|
||||
});
|
||||
|
||||
test("incident history pagination loads more incidents", async ({ page }, testInfo) => {
|
||||
test.setTimeout(180000);
|
||||
|
||||
await page.goto("./add");
|
||||
await login(page);
|
||||
await expect(page.getByTestId("monitor-type-select")).toBeVisible();
|
||||
|
||||
await page.goto("./add-status-page");
|
||||
await page.getByTestId("name-input").fill("Pagination Test");
|
||||
await page.getByTestId("slug-input").fill("pagination-test");
|
||||
await page.getByTestId("submit-button").click();
|
||||
await page.waitForURL("/status/pagination-test?edit");
|
||||
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
await page.getByTestId("create-incident-button").click();
|
||||
await page.getByTestId("incident-title").fill("Incident " + i);
|
||||
await page.getByTestId("incident-content-editable").fill("Content for incident " + i);
|
||||
await page.getByTestId("post-incident-button").click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const resolveButton = page.locator("button", { hasText: "Resolve" }).first();
|
||||
if (await resolveButton.isVisible()) {
|
||||
await resolveButton.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
|
||||
await page.getByTestId("save-button").click();
|
||||
await expect(page.getByTestId("edit-sidebar")).toHaveCount(0);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const pastIncidentsSection = page.locator(".past-incidents-section");
|
||||
await expect(pastIncidentsSection).toBeVisible();
|
||||
|
||||
const loadMoreButton = page.locator("button", { hasText: "Load More" });
|
||||
|
||||
if (await loadMoreButton.isVisible()) {
|
||||
await loadMoreButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await screenshot(testInfo, page);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user