diff --git a/.dockerignore b/.dockerignore index 4a63437a4..4ce0e13ab 100644 --- a/.dockerignore +++ b/.dockerignore @@ -28,6 +28,8 @@ SECURITY.md tsconfig.json .env /tmp +/babel.config.js +/ecosystem.config.js ### .gitignore content (commented rules are duplicated) @@ -42,4 +44,6 @@ dist-ssr #!/data/.gitkeep #.vscode + + ### End of .gitignore content diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..521a9f7c0 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff47b90b7..1e6f7dfad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,8 @@ My long story here: https://www.reddit.com/r/UptimeKuma/comments/t1t6or/comment/ ### Recommended Pull Request Guideline +Before deep into coding, disscussion first is preferred. Creating an empty pull request for disscussion would be recommended. + 1. Fork the project 1. Clone your fork repo to local 1. Create a new branch @@ -53,6 +55,7 @@ My long story here: https://www.reddit.com/r/UptimeKuma/comments/t1t6or/comment/ 1. Create a pull request: https://github.com/louislam/uptime-kuma/compare 1. Write a proper description 1. Click "Change to draft" +1. Discussion #### ❌ Won't Merge diff --git a/README.md b/README.md index 05e0bb21c..8bb922dc9 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,6 @@ VPS is sponsored by Uptime Kuma sponsors on [Open Collective](https://opencollec ### 🐳 Docker ```bash -docker volume create uptime-kuma docker run -d --restart=always -p 3001:3001 -v uptime-kuma:/app/data --name uptime-kuma louislam/uptime-kuma:1 ``` @@ -47,7 +46,10 @@ Browse to http://localhost:3001 after starting. ### 💪🏻 Non-Docker -Required Tools: Node.js >= 14, git and pm2. +Required Tools: +- [Node.js](https://nodejs.org/en/download/) >= 14 +- [Git](https://git-scm.com/downloads) +- [pm2](https://pm2.keymetrics.io/) - For run in background ```bash # Update your npm to the latest version @@ -67,11 +69,19 @@ npm install pm2 -g && pm2 install pm2-logrotate # Start Server pm2 start server/server.js --name uptime-kuma + +``` +Browse to http://localhost:3001 after starting. + +More useful PM2 Commands + +```bash # If you want to see the current console output pm2 monit -``` -Browse to http://localhost:3001 after starting. +# If you want to add it to startup +pm2 save && pm2 startup +``` ### Advanced Installation diff --git a/db/patch-monitor-expiry-notification.sql b/db/patch-monitor-expiry-notification.sql new file mode 100644 index 000000000..7a330014a --- /dev/null +++ b/db/patch-monitor-expiry-notification.sql @@ -0,0 +1,7 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +BEGIN TRANSACTION; + +ALTER TABLE monitor + ADD expiry_notification BOOLEAN default 1; + +COMMIT; diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index ba22bd24e..a6499ef9f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -5,9 +5,10 @@ version: '3.3' services: uptime-kuma: - image: louislam/uptime-kuma + image: louislam/uptime-kuma:1 container_name: uptime-kuma volumes: - ./uptime-kuma:/app/data ports: - 3001:3001 + restart: always diff --git a/extra/download-dist.js b/extra/download-dist.js index c184c2846..b04beec7a 100644 --- a/extra/download-dist.js +++ b/extra/download-dist.js @@ -12,6 +12,12 @@ const filename = "dist.tar.gz"; const url = `https://github.com/louislam/uptime-kuma/releases/download/${version}/${filename}`; download(url); +/** + * Downloads the latest version of the dist from a GitHub release. + * @param {string} url The URL to download from. + * + * Generated by Trelent + */ function download(url) { console.log(url); diff --git a/extra/fs-rmSync.js b/extra/fs-rmSync.js index 4c12f22e0..aa45b6dc3 100644 --- a/extra/fs-rmSync.js +++ b/extra/fs-rmSync.js @@ -4,7 +4,10 @@ const fs = require("fs"); * to avoid the runtime deprecation warning triggered for using `fs.rmdirSync` with `{ recursive: true }` in Node.js v16, * or the `recursive` property removing completely in the future Node.js version. * See the link below. - * @link https://nodejs.org/docs/latest-v16.x/api/deprecations.html#dep0147-fsrmdirpath--recursive-true- + * + * @todo Once we drop the support for Node.js v14 (or at least versions before v14.14.0), we can safely replace this function with `fs.rmSync`, since `fs.rmSync` was add in Node.js v14.14.0 and currently we supports all the Node.js v14 versions that include the versions before the v14.14.0, and this function have almost the same signature with `fs.rmSync`. + * @link https://nodejs.org/docs/latest-v16.x/api/deprecations.html#dep0147-fsrmdirpath--recursive-true- the deprecation infomation of `fs.rmdirSync` + * @link https://nodejs.org/docs/latest-v16.x/api/fs.html#fsrmsyncpath-options the document of `fs.rmSync` * @param {fs.PathLike} path Valid types for path values in "fs". * @param {fs.RmDirOptions} [options] options for `fs.rmdirSync`, if `fs.rmSync` is available and property `recursive` is true, it will automatically have property `force` with value `true`. */ diff --git a/extra/reset-password.js b/extra/reset-password.js index 1b48dffd7..160ef0a3e 100644 --- a/extra/reset-password.js +++ b/extra/reset-password.js @@ -1,7 +1,5 @@ console.log("== Uptime Kuma Reset Password Tool =="); -console.log("Loading the database"); - const Database = require("../server/database"); const { R } = require("redbean-node"); const readline = require("readline"); @@ -13,8 +11,9 @@ const rl = readline.createInterface({ }); const main = async () => { + console.log("Connecting the database"); Database.init(args); - await Database.connect(); + await Database.connect(false, false, true); try { // No need to actually reset the password for testing, just make sure no connection problem. It is ok for now. diff --git a/extra/update-version.js b/extra/update-version.js index 505bb2cb0..d72fee68f 100644 --- a/extra/update-version.js +++ b/extra/update-version.js @@ -33,6 +33,12 @@ if (! exists) { console.log("version exists"); } +/** + * Updates the version number in package.json and commits it to git. + * @param {string} version - The new version number + * + * Generated by Trelent + */ function commit(version) { let msg = "Update to " + version; @@ -50,6 +56,12 @@ function tag(version) { console.log(res.stdout.toString().trim()); } +/** + * Checks if a given version is already tagged in the git repository. + * @param {string} version - The version to check for. + * + * Generated by Trelent + */ function tagExists(version) { if (! version) { throw new Error("invalid version"); diff --git a/package.json b/package.json index 8bffb56e5..28db266a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "uptime-kuma", - "version": "1.14.0-beta.1", + "version": "1.14.0", "license": "MIT", "repository": { "type": "git", @@ -36,7 +36,7 @@ "build-docker-nightly-alpine": "docker buildx build -f docker/dockerfile-alpine --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:nightly-alpine --target nightly . --push", "build-docker-nightly-amd64": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push --progress plain", "upload-artifacts": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain", - "setup": "git checkout 1.13.1 && npm ci --production && npm run download-dist", + "setup": "git checkout 1.14.0 && npm ci --production && npm run download-dist", "download-dist": "node extra/download-dist.js", "mark-as-nightly": "node extra/mark-as-nightly.js", "reset-password": "node extra/reset-password.js", diff --git a/server/auth.js b/server/auth.js index c59d65492..cd14f690e 100644 --- a/server/auth.js +++ b/server/auth.js @@ -34,6 +34,13 @@ exports.login = async function (username, password) { return null; }; +/** + * A function that checks if a user is logged in. + * @param {string} username The username of the user to check for. + * @param {function} callback The callback to call when done, with an error and result parameter. + * + * Generated by Trelent + */ function myAuthorizer(username, password, callback) { // Login Rate Limit loginRateLimiter.pass(null, 0).then((pass) => { diff --git a/server/check-version.js b/server/check-version.js index f3b15e848..c9d87c96f 100644 --- a/server/check-version.js +++ b/server/check-version.js @@ -17,7 +17,7 @@ exports.startInterval = () => { res.data.slow = "1000.0.0"; } - if (!await setting("checkUpdate")) { + if (await setting("checkUpdate") === false) { return; } diff --git a/server/client.js b/server/client.js index 2c07448b1..3a2d6df27 100644 --- a/server/client.js +++ b/server/client.js @@ -7,6 +7,12 @@ const { io } = require("./server"); const { setting } = require("./util-server"); const checkVersion = require("./check-version"); +/** + * Send a list of notifications to the user. + * @param {Socket} socket The socket object that is connected to the client. + * + * Generated by Trelent + */ async function sendNotificationList(socket) { const timeLogger = new TimeLogger(); @@ -100,6 +106,12 @@ async function sendProxyList(socket) { return list; } +/** + * Emits the version information to the client. + * @param {Socket} socket The socket object that is connected to the client. + * + * Generated by Trelent + */ async function sendInfo(socket) { socket.emit("info", { version: checkVersion.version, diff --git a/server/database.js b/server/database.js index f7cd35eaf..a5046c6d4 100644 --- a/server/database.js +++ b/server/database.js @@ -56,6 +56,7 @@ class Database { "patch-add-docker-columns.sql": true, "patch-status-page.sql": true, "patch-proxy.sql": true, + "patch-monitor-expiry-notification.sql": true, } /** @@ -83,7 +84,7 @@ class Database { console.log(`Data Dir: ${Database.dataDir}`); } - static async connect(testMode = false) { + static async connect(testMode = false, autoloadModels = true, noLog = false) { const acquireConnectionTimeout = 120 * 1000; const Dialect = require("knex/lib/dialects/sqlite3/index.js"); @@ -113,7 +114,10 @@ class Database { // Auto map the model to a bean object R.freeze(true); - await R.autoloadModels("./server/model"); + + if (autoloadModels) { + await R.autoloadModels("./server/model"); + } await R.exec("PRAGMA foreign_keys = ON"); if (testMode) { @@ -126,10 +130,17 @@ class Database { await R.exec("PRAGMA cache_size = -12000"); await R.exec("PRAGMA auto_vacuum = FULL"); - console.log("SQLite config:"); - console.log(await R.getAll("PRAGMA journal_mode")); - console.log(await R.getAll("PRAGMA cache_size")); - console.log("SQLite Version: " + await R.getCell("SELECT sqlite_version()")); + // This ensures that an operating system crash or power failure will not corrupt the database. + // FULL synchronous is very safe, but it is also slower. + // Read more: https://sqlite.org/pragma.html#pragma_synchronous + await R.exec("PRAGMA synchronous = FULL"); + + if (!noLog) { + console.log("SQLite config:"); + console.log(await R.getAll("PRAGMA journal_mode")); + console.log(await R.getAll("PRAGMA cache_size")); + console.log("SQLite Version: " + await R.getCell("SELECT sqlite_version()")); + } } static async patch() { diff --git a/server/image-data-uri.js b/server/image-data-uri.js index 3ccaab7d5..1ab499c15 100644 --- a/server/image-data-uri.js +++ b/server/image-data-uri.js @@ -6,6 +6,12 @@ let fs = require("fs"); let ImageDataURI = (() => { + /** + * @param {string} dataURI - A string that is a valid Data URI. + * @returns {?Object} An object with properties "imageType" and "dataBase64". The former is the image type, e.g., "png", and the latter is a base64 encoded string of the image's binary data. If it fails to parse, returns null instead of an object. + * + * Generated by Trelent + */ function decode(dataURI) { if (!/data:image\//.test(dataURI)) { console.log("ImageDataURI :: Error :: It seems that it is not an Image Data URI. Couldn't match \"data:image/\""); @@ -20,6 +26,13 @@ let ImageDataURI = (() => { }; } + /** + * @param {Buffer} data - The image data to be encoded. + * @param {String} mediaType - The type of the image, e.g., "image/png". + * @returns {String|null} A string representing the base64-encoded version of the given Buffer object or null if an error occurred. + * + * Generated by Trelent + */ function encode(data, mediaType) { if (!data || !mediaType) { console.log("ImageDataURI :: Error :: Missing some of the required params: data, mediaType "); @@ -33,6 +46,13 @@ let ImageDataURI = (() => { return dataImgBase64; } + /** + * Converts a data URI to a file path. + * @param {string} dataURI The Data URI of the image. + * @param {string} [filePath] The path where the image will be saved, defaults to "./". + * + * Generated by Trelent + */ function outputFile(dataURI, filePath) { filePath = filePath || "./"; return new Promise((resolve, reject) => { diff --git a/server/jobs.js b/server/jobs.js index 0469d5cab..d33adb98c 100644 --- a/server/jobs.js +++ b/server/jobs.js @@ -1,7 +1,7 @@ const path = require("path"); const Bree = require("bree"); const { SHARE_ENV } = require("worker_threads"); - +let bree; const jobs = [ { name: "clear-old-data", @@ -10,7 +10,7 @@ const jobs = [ ]; const initBackgroundJobs = function (args) { - const bree = new Bree({ + bree = new Bree({ root: path.resolve("server", "jobs"), jobs, worker: { @@ -26,6 +26,13 @@ const initBackgroundJobs = function (args) { return bree; }; -module.exports = { - initBackgroundJobs +const stopBackgroundJobs = function () { + if (bree) { + bree.stop(); + } +}; + +module.exports = { + initBackgroundJobs, + stopBackgroundJobs }; diff --git a/server/model/monitor.js b/server/model/monitor.js index 6657295e2..894ae71e5 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -74,6 +74,7 @@ class Monitor extends BeanModel { interval: this.interval, retryInterval: this.retryInterval, keyword: this.keyword, + expiryNotification: this.isEnabledExpiryNotification(), ignoreTls: this.getIgnoreTls(), upsideDown: this.isUpsideDown(), maxredirects: this.maxredirects, @@ -104,6 +105,10 @@ class Monitor extends BeanModel { return Buffer.from(user + ":" + pass).toString("base64"); } + isEnabledExpiryNotification() { + return Boolean(this.expiryNotification); + } + /** * Parse to boolean * @returns {boolean} @@ -243,7 +248,7 @@ class Monitor extends BeanModel { let tlsInfoObject = checkCertificate(res); tlsInfo = await this.updateTlsInfo(tlsInfoObject); - if (!this.getIgnoreTls()) { + if (!this.getIgnoreTls() && this.isEnabledExpiryNotification()) { debug(`[${this.name}] call sendCertNotification`); await this.sendCertNotification(tlsInfoObject); } diff --git a/server/model/status_page.js b/server/model/status_page.js index 6f763f586..1383d3b00 100644 --- a/server/model/status_page.js +++ b/server/model/status_page.js @@ -3,6 +3,20 @@ const { R } = require("redbean-node"); class StatusPage extends BeanModel { + static domainMappingList = { }; + + /** + * Return object like this: { "test-uptime.kuma.pet": "default" } + * @returns {Promise} + */ + static async loadDomainMappingList() { + StatusPage.domainMappingList = await R.getAssoc(` + SELECT domain, slug + FROM status_page, status_page_cname + WHERE status_page.id = status_page_cname.status_page_id + `); + } + static async sendStatusPageList(io, socket) { let result = {}; @@ -16,6 +30,57 @@ class StatusPage extends BeanModel { return list; } + async updateDomainNameList(domainNameList) { + + if (!Array.isArray(domainNameList)) { + throw new Error("Invalid array"); + } + + let trx = await R.begin(); + + await trx.exec("DELETE FROM status_page_cname WHERE status_page_id = ?", [ + this.id, + ]); + + try { + for (let domain of domainNameList) { + if (typeof domain !== "string") { + throw new Error("Invalid domain"); + } + + if (domain.trim() === "") { + continue; + } + + // If the domain name is used in another status page, delete it + await trx.exec("DELETE FROM status_page_cname WHERE domain = ?", [ + domain, + ]); + + let mapping = trx.dispense("status_page_cname"); + mapping.status_page_id = this.id; + mapping.domain = domain; + await trx.store(mapping); + } + await trx.commit(); + } catch (error) { + await trx.rollback(); + throw error; + } + } + + getDomainNameList() { + let domainList = []; + for (let domain in StatusPage.domainMappingList) { + let s = StatusPage.domainMappingList[domain]; + + if (this.slug === s) { + domainList.push(domain); + } + } + return domainList; + } + async toJSON() { return { id: this.id, @@ -26,6 +91,7 @@ class StatusPage extends BeanModel { theme: this.theme, published: !!this.published, showTags: !!this.show_tags, + domainNameList: this.getDomainNameList(), }; } diff --git a/server/modules/apicache/apicache.js b/server/modules/apicache/apicache.js index 22d1fed71..25f0a54f5 100644 --- a/server/modules/apicache/apicache.js +++ b/server/modules/apicache/apicache.js @@ -68,6 +68,15 @@ function ApiCache() { instances.push(this); this.id = instances.length; + /** + * Logs a message to the console if the `DEBUG` environment variable is set. + * @param {string} a - The first argument to log. + * @param {string} b - The second argument to log. + * @param {string} c - The third argument to log. + * @param {string} d - The fourth argument to log, and so on... (optional) + * + * Generated by Trelent + */ function debug(a, b, c, d) { let arr = ["\x1b[36m[apicache]\x1b[0m", a, b, c, d].filter(function (arg) { return arg !== undefined; @@ -77,6 +86,13 @@ function ApiCache() { return (globalOptions.debug || debugEnv) && console.log.apply(null, arr); } + /** + * Returns true if the given request and response should be logged. + * @param {Object} request The HTTP request object. + * @param {Object} response The HTTP response object. + * + * Generated by Trelent + */ function shouldCacheResponse(request, response, toggle) { let opt = globalOptions; let codes = opt.statusCodes; @@ -99,6 +115,12 @@ function ApiCache() { return true; } + /** + * Adds a key to the index. + * @param {string} key The key to add. + * + * Generated by Trelent + */ function addIndexEntries(key, req) { let groupName = req.apicacheGroup; @@ -111,6 +133,13 @@ function ApiCache() { index.all.unshift(key); } + /** + * Returns a new object containing only the whitelisted headers. + * @param {Object} headers The original object of header names and values. + * @param {Array.} globalOptions.headerWhitelist An array of strings representing the whitelisted header names to keep in the output object. + * + * Generated by Trelent + */ function filterBlacklistedHeaders(headers) { return Object.keys(headers) .filter(function (key) { @@ -122,6 +151,12 @@ function ApiCache() { }, {}); } + /** + * @param {Object} headers The response headers to filter. + * @returns {Object} A new object containing only the whitelisted response headers. + * + * Generated by Trelent + */ function createCacheObject(status, headers, data, encoding) { return { status: status, @@ -132,6 +167,14 @@ function ApiCache() { }; } + /** + * Sets a cache value for the given key. + * @param {string} key The cache key to set. + * @param {*} value The cache value to set. + * @param {number} duration How long in milliseconds the cached response should be valid for (defaults to 1 hour). + * + * Generated by Trelent + */ function cacheResponse(key, value, duration) { let redis = globalOptions.redisClient; let expireCallback = globalOptions.events.expire; @@ -154,6 +197,12 @@ function ApiCache() { }, Math.min(duration, 2147483647)); } + /** + * Appends content to the response. + * @param {string|Buffer} content The content to append. + * + * Generated by Trelent + */ function accumulateContent(res, content) { if (content) { if (typeof content == "string") { @@ -179,6 +228,13 @@ function ApiCache() { } } + /** + * Monkeypatches the response object to add cache control headers and create a cache object. + * @param {Object} req - The request object. + * @param {Object} res - The response object. + * + * Generated by Trelent + */ function makeResponseCacheable(req, res, next, key, duration, strDuration, toggle) { // monkeypatch res.end to create cache object res._apicache = { @@ -245,6 +301,13 @@ function ApiCache() { next(); } + /** + * @param {Request} request + * @param {Response} response + * @returns {boolean|undefined} true if the request should be cached, false otherwise. If undefined, defaults to true. + * + * Generated by Trelent + */ function sendCachedResponse(request, response, cacheObject, toggle, next, duration) { if (toggle && !toggle(request, response)) { return next(); @@ -365,6 +428,13 @@ function ApiCache() { return this.getIndex(); }; + /** + * Converts a duration string to an integer number of milliseconds. + * @param {string} duration - The string to convert. + * @returns {number} The converted value in milliseconds, or the defaultDuration if it can't be parsed. + * + * Generated by Trelent + */ function parseDuration(duration, defaultDuration) { if (typeof duration === "number") { return duration; diff --git a/server/notification-providers/alerta.js b/server/notification-providers/alerta.js index e692b57ba..bcee80df7 100644 --- a/server/notification-providers/alerta.js +++ b/server/notification-providers/alerta.js @@ -14,7 +14,7 @@ class Alerta extends NotificationProvider { let config = { headers: { "Content-Type": "application/json;charset=UTF-8", - "Authorization": "Key " + notification.alertaapiKey, + "Authorization": "Key " + notification.alertaApiKey, } }; let data = { diff --git a/server/notification-providers/mattermost.js b/server/notification-providers/mattermost.js index c2ffc23b8..fe7b685e1 100644 --- a/server/notification-providers/mattermost.js +++ b/server/notification-providers/mattermost.js @@ -15,12 +15,17 @@ class Mattermost extends NotificationProvider { let mattermostTestData = { username: mattermostUserName, text: msg, - } - await axios.post(notification.mattermostWebhookUrl, mattermostTestData) + }; + await axios.post(notification.mattermostWebhookUrl, mattermostTestData); return okMsg; } - const mattermostChannel = notification.mattermostchannel.toLowerCase(); + let mattermostChannel; + + if (typeof notification.mattermostchannel === "string") { + mattermostChannel = notification.mattermostchannel.toLowerCase(); + } + const mattermostIconEmoji = notification.mattermosticonemo; const mattermostIconUrl = notification.mattermosticonurl; diff --git a/server/notification.js b/server/notification.js index 30f83b0e0..35a268640 100644 --- a/server/notification.js +++ b/server/notification.js @@ -154,6 +154,13 @@ class Notification { } +/** + * Adds a new monitor to the database. + * @param {number} userID The ID of the user that owns this monitor. + * @param {string} name The name of this monitor. + * + * Generated by Trelent + */ async function applyNotificationEveryMonitor(notificationID, userID) { let monitors = await R.getAll("SELECT id FROM monitor WHERE user_id = ?", [ userID diff --git a/server/ping-lite.js b/server/ping-lite.js index 0075e80bd..5f15b6d3a 100644 --- a/server/ping-lite.js +++ b/server/ping-lite.js @@ -8,6 +8,13 @@ const util = require("./util-server"); module.exports = Ping; +/** + * @param {string} host - The host to ping + * @param {object} [options] - Options for the ping command + * @param {array|string} [options.args] - Arguments to pass to the ping command + * + * Generated by Trelent + */ function Ping(host, options) { if (!host) { throw new Error("You must specify a host to ping!"); @@ -125,6 +132,11 @@ Ping.prototype.send = function (callback) { } }); + /** + * @param {Function} callback + * + * Generated by Trelent + */ function onEnd() { let stdout = this.stdout._stdout; let stderr = this.stderr._stderr; diff --git a/server/proxy.js b/server/proxy.js index 392a0af7f..af72402d1 100644 --- a/server/proxy.js +++ b/server/proxy.js @@ -3,6 +3,7 @@ const HttpProxyAgent = require("http-proxy-agent"); const HttpsProxyAgent = require("https-proxy-agent"); const SocksProxyAgent = require("socks-proxy-agent"); const { debug } = require("../src/util"); +const server = require("./server"); class Proxy { @@ -144,6 +145,22 @@ class Proxy { httpsAgent }; } + + /** + * Reload proxy settings for current monitors + * @returns {Promise} + */ + static async reloadProxy() { + let updatedList = await R.getAssoc("SELECT id, proxy_id FROM monitor"); + + for (let monitorID in server.monitorList) { + let monitor = server.monitorList[monitorID]; + + if (updatedList[monitorID]) { + monitor.proxy_id = updatedList[monitorID].proxy_id; + } + } + } } /** diff --git a/server/routers/api-router.js b/server/routers/api-router.js index ad8870847..6f463b6b0 100644 --- a/server/routers/api-router.js +++ b/server/routers/api-router.js @@ -12,9 +12,19 @@ let router = express.Router(); let cache = apicache.middleware; let io = server.io; -router.get("/api/entry-page", async (_, response) => { +router.get("/api/entry-page", async (request, response) => { allowDevAllOrigin(response); - response.json(server.entryPage); + + let result = { }; + + if (request.hostname in StatusPage.domainMappingList) { + result.type = "statusPageMatchedDomain"; + result.statusPageSlug = StatusPage.domainMappingList[request.hostname]; + } else { + result.type = "entryPage"; + result.entryPage = server.entryPage; + } + response.json(result); }); router.get("/api/push/:pushToken", async (request, response) => { diff --git a/server/server.js b/server/server.js index 080301ea8..4323566a3 100644 --- a/server/server.js +++ b/server/server.js @@ -48,6 +48,27 @@ debug("Importing 2FA Modules"); const notp = require("notp"); const base32 = require("thirty-two"); +/** + * `module.exports` (alias: `server`) should be inside this class, in order to avoid circular dependency issue. + * @type {UptimeKumaServer} + */ +class UptimeKumaServer { + /** + * Main monitor list + * @type {{}} + */ + monitorList = {}; + entryPage = "dashboard"; + + async sendMonitorList(socket) { + let list = await getMonitorJSONList(socket.userID); + io.to(socket.userID).emit("monitorList", list); + return list; + } +} + +const server = module.exports = new UptimeKumaServer(); + console.log("Importing this project modules"); debug("Importing Monitor"); const Monitor = require("./model/monitor"); @@ -65,7 +86,7 @@ debug("Importing Database"); const Database = require("./database"); debug("Importing Background Jobs"); -const { initBackgroundJobs } = require("./jobs"); +const { initBackgroundJobs, stopBackgroundJobs } = require("./jobs"); const { loginRateLimiter, twoFaRateLimiter } = require("./rate-limiter"); const { basicAuth } = require("./auth"); @@ -77,23 +98,22 @@ console.info("Version: " + checkVersion.version); // If host is omitted, the server will accept connections on the unspecified IPv6 address (::) when IPv6 is available and the unspecified IPv4 address (0.0.0.0) otherwise. // Dual-stack support for (::) -let hostname = process.env.UPTIME_KUMA_HOST || args.host; - // Also read HOST if not FreeBSD, as HOST is a system environment variable in FreeBSD -if (!hostname && !FBSD) { - hostname = process.env.HOST; -} +let hostEnv = FBSD ? null : process.env.HOST; +let hostname = args.host || process.env.UPTIME_KUMA_HOST || hostEnv; if (hostname) { console.log("Custom hostname: " + hostname); } -const port = parseInt(process.env.UPTIME_KUMA_PORT || process.env.PORT || args.port || 3001); +const port = [args.port, process.env.UPTIME_KUMA_PORT, process.env.PORT, 3001] + .map(portValue => parseInt(portValue)) + .find(portValue => !isNaN(portValue)); // SSL -const sslKey = process.env.UPTIME_KUMA_SSL_KEY || process.env.SSL_KEY || args["ssl-key"] || undefined; -const sslCert = process.env.UPTIME_KUMA_SSL_CERT || process.env.SSL_CERT || args["ssl-cert"] || undefined; -const disableFrameSameOrigin = !!process.env.UPTIME_KUMA_DISABLE_FRAME_SAMEORIGIN || args["disable-frame-sameorigin"] || false; +const sslKey = args["ssl-key"] || process.env.UPTIME_KUMA_SSL_KEY || process.env.SSL_KEY || undefined; +const sslCert = args["ssl-cert"] || process.env.UPTIME_KUMA_SSL_CERT || process.env.SSL_CERT || undefined; +const disableFrameSameOrigin = args["disable-frame-sameorigin"] || !!process.env.UPTIME_KUMA_DISABLE_FRAME_SAMEORIGIN || false; const cloudflaredToken = args["cloudflared-token"] || process.env.UPTIME_KUMA_CLOUDFLARED_TOKEN || undefined; // 2FA / notp verification defaults @@ -115,20 +135,20 @@ if (config.demoMode) { console.log("Creating express and socket.io instance"); const app = express(); -let server; +let httpServer; if (sslKey && sslCert) { console.log("Server Type: HTTPS"); - server = https.createServer({ + httpServer = https.createServer({ key: fs.readFileSync(sslKey), cert: fs.readFileSync(sslCert) }, app); } else { console.log("Server Type: HTTP"); - server = http.createServer(app); + httpServer = http.createServer(app); } -const io = new Server(server); +const io = new Server(httpServer); module.exports.io = io; // Must be after io instantiation @@ -137,7 +157,8 @@ const { statusPageSocketHandler } = require("./socket-handlers/status-page-socke const databaseSocketHandler = require("./socket-handlers/database-socket-handler"); const TwoFA = require("./2fa"); const StatusPage = require("./model/status_page"); -const { cloudflaredSocketHandler, autoStart: cloudflaredAutoStart } = require("./socket-handlers/cloudflared-socket-handler"); +const { cloudflaredSocketHandler, autoStart: cloudflaredAutoStart, stop: cloudflaredStop } = require("./socket-handlers/cloudflared-socket-handler"); +const { proxySocketHandler } = require("./socket-handlers/proxy-socket-handler"); app.use(express.json()); @@ -162,12 +183,6 @@ let totalClient = 0; */ let jwtSecret = null; -/** - * Main monitor list - * @type {{}} - */ -let monitorList = {}; - /** * Show Setup Page * @type {boolean} @@ -190,13 +205,12 @@ try { } } -exports.entryPage = "dashboard"; - (async () => { Database.init(args); await initDatabase(testMode); exports.entryPage = await setting("entryPage"); + await StatusPage.loadDomainMappingList(); console.log("Adding route"); @@ -205,8 +219,13 @@ exports.entryPage = "dashboard"; // *************************** // Entry Page - app.get("/", async (_request, response) => { - if (exports.entryPage && exports.entryPage.startsWith("statusPage-")) { + app.get("/", async (request, response) => { + debug(`Request Domain: ${request.hostname}`); + + if (request.hostname in StatusPage.domainMappingList) { + debug("This is a status page domain"); + response.send(indexHTML); + } else if (exports.entryPage && exports.entryPage.startsWith("statusPage-")) { response.redirect("/status/" + exports.entryPage.replace("statusPage-", "")); } else { response.redirect("/dashboard"); @@ -600,7 +619,7 @@ exports.entryPage = "dashboard"; await updateMonitorNotification(bean.id, notificationIDList); - await sendMonitorList(socket); + await server.sendMonitorList(socket); await startMonitor(socket.userID, bean.id); callback({ @@ -629,7 +648,7 @@ exports.entryPage = "dashboard"; } // Reset Prometheus labels - monitorList[monitor.id]?.prometheus()?.remove(); + server.monitorList[monitor.id]?.prometheus()?.remove(); bean.name = monitor.name; bean.type = monitor.type; @@ -646,6 +665,7 @@ exports.entryPage = "dashboard"; bean.port = monitor.port; bean.keyword = monitor.keyword; bean.ignoreTls = monitor.ignoreTls; + bean.expiryNotification = monitor.expiryNotification; bean.upsideDown = monitor.upsideDown; bean.maxredirects = monitor.maxredirects; bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes); @@ -665,7 +685,7 @@ exports.entryPage = "dashboard"; await restartMonitor(socket.userID, bean.id); } - await sendMonitorList(socket); + await server.sendMonitorList(socket); callback({ ok: true, @@ -685,7 +705,7 @@ exports.entryPage = "dashboard"; socket.on("getMonitorList", async (callback) => { try { checkLogin(socket); - await sendMonitorList(socket); + await server.sendMonitorList(socket); callback({ ok: true, }); @@ -759,7 +779,7 @@ exports.entryPage = "dashboard"; try { checkLogin(socket); await startMonitor(socket.userID, monitorID); - await sendMonitorList(socket); + await server.sendMonitorList(socket); callback({ ok: true, @@ -778,7 +798,7 @@ exports.entryPage = "dashboard"; try { checkLogin(socket); await pauseMonitor(socket.userID, monitorID); - await sendMonitorList(socket); + await server.sendMonitorList(socket); callback({ ok: true, @@ -799,9 +819,9 @@ exports.entryPage = "dashboard"; console.log(`Delete Monitor: ${monitorID} User ID: ${socket.userID}`); - if (monitorID in monitorList) { - monitorList[monitorID].stop(); - delete monitorList[monitorID]; + if (monitorID in server.monitorList) { + server.monitorList[monitorID].stop(); + delete server.monitorList[monitorID]; } await R.exec("DELETE FROM monitor WHERE id = ? AND user_id = ? ", [ @@ -814,7 +834,7 @@ exports.entryPage = "dashboard"; msg: "Deleted Successfully.", }); - await sendMonitorList(socket); + await server.sendMonitorList(socket); // Clear heartbeat list on client await sendImportantHeartbeatList(socket, monitorID, true, true); @@ -1114,52 +1134,6 @@ exports.entryPage = "dashboard"; } }); - socket.on("addProxy", async (proxy, proxyID, callback) => { - try { - checkLogin(socket); - - const proxyBean = await Proxy.save(proxy, proxyID, socket.userID); - await sendProxyList(socket); - - if (proxy.applyExisting) { - await restartMonitors(socket.userID); - } - - callback({ - ok: true, - msg: "Saved", - id: proxyBean.id, - }); - - } catch (e) { - callback({ - ok: false, - msg: e.message, - }); - } - }); - - socket.on("deleteProxy", async (proxyID, callback) => { - try { - checkLogin(socket); - - await Proxy.delete(proxyID, socket.userID); - await sendProxyList(socket); - await restartMonitors(socket.userID); - - callback({ - ok: true, - msg: "Deleted", - }); - - } catch (e) { - callback({ - ok: false, - msg: e.message, - }); - } - }); - socket.on("checkApprise", async (callback) => { try { checkLogin(socket); @@ -1186,8 +1160,8 @@ exports.entryPage = "dashboard"; // If the import option is "overwrite" it'll clear most of the tables, except "settings" and "user" if (importHandle == "overwrite") { // Stops every monitor first, so it doesn't execute any heartbeat while importing - for (let id in monitorList) { - let monitor = monitorList[id]; + for (let id in server.monitorList) { + let monitor = server.monitorList[id]; await monitor.stop(); } await R.exec("DELETE FROM heartbeat"); @@ -1350,7 +1324,7 @@ exports.entryPage = "dashboard"; } await sendNotificationList(socket); - await sendMonitorList(socket); + await server.sendMonitorList(socket); } callback({ @@ -1440,6 +1414,7 @@ exports.entryPage = "dashboard"; statusPageSocketHandler(socket); cloudflaredSocketHandler(socket); databaseSocketHandler(socket); + proxySocketHandler(socket); debug("added all socket handlers"); @@ -1460,12 +1435,12 @@ exports.entryPage = "dashboard"; console.log("Init the server"); - server.once("error", async (err) => { + httpServer.once("error", async (err) => { console.error("Cannot listen: " + err.message); - await Database.close(); + await shutdownFunction(); }); - server.listen(port, hostname, () => { + httpServer.listen(port, hostname, () => { if (hostname) { console.log(`Listening on ${hostname}:${port}`); } else { @@ -1486,6 +1461,13 @@ exports.entryPage = "dashboard"; })(); +/** + * Adds or removes notifications from a monitor. + * @param {number} monitorID The ID of the monitor to add/remove notifications from. + * @param {Array.} notificationIDList An array of IDs for the notifications to add/remove. + * + * Generated by Trelent + */ async function updateMonitorNotification(monitorID, notificationIDList) { await R.exec("DELETE FROM monitor_notification WHERE monitor_id = ? ", [ monitorID, @@ -1501,6 +1483,13 @@ async function updateMonitorNotification(monitorID, notificationIDList) { } } +/** + * This function checks if the user owns a monitor with the given ID. + * @param {number} monitorID - The ID of the monitor to check ownership for. + * @param {number} userID - The ID of the user who is trying to access this data. + * + * Generated by Trelent + */ async function checkOwner(userID, monitorID) { let row = await R.getRow("SELECT id FROM monitor WHERE id = ? AND user_id = ? ", [ monitorID, @@ -1512,17 +1501,15 @@ async function checkOwner(userID, monitorID) { } } -async function sendMonitorList(socket) { - let list = await getMonitorJSONList(socket.userID); - io.to(socket.userID).emit("monitorList", list); - return list; -} - +/** + * This function is used to send the heartbeat list of a monitor. + * @param {Socket} socket - The socket object that will be used to send the data. + */ async function afterLogin(socket, user) { socket.userID = user.id; socket.join(user.id); - let monitorList = await sendMonitorList(socket); + let monitorList = await server.sendMonitorList(socket); sendNotificationList(socket); sendProxyList(socket); @@ -1543,6 +1530,13 @@ async function afterLogin(socket, user) { } } +/** + * Get a list of monitors for the given user. + * @param {string} userID - The ID of the user to get monitors for. + * @returns {Promise} A promise that resolves to an object with monitor IDs as keys and monitor objects as values. + * + * Generated by Trelent + */ async function getMonitorJSONList(userID) { let result = {}; @@ -1557,6 +1551,11 @@ async function getMonitorJSONList(userID) { return result; } +/** + * Connect to the database and patch it if necessary. + * + * Generated by Trelent + */ async function initDatabase(testMode = false) { if (! fs.existsSync(Database.path)) { console.log("Copying Database"); @@ -1591,6 +1590,13 @@ async function initDatabase(testMode = false) { jwtSecret = jwtSecretBean.value; } +/** + * Resume a monitor. + * @param {string} userID - The ID of the user who owns the monitor. + * @param {string} monitorID - The ID of the monitor to resume. + * + * Generated by Trelent + */ async function startMonitor(userID, monitorID) { await checkOwner(userID, monitorID); @@ -1605,11 +1611,11 @@ async function startMonitor(userID, monitorID) { monitorID, ]); - if (monitor.id in monitorList) { - monitorList[monitor.id].stop(); + if (monitor.id in server.monitorList) { + server.monitorList[monitor.id].stop(); } - monitorList[monitor.id] = monitor; + server.monitorList[monitor.id] = monitor; monitor.start(io); } @@ -1617,19 +1623,13 @@ async function restartMonitor(userID, monitorID) { return await startMonitor(userID, monitorID); } -async function restartMonitors(userID) { - // Fetch all active monitors for user - const monitors = await R.getAll("SELECT id FROM monitor WHERE active = 1 AND user_id = ?", [userID]); - - for (const monitor of monitors) { - // Start updated monitor - await startMonitor(userID, monitor.id); - - // Give some delays, so all monitors won't make request at the same moment when just start the server. - await sleep(getRandomInt(300, 1000)); - } -} - +/** + * Pause a monitor. + * @param {string} userID - The ID of the user who owns the monitor. + * @param {string} monitorID - The ID of the monitor to pause. + * + * Generated by Trelent + */ async function pauseMonitor(userID, monitorID) { await checkOwner(userID, monitorID); @@ -1640,8 +1640,8 @@ async function pauseMonitor(userID, monitorID) { userID, ]); - if (monitorID in monitorList) { - monitorList[monitorID].stop(); + if (monitorID in server.monitorList) { + server.monitorList[monitorID].stop(); } } @@ -1652,7 +1652,7 @@ async function startMonitors() { let list = await R.find("monitor", " active = 1 "); for (let monitor of list) { - monitorList[monitor.id] = monitor; + server.monitorList[monitor.id] = monitor; } for (let monitor of list) { @@ -1662,24 +1662,33 @@ async function startMonitors() { } } +/** + * Stops all monitors and closes the database connection. + * @param {string} signal The signal that triggered this function to be called. + * + * Generated by Trelent + */ async function shutdownFunction(signal) { console.log("Shutdown requested"); console.log("Called signal: " + signal); console.log("Stopping all monitors"); - for (let id in monitorList) { - let monitor = monitorList[id]; + for (let id in server.monitorList) { + let monitor = server.monitorList[id]; monitor.stop(); } await sleep(2000); await Database.close(); + + stopBackgroundJobs(); + await cloudflaredStop(); } function finalFunction() { console.log("Graceful shutdown successful!"); } -gracefulShutdown(server, { +gracefulShutdown(httpServer, { signals: "SIGINT SIGTERM", timeout: 30000, // timeout: 30 secs development: false, // not in dev mode diff --git a/server/socket-handlers/cloudflared-socket-handler.js b/server/socket-handlers/cloudflared-socket-handler.js index 3d65cda5b..37c12256d 100644 --- a/server/socket-handlers/cloudflared-socket-handler.js +++ b/server/socket-handlers/cloudflared-socket-handler.js @@ -83,3 +83,8 @@ module.exports.autoStart = async (token) => { cloudflared.start(); } }; + +module.exports.stop = async () => { + console.log("Stop cloudflared"); + cloudflared.stop(); +}; diff --git a/server/socket-handlers/proxy-socket-handler.js b/server/socket-handlers/proxy-socket-handler.js new file mode 100644 index 000000000..817bdd49e --- /dev/null +++ b/server/socket-handlers/proxy-socket-handler.js @@ -0,0 +1,53 @@ +const { checkLogin } = require("../util-server"); +const { Proxy } = require("../proxy"); +const { sendProxyList } = require("../client"); +const server = require("../server"); + +module.exports.proxySocketHandler = (socket) => { + socket.on("addProxy", async (proxy, proxyID, callback) => { + try { + checkLogin(socket); + + const proxyBean = await Proxy.save(proxy, proxyID, socket.userID); + await sendProxyList(socket); + + if (proxy.applyExisting) { + await Proxy.reloadProxy(); + await server.sendMonitorList(socket); + } + + callback({ + ok: true, + msg: "Saved", + id: proxyBean.id, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("deleteProxy", async (proxyID, callback) => { + try { + checkLogin(socket); + + await Proxy.delete(proxyID, socket.userID); + await sendProxyList(socket); + await Proxy.reloadProxy(); + + callback({ + ok: true, + msg: "Deleted", + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); +}; diff --git a/server/socket-handlers/status-page-socket-handler.js b/server/socket-handlers/status-page-socket-handler.js index 55a70d711..c844136ea 100644 --- a/server/socket-handlers/status-page-socket-handler.js +++ b/server/socket-handlers/status-page-socket-handler.js @@ -85,15 +85,35 @@ module.exports.statusPageSocketHandler = (socket) => { } }); + socket.on("getStatusPage", async (slug, callback) => { + try { + checkLogin(socket); + + let statusPage = await R.findOne("status_page", " slug = ? ", [ + slug + ]); + + if (!statusPage) { + throw new Error("No slug?"); + } + + callback({ + ok: true, + config: await statusPage.toJSON(), + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); + // Save Status Page // imgDataUrl Only Accept PNG! socket.on("saveStatusPage", async (slug, config, imgDataUrl, publicGroupList, callback) => { - try { - checkSlug(config.slug); - checkLogin(socket); - apicache.clear(); // Save Config let statusPage = await R.findOne("status_page", " slug = ? ", [ @@ -104,6 +124,8 @@ module.exports.statusPageSocketHandler = (socket) => { throw new Error("No slug?"); } + checkSlug(config.slug); + const header = "data:image/png;base64,"; // Check logo format @@ -137,6 +159,9 @@ module.exports.statusPageSocketHandler = (socket) => { await R.store(statusPage); + await statusPage.updateDomainNameList(config.domainNameList); + await StatusPage.loadDomainMappingList(); + // Save Public Group List const groupIDList = []; let groupOrder = 1; @@ -193,6 +218,8 @@ module.exports.statusPageSocketHandler = (socket) => { await setSetting("entryPage", server.entryPage, "general"); } + apicache.clear(); + callback({ ok: true, publicGroupList, diff --git a/src/assets/app.scss b/src/assets/app.scss index 9e37cc99b..0b27c6a6e 100644 --- a/src/assets/app.scss +++ b/src/assets/app.scss @@ -22,6 +22,18 @@ textarea.form-control { width: 10px; } +.list-group { + border-radius: 0.75rem; + + .dark & { + .list-group-item { + background-color: $dark-bg; + color: $dark-font-color; + border-color: $dark-border-color; + } + } +} + ::-webkit-scrollbar-thumb { background: #ccc; border-radius: 20px; @@ -412,6 +424,10 @@ textarea.form-control { background-color: rgba(239, 239, 239, 0.7); border-radius: 8px; + &.no-bg { + background-color: transparent !important; + } + &:focus { outline: 0 solid #eee; background-color: rgba(245, 245, 245, 0.9); diff --git a/src/components/CertificateInfoRow.vue b/src/components/CertificateInfoRow.vue index df726eb70..3ac22f3b6 100644 --- a/src/components/CertificateInfoRow.vue +++ b/src/components/CertificateInfoRow.vue @@ -11,23 +11,23 @@ - + - + - + - + - + diff --git a/src/components/MonitorList.vue b/src/components/MonitorList.vue index 6171c0b3a..325245a67 100644 --- a/src/components/MonitorList.vue +++ b/src/components/MonitorList.vue @@ -36,7 +36,7 @@
-
+
@@ -203,9 +203,16 @@ export default { } .tags { - padding-left: 62px; + margin-top: 4px; + padding-left: 67px; display: flex; flex-wrap: wrap; gap: 0; } + +.bottom-style { + padding-left: 67px; + margin-top: 5px; +} + diff --git a/src/components/settings/ReverseProxy.vue b/src/components/settings/ReverseProxy.vue index d35d53535..97db4d597 100644 --- a/src/components/settings/ReverseProxy.vue +++ b/src/components/settings/ReverseProxy.vue @@ -20,11 +20,16 @@
- Message: + {{ $t("Message:") }}
-

(Download cloudflared from Cloudflare Website)

+ + {{ $t("cloudflareWebsite") }} + @@ -44,7 +49,7 @@ {{ $t("Remove Token") }} - Don't know how to get the token? Please read the guide:
+ {{ $t("Don't know how to get the token? Please read the guide:") }}
https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy-with-Cloudflare-Tunnel @@ -61,7 +66,7 @@ - The current connection may be lost if you are currently connecting via Cloudflare Tunnel. Are you sure want to stop it? Type your current password to confirm it. + {{ $t("The current connection may be lost if you are currently connecting via Cloudflare Tunnel. Are you sure want to stop it? Type your current password to confirm it.") }}
-

Other Software

+

{{ $t("Other Software") }}

- For example: nginx, Apache and Traefik.
- Please read https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy. + {{ $t("For example: nginx, Apache and Traefik.") }}
+ {{ $t("Please read") }} https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy.
diff --git a/src/icon.js b/src/icon.js index bbd816ea0..7201b94fb 100644 --- a/src/icon.js +++ b/src/icon.js @@ -37,6 +37,8 @@ import { faPen, faExternalLinkSquareAlt, faSpinner, + faUndo, + faPlusCircle, } from "@fortawesome/free-solid-svg-icons"; library.add( @@ -73,6 +75,8 @@ library.add( faPen, faExternalLinkSquareAlt, faSpinner, + faUndo, + faPlusCircle, ); export { FontAwesomeIcon }; diff --git a/src/languages/en.js b/src/languages/en.js index 535a99589..2221d051f 100644 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -331,21 +331,21 @@ export default { dark: "dark", Post: "Post", "Please input title and content": "Please input title and content", - "Created": "Created", + Created: "Created", "Last Updated": "Last Updated", - "Unpin": "Unpin", + Unpin: "Unpin", "Switch to Light Theme": "Switch to Light Theme", "Switch to Dark Theme": "Switch to Dark Theme", "Show Tags": "Show Tags", "Hide Tags": "Hide Tags", - "Description": "Description", + Description: "Description", "No monitors available.": "No monitors available.", "Add one": "Add one", "No Monitors": "No Monitors", "Untitled Group": "Untitled Group", - "Services": "Services", - "Discard": "Discard", - "Cancel": "Cancel", + Services: "Services", + Discard: "Discard", + Cancel: "Cancel", "Powered by": "Powered by", shrinkDatabaseDescription: "Trigger database VACUUM for SQLite. If your database is created after 1.10.0, AUTO_VACUUM is already enabled and this action is not needed.", serwersms: "SerwerSMS.pl", @@ -385,4 +385,67 @@ export default { 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", + Valid: "Valid", + Invalid: "Invalid", + AccessKeyId: "AccessKey ID", + SecretAccessKey: "AccessKey Secret", + PhoneNumbers: "PhoneNumbers", + TemplateCode: "TemplateCode", + SignName: "SignName", + "Sms template must contain parameters: ": "Sms template must contain parameters: ", + "Bark Endpoint": "Bark Endpoint", + WebHookUrl: "WebHookUrl", + SecretKey: "SecretKey", + "For safety, must use secret key": "For safety, must use secret key", + "Device Token": "Device Token", + Platform: "Platform", + iOS: "iOS", + Android: "Android", + Huawei: "Huawei", + High: "High", + Retry: "Retry", + Topic: "Topic", + "WeCom Bot Key": "WeCom Bot Key", + "Setup Proxy": "Setup Proxy", + "Proxy Protocol": "Proxy Protocol", + "Proxy Server": "Proxy Server", + "Proxy server has authentication": "Proxy server has authentication", + User: "User", + Installed: "Installed", + "Not installed": "Not installed", + Running: "Running", + "Not running": "Not running", + "Remove Token": "Remove Token", + Start: "Start", + Stop: "Stop", + "Uptime Kuma": "Uptime Kuma", + "Add New Status Page": "Add New Status Page", + Slug: "Slug", + "Accept characters:": "Accept characters:", + "startOrEndWithOnly": "Start or end with {0} only", + "No consecutive dashes": "No consecutive dashes", + Next: "Next", + "The slug is already taken. Please choose another slug.": "The slug is already taken. Please choose another slug.", + "No Proxy": "No Proxy", + "HTTP Basic Auth": "HTTP Basic Auth", + "New Status Page": "New Status Page", + "Page Not Found": "Page Not Found", + "Reverse Proxy": "Reverse Proxy", + Backup: "Backup", + About: "About", + wayToGetCloudflaredURL: "(Download cloudflared from {0})", + cloudflareWebsite: "Cloudflare Website", + "Message:": "Message:", + "Don't know how to get the token? Please read the guide:": "Don't know how to get the token? Please read the guide:", + "The current connection may be lost if you are currently connecting via Cloudflare Tunnel. Are you sure want to stop it? Type your current password to confirm it.": "The current connection may be lost if you are currently connecting via Cloudflare Tunnel. Are you sure want to stop it? Type your current password to confirm it.", + "Other Software": "Other Software", + "For example: nginx, Apache and Traefik.": "For example: nginx, Apache and Traefik.", + "Please read": "Please read", + "Subject:": "Subject:", + "Valid To:": "Valid To:", + "Days Remaining:": "Days Remaining:", + "Issuer:": "Issuer:", + "Fingerprint:": "Fingerprint:", + "No status pages": "No status pages", }; diff --git a/src/languages/zh-CN.js b/src/languages/zh-CN.js index 1f2439b05..5bed47006 100644 --- a/src/languages/zh-CN.js +++ b/src/languages/zh-CN.js @@ -88,8 +88,8 @@ export default { Dark: "黑暗", Auto: "自动", "Theme - Heartbeat Bar": "主题 - 心跳栏", - Normal: "正常显示", - Bottom: "靠下显示", + Normal: "正常", // 此处还供 Gorush 的通知优先级功能使用,不应翻译为“正常显示” + Bottom: "靠下", None: "不显示", Timezone: "时区", "Search Engine Visibility": "搜索引擎可见性", @@ -373,4 +373,80 @@ export default { "For safety, must use secret key": "出于安全考虑,必须使用加签密钥", WeCom: "企业微信群机器人", "WeCom Bot Key": "企业微信群机器人 Key", + PushByTechulus: "Push by Techulus", + gorush: "Gorush", + alerta: "Alerta", + alertaApiEndpoint: "API 接入点", + alertaEnvironment: "环境参数", + alertaApiKey: "API Key", + alertaAlertState: "报警时的严重性", + alertaRecoverState: "恢复后的严重性", + deleteStatusPageMsg: "您确认要删除此状态页吗?", + Proxies: "代理", + default: "默认", + enabled: "启用", + setAsDefault: "设为默认", + deleteProxyMsg: "您确认要在所有监控项中删除此代理吗?", + proxyDescription: "代理必须配置到至少一个监控项后才会工作。", + enableProxyDescription: "此代理必须启用才能对监控项的网络请求起作用。您可以通过修改激活状态,临时在所有监控项中禁用此代理。", + setAsDefaultProxyDescription: "此代理会对新创建的监控项默认激活,您仍可以在监控项配置中单独禁用此代理。", + "Proxy Protocol": "代理协议", + "Proxy Server": "代理服务器", + "Server Address": "服务器地址", + "Certificate Chain": "证书链", + Valid: "有效", + Invalid: "无效", + AccessKeyId: "AccessKey ID", + SecretAccessKey: "AccessKey Secret", + /* 以下为阿里云短信服务 API Dysms#SendSms 的参数 */ + PhoneNumbers: "PhoneNumbers", + TemplateCode: "TemplateCode", + SignName: "SignName", + /* 以上为阿里云短信服务 API Dysms#SendSms 的参数 */ + "Bark Endpoint": "Bark 接入点", + "Device Token": "Apple Device Token", + Platform: "平台", + iOS: "iOS", + Android: "Android", + Huawei: "华为", + High: "高", + Retry: "重试次数", + Topic: "Gorush Topic", + "Setup Proxy": "设置代理", + "Proxy server has authentication": "代理服务器启用了身份验证功能", + User: "用户名", + Installed: "已安装", + "Not installed": "未安装", + Running: "运行中", + "Not running": "未运行", + "Message:": "信息:", + wayToGetCloudflaredURL: "(可从 {0} 下载 cloudflared)", + cloudflareWebsite: "Cloudflare 网站", + "Don't know how to get the token? Please read the guide:": "不知道如何获取 Token?请阅读指南:", + "The current connection may be lost if you are currently connecting via Cloudflare Tunnel. Are you sure want to stop it? Type your current password to confirm it.": "如果您正在通过 Cloudflare Tunnel 访问网站,则停止可能会导致当前连接断开。您确定要停止吗?请输入密码以确认。", + "Other Software": "其他软件", + "For example: nginx, Apache and Traefik.": "例如:nginx、Apache 和 Traefik。", + "Please read": "请阅读", + "Remove Token": "移除 Token", + Start: "启动", + Stop: "停止", + "Uptime Kuma": "Uptime Kuma", + "Add New Status Page": "添加新的状态页", + Slug: "路径", + "Accept characters:": "可接受的字符:", + "startOrEndWithOnly": "开头和结尾必须为 {0}", + "No consecutive dashes": "不能有连续的破折号", + Next: "下一步", + "The slug is already taken. Please choose another slug.": "该路径已被使用。请选择其他路径。", + "No Proxy": "无代理", + "HTTP Basic Auth": "HTTP 基础身份验证", + "New Status Page": "新的状态页", + "Page Not Found": "状态页未找到", + "Reverse Proxy": "反向代理", + "Subject:": "颁发给:", + "Valid To:": "有效期至:", + "Days Remaining:": "剩余有效天数:", + "Issuer:": "颁发者:", + "Fingerprint:": "指纹:", + "No status pages": "无状态页", }; diff --git a/src/languages/zh-HK.js b/src/languages/zh-HK.js index 4def65d37..0c282f372 100644 --- a/src/languages/zh-HK.js +++ b/src/languages/zh-HK.js @@ -200,4 +200,182 @@ export default { line: "Line Messenger", mattermost: "Mattermost", deleteStatusPageMsg: "是否確定刪除這個 Status Page?", + "Push URL": "推送網址", + needPushEvery: "您應每 {0} 秒呼叫此網址。", + pushOptionalParams: "選填參數:{0}", + defaultNotificationName: "我的 {notification} 通知 ({number})", + here: "此處", + Required: "必填", + "Bot Token": "機器人權杖", + wayToGetTelegramToken: "您可以從 {0} 取得 Token。", + "Chat ID": "聊天 ID", + supportTelegramChatID: "支援 對話/群組/頻道的聊天 ID", + wayToGetTelegramChatID: "傳送訊息給機器人,並前往以下網址以取得您的 chat ID:", + "YOUR BOT TOKEN HERE": "在此填入您的機器人權杖", + chatIDNotFound: "找不到 Chat ID;請先傳送訊息給機器人", + "Post URL": "Post 網址", + "Content Type": "Content Type", + webhookJsonDesc: "{0} 適合任何現代的 HTTP 伺服器,如 Express.js", + webhookFormDataDesc: "{multipart} 適合 PHP。 JSON 必須先經由 {decodeFunction} 剖析。", + secureOptionNone: "無 / STARTTLS (25, 587)", + secureOptionTLS: "TLS (465)", + "Ignore TLS Error": "忽略 TLS 錯誤", + "From Email": "寄件人", + emailCustomSubject: "自訂主旨", + "To Email": "收件人", + smtpCC: "CC", + smtpBCC: "BCC", + "Discord Webhook URL": "Discord Webhook 網址", + wayToGetDiscordURL: "您可以前往伺服器設定 -> 整合 -> Webhook -> 新 Webhook 以取得", + "Bot Display Name": "機器人顯示名稱", + "Prefix Custom Message": "前綴自訂訊息", + "Webhook URL": "Webhook 網址", + wayToGetTeamsURL: "您可以前往此頁面以了解如何建立 Webhook 網址 {0}。", + Number: "號碼", + Recipients: "收件人", + needSignalAPI: "您需要有 REST API 的 Signal 客戶端。", + wayToCheckSignalURL: "您可以前往下列網址以了解如何設定:", + signalImportant: "注意: 不得混合收件人的群組和號碼!", + "Application Token": "應用程式權杖", + "Server URL": "伺服器網址", + Priority: "優先度", + "Icon Emoji": "Emoji 圖示", + "Channel Name": "頻道名稱", + "Uptime Kuma URL": "Uptime Kuma 網址", + aboutWebhooks: "更多關於 Webhook 的資訊: {0}", + aboutChannelName: "如果您不想使用 Webhook 頻道,請在 {0} 頻道名稱欄位填入您想使用的頻道。例如: #其他頻道", + aboutKumaURL: "如果您未填入 Uptime Kuma 網址。將預設使用專案 Github 頁面。", + emojiCheatSheet: "Emoji 一覽表: {0}", + PushByTechulus: "Push by Techulus", + clicksendsms: "ClickSend SMS", + GoogleChat: "Google Chat (僅限 Google Workspace)", + "User Key": "使用者金鑰", + Device: "裝置", + "Message Title": "訊息標題", + "Notification Sound": "通知音效", + "More info on:": "更多資訊: {0}", + pushoverDesc1: "緊急優先度 (2) 的重試間隔為 30 秒並且會在 1 小時後過期。", + pushoverDesc2: "如果您想要傳送通知到不同裝置,請填寫裝置欄位。", + "SMS Type": "簡訊類型", + octopushTypePremium: "Premium (快速 - 建議用於警報)", + octopushTypeLowCost: "Low Cost (緩慢 - 有時會被營運商阻擋)", + checkPrice: "查看 {0} 價格:", + apiCredentials: "API 認證", + octopushLegacyHint: "您使用的是舊版的 Octopush (2011-2020) 還是新版?", + "Check octopush prices": "查看 octopush 價格 {0}。", + octopushPhoneNumber: "電話號碼 (intl 格式,例如:+33612345678) ", + octopushSMSSender: "簡訊寄件人名稱:3-11位英數字元及空白 (a-zA-Z0-9)", + "LunaSea Device ID": "LunaSea 裝置 ID", + "Apprise URL": "Apprise 網址", + "Example:": "範例:{0}", + "Read more:": "深入瞭解:{0}", + "Status:": "狀態:{0}", + "Read more": "深入瞭解", + appriseInstalled: "已安裝 Apprise。", + appriseNotInstalled: "尚未安裝 Apprise。{0}", + "Access Token": "存取權杖", + "Channel access token": "頻道存取權杖", + "Line Developers Console": "Line 開發者控制台", + lineDevConsoleTo: "Line 開發者控制台 - {0}", + "Basic Settings": "基本設定", + "User ID": "使用者 ID", + "Messaging API": "Messaging API", + wayToGetLineChannelToken: "首先,前往 {0},建立 provider 和 channel (Messaging API)。接著您就可以從上面提到的選單項目中取得頻道存取權杖及使用者 ID。", + "Icon URL": "圖示網址", + aboutIconURL: "您可以在 \"圖示網址\" 中提供圖片網址以覆蓋預設個人檔案圖片。若已設定 Emoji 圖示,將忽略此設定。", + aboutMattermostChannelName: "您可以在 \"頻道名稱\" 欄位中填寫頻道名稱以覆蓋 Webhook 的預設頻道。必須在 Mattermost 的 Webhook 設定中啟用。例如:#其他頻道", + matrix: "Matrix", + promosmsTypeEco: "SMS ECO - 便宜,但是很慢且經常過載。僅限位於波蘭的收件人。", + promosmsTypeFlash: "SMS FLASH - 訊息會自動在收件人的裝置上顯示。僅限位於波蘭的收件人。", + promosmsTypeFull: "SMS FULL - 高級版,您可以使用您的寄件人名稱 (必須先註冊名稱。對於警報來說十分可靠。", + promosmsTypeSpeed: "SMS SPEED - 系統中的最高優先度。快速、可靠,但昂貴 (約 SMS FULL 的兩倍價格)。", + promosmsPhoneNumber: "電話號碼 (若收件人位於波蘭則無需輸入區域代碼)", + promosmsSMSSender: "簡訊寄件人名稱:預先註冊的名稱或以下的預設名稱:InfoSMS、SMS Info、MaxSMS、INFO、SMS", + "Feishu WebHookUrl": "飛書 WebHook 網址", + matrixHomeserverURL: "Homeserver 網址 (開頭為 http(s)://,結尾可能帶連接埠)", + "Internal Room Id": "Internal Room ID", + matrixDesc1: "您可以在 Matrix 客戶端的房間設定中的進階選項找到 internal room ID。應該看起來像 !QMdRCpUIfLwsfjxye6:home.server。", + matrixDesc2: "使用您自己的 Matrix 使用者存取權杖將賦予存取您的帳號和您加入的房間的完整權限。建議建立新使用者,並邀請至您想要接收通知的房間中。您可以執行 {0} 以取得存取權杖", + Method: "方法", + Body: "主體", + Headers: "標頭", + PushUrl: "Push URL", + HeadersInvalidFormat: "要求標頭不是有效的 JSON:", + BodyInvalidFormat: "請求主體不是有效的 JSON:", + "Monitor History": "監測器歷史紀錄", + clearDataOlderThan: "保留 {0} 天內的監測器歷史紀錄。", + PasswordsDoNotMatch: "密碼不相符。", + records: "記錄", + "One record": "一項記錄", + "Showing {from} to {to} of {count} records": "正在顯示 {count} 項記錄中的 {from} 至 {to} 項", + steamApiKeyDescription: "若要監測 Steam 遊戲伺服器,您將需要 Steam Web-API 金鑰。您可以在此註冊您的 API 金鑰:", + "Current User": "目前使用者", + recent: "最近", + Done: "完成", + Info: "資訊", + Security: "安全性", + "Steam API Key": "Steam API 金鑰", + "Shrink Database": "壓縮資料庫", + "Pick a RR-Type...": "選擇資源記錄類型...", + "Pick Accepted Status Codes...": "選擇可接受的狀態碼...", + Default: "預設", + "HTTP Options": "HTTP 選項", + "Create Incident": "建立事件", + Title: "標題", + Content: "內容", + Style: "樣式", + info: "資訊", + warning: "警告", + danger: "危險", + primary: "主要", + light: "淺色", + dark: "暗色", + Post: "發佈", + "Please input title and content": "請輸入標題及內容", + Created: "建立", + "Last Updated": "最後更新", + Unpin: "取消釘選", + "Switch to Light Theme": "切換至淺色佈景主題", + "Switch to Dark Theme": "切換至深色佈景主題", + "Show Tags": "顯示標籤", + "Hide Tags": "隱藏標籤", + Description: "描述", + "No monitors available.": "沒有可用的監測器。", + "Add one": "新增一個", + "No Monitors": "無監測器", + "Untitled Group": "未命名群組", + Services: "服務", + Discard: "捨棄", + Cancel: "取消", + shrinkDatabaseDescription: "觸發 SQLite 的資料庫清理 (VACUUM)。如果您的資料庫是在 1.10.0 版本後建立,AUTO_VACUUM 已自動啟用,則無需此操作。", + serwersms: "SerwerSMS.pl", + serwersmsAPIUser: "API 使用者名稱 (包括 webapi_ 前綴)", + serwersmsAPIPassword: "API 密碼", + serwersmsPhoneNumber: "電話號碼", + serwersmsSenderName: "SMS 寄件人名稱 (由客戶入口網站註冊)", + stackfield: "Stackfield", + smtpDkimSettings: "DKIM 設定", + smtpDkimDesc: "請參考 Nodemailer DKIM {0} 使用方式。", + documentation: "文件", + smtpDkimDomain: "網域名稱", + smtpDkimKeySelector: "DKIM 選取器", + smtpDkimPrivateKey: "私密金鑰", + smtpDkimHashAlgo: "雜湊演算法 (選填)", + smtpDkimheaderFieldNames: "要簽署的郵件標頭 (選填)", + smtpDkimskipFields: "不簽署的郵件標頭 (選填)", + gorush: "Gorush", + alerta: "Alerta", + alertaApiEndpoint: "API Endpoint", + alertaEnvironment: "環境", + alertaApiKey: "API 金鑰", + alertaAlertState: "警示狀態", + alertaRecoverState: "恢復狀態", + Proxies: "代理伺服器", + default: "預設", + enabled: "啟用", + setAsDefault: "設為預設", + deleteProxyMsg: "您確定要為所有監測器刪除此代理伺服器嗎?", + proxyDescription: "必須將代理伺服器指派給監測器才能運作。", + enableProxyDescription: "此代理伺服器在啟用前不會在監測器上生效,您可以藉由控制啟用狀態來暫時對所有的監測器停用代理伺服器。", + setAsDefaultProxyDescription: "預設情況下,新監測器將啟用此代理伺服器。您仍可分別停用各監測器的代理伺服器。", }; diff --git a/src/languages/zh-TW.js b/src/languages/zh-TW.js index ec730b0f2..0b5b5adda 100644 --- a/src/languages/zh-TW.js +++ b/src/languages/zh-TW.js @@ -239,11 +239,13 @@ export default { "rocket.chat": "Rocket.Chat", pushover: "Pushover", pushy: "Pushy", + PushByTechulus: "Push by Techulus", octopush: "Octopush", promosms: "PromoSMS", clicksendsms: "ClickSend SMS", lunasea: "LunaSea", apprise: "Apprise (支援 50 種以上的通知服務)", + GoogleChat: "Google Chat (僅限 Google Workspace)", pushbullet: "Pushbullet", line: "Line Messenger", mattermost: "Mattermost", @@ -352,5 +354,30 @@ export default { serwersmsAPIPassword: "API 密碼", serwersmsPhoneNumber: "電話號碼", serwersmsSenderName: "SMS 寄件人名稱 (由客戶入口網站註冊)", - "stackfield": "Stackfield", + stackfield: "Stackfield", + smtpDkimSettings: "DKIM 設定", + smtpDkimDesc: "請參考 Nodemailer DKIM {0} 使用方式。", + documentation: "文件", + smtpDkimDomain: "網域名稱", + smtpDkimKeySelector: "DKIM 選取器", + smtpDkimPrivateKey: "私密金鑰", + smtpDkimHashAlgo: "雜湊演算法 (選填)", + smtpDkimheaderFieldNames: "要簽署的郵件標頭 (選填)", + smtpDkimskipFields: "不簽署的郵件標頭 (選填)", + gorush: "Gorush", + alerta: "Alerta", + alertaApiEndpoint: "API Endpoint", + alertaEnvironment: "環境", + alertaApiKey: "API 金鑰", + alertaAlertState: "警示狀態", + alertaRecoverState: "恢復狀態", + deleteStatusPageMsg: "您確定要刪除此狀態頁嗎?", + Proxies: "代理伺服器", + default: "預設", + enabled: "啟用", + setAsDefault: "設為預設", + deleteProxyMsg: "您確定要為所有監測器刪除此代理伺服器嗎?", + proxyDescription: "必須將代理伺服器指派給監測器才能運作。", + enableProxyDescription: "此代理伺服器在啟用前不會在監測器上生效,您可以藉由控制啟用狀態來暫時對所有的監測器停用代理伺服器。", + setAsDefaultProxyDescription: "預設情況下,新監測器將啟用此代理伺服器。您仍可分別停用各監測器的代理伺服器。", }; diff --git a/src/mixins/theme.js b/src/mixins/theme.js index fc593eb0d..21ebd0916 100644 --- a/src/mixins/theme.js +++ b/src/mixins/theme.js @@ -6,6 +6,7 @@ export default { userTheme: localStorage.theme, userHeartbeatBar: localStorage.heartbeatBarTheme, statusPageTheme: "light", + forceStatusPageTheme: false, path: "", }; }, @@ -27,6 +28,10 @@ export default { computed: { theme() { + // As entry can be status page now, set forceStatusPageTheme to true to use status page theme + if (this.forceStatusPageTheme) { + return this.statusPageTheme; + } // Entry no need dark if (this.path === "") { diff --git a/src/pages/AddStatusPage.vue b/src/pages/AddStatusPage.vue index 59c21ee95..e0200177e 100644 --- a/src/pages/AddStatusPage.vue +++ b/src/pages/AddStatusPage.vue @@ -21,7 +21,9 @@
  • {{ $t("Accept characters:") }} a-z 0-9 -
  • -
  • {{ $t("Start or end with") }} a-z 0-9 only
  • + + a-z 0-9 +
  • {{ $t("No consecutive dashes") }} --
diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index b1b471bb1..bd8d99ed3 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -170,6 +170,15 @@

{{ $t("Advanced") }}

+
+ + +
+
+
+
Subject:{{ $t("Subject:") }} {{ formatSubject(cert.subject) }}
Valid To:{{ $t("Valid To:") }}
Days Remaining:{{ $t("Days Remaining:") }} {{ cert.daysRemaining }}
Issuer:{{ $t("Issuer:") }} {{ formatSubject(cert.issuer) }}
Fingerprint:{{ $t("Fingerprint:") }} {{ cert.fingerprint }}