diff --git a/.eslintrc.js b/.eslintrc.js index fde74f6..fe63d4a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -77,6 +77,8 @@ module.exports = { "no-empty": ["error", { "allowEmptyCatch": true }], - "no-control-regex": "off" + "no-control-regex": "off", + "one-var": ["error", "never"], + "max-statements-per-line": ["error", { "max": 1 }] }, } diff --git a/.stylelintrc b/.stylelintrc index d981fe7..aad673d 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -4,7 +4,6 @@ "indentation": 4, "no-descending-specificity": null, "selector-list-comma-newline-after": null, - "declaration-empty-line-before": null, - "no-duplicate-selectors": null + "declaration-empty-line-before": null } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 5bbf343..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,146 +0,0 @@ -# Project Info - -First of all, thank you everyone who made pull requests for Uptime Kuma, I never thought GitHub Community can be that nice! And also because of this, I also never thought other people actually read my code and edit my code. It is not structed and commented so well, lol. Sorry about that. - -The project was created with vite.js (vue3). Then I created a sub-directory called "server" for server part. Both frontend and backend share the same package.json. - -The frontend code build into "dist" directory. The server uses "dist" as root. This is how production is working. - -# Can I create a pull request for Uptime Kuma? - -Generally, if the pull request is working fine and it do not affect any existing logic, workflow and perfomance, I will merge to the master branch once it is tested. - -If you are not sure, feel free to create an empty pull request draft first. - -## Pull Request Examples - -### ✅ High - Medium Priority - -- Add a new notification -- Add a chart -- Fix a bug - -### *️⃣ Requires one more reviewer - -I do not have such knowledge to test it - -- Add k8s supports - -### *️⃣ Low Priority - -It chnaged my current workflow and require further studies. - -- Change my release approach - -### ❌ Won't Merge - -- Duplicated pull request -- Buggy -- Existing logic is completely modified or deleted -- A function that is completely out of scope - -# Project Styles - -I personally do not like something need to learn so much and need to config so much before you can finally start the app. - -For example, recently, because I am not a python expert, I spent a 2 hours to resolve all problems in order to install and use the Apprise cli. Apprise requires so many hidden requirements, I have to figure out myself how to solve the problems by Google search for my OS. That is painful. I do not want Uptime Kuma to be like this way, so: - -- Easy to install for non-Docker users, no native build dependency is needed (at least for x86_64), no extra config, no extra effort to get it run -- Single container for Docker users, no very complex docker-composer file. Just map the volume and expose the port, then good to go -- All settings in frontend. -- Easy to use - -# Coding Styles - -- Follow .editorconfig -- Follow eslint - -## Name convention - -- Javascript/Typescript: camelCaseType -- SQLite: underscore_type -- CSS/SCSS: dash-type - -# Tools -- Node.js >= 14 -- Git -- IDE that supports .editorconfig and eslint (I am using Intellji Idea) -- A SQLite tool (I am using SQLite Expert Personal) - -# Install dependencies - -```bash -npm install --dev -``` - -# Backend Dev - -```bash -npm run start-server - -# Or - -node server/server.js -``` - -It binds to 0.0.0.0:3001 by default. - - -## Backend Details - -It is mainly a socket.io app + express.js. - -express.js is just used for serving the frontend built files (index.html, .js and .css etc.) - -# Frontend Dev - -Start frontend dev server. Hot-reload enabled in this way. It binds to 0.0.0.0:3000. - -```bash -npm run dev -``` - -PS: You can ignore those scss warnings, those warnings are from Bootstrap that I cannot fix. - -You can use Vue Devtool Chrome extension for debugging. - -After the frontend server started. It cannot connect to the websocket server even you have started the server. You need to tell the frontend that is a dev env by running this in DevTool console and refresh: - -```javascript -localStorage.dev = "dev"; -``` - -So that the frontend will try to connect websocket server in 3001. - -Alternately, you can specific NODE_ENV to "development". - - -## Build the frontend - -```bash -npm run build -``` - -## Frontend Details - -Uptime Kuma Frontend is a single page application (SPA). Most paths are handled by Vue Router. - -The router in "src/main.js" - -As you can see, most data in frontend is stored in root level, even though you changed the current router to any other pages. - -The data and socket logic in "src/mixins/socket.js" - -# Database Migration - -1. create `patch{num}.sql` in `./db/` -1. update `latestVersion` in `./server/database.js` - -# Unit Test - -Yes, no unit test for now. I know it is very important, but at the same time my spare time is very limited. I want to implement my ideas first. I will go back to this in some points. - - - - - diff --git a/db/demo_kuma.db b/db/demo_kuma.db new file mode 100644 index 0000000..2042fcf Binary files /dev/null and b/db/demo_kuma.db differ diff --git a/db/patch7.sql b/db/patch7.sql new file mode 100644 index 0000000..2e8eba1 --- /dev/null +++ b/db/patch7.sql @@ -0,0 +1,10 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +BEGIN TRANSACTION; + +ALTER TABLE monitor + ADD dns_resolve_type VARCHAR(5); + +ALTER TABLE monitor + ADD dns_resolve_server VARCHAR(255); + +COMMIT; diff --git a/db/patch8.sql b/db/patch8.sql new file mode 100644 index 0000000..d63a594 --- /dev/null +++ b/db/patch8.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 dns_last_result VARCHAR(255); + +COMMIT; diff --git a/extra/healthcheck.js b/extra/healthcheck.js index c0b33b6..ed4e3eb 100644 --- a/extra/healthcheck.js +++ b/extra/healthcheck.js @@ -1,19 +1,31 @@ -let http = require("http"); +process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + +let client; + +if (process.env.SSL_KEY && process.env.SSL_CERT) { + client = require("https"); +} else { + client = require("http"); +} + let options = { - host: "localhost", - port: "3001", - timeout: 2000, + host: process.env.HOST || "127.0.0.1", + port: parseInt(process.env.PORT) || 3001, + timeout: 120 * 1000, }; -let request = http.request(options, (res) => { - console.log(`STATUS: ${res.statusCode}`); - if (res.statusCode == 200) { + +let request = client.request(options, (res) => { + console.log(`Health Check OK [Res Code: ${res.statusCode}]`); + if (res.statusCode === 200) { process.exit(0); } else { process.exit(1); } }); + request.on("error", function (err) { - console.log("ERROR"); + console.error("Health Check ERROR"); process.exit(1); }); + request.end(); diff --git a/extra/install.batsh b/extra/install.batsh index cf14d0a..bca0b09 100644 --- a/extra/install.batsh +++ b/extra/install.batsh @@ -198,7 +198,7 @@ if (type == "local") { bash("git clone https://github.com/louislam/uptime-kuma.git ."); bash("npm run setup"); - bash("pm2 start npm --name uptime-kuma -- run start-server -- --port=$port"); + bash("pm2 start server/server.js --name uptime-kuma -- --port=$port"); } else { defaultVolume = "uptime-kuma"; @@ -212,8 +212,8 @@ if (type == "local") { bash("check=$(docker info)"); bash("if [[ \"$check\" == *\"Is the docker daemon running\"* ]]; then - echo \"Error: docker is not running\" - exit 1 + \"echo\" \"Error: docker is not running\" + \"exit\" \"1\" fi"); if ("$3" != "") { diff --git a/extra/simple-dns-server.js b/extra/simple-dns-server.js new file mode 100644 index 0000000..5e5745f --- /dev/null +++ b/extra/simple-dns-server.js @@ -0,0 +1,144 @@ +/* + * Simple DNS Server + * For testing DNS monitoring type, dev only + */ +const dns2 = require("dns2"); + +const { Packet } = dns2; + +const server = dns2.createServer({ + udp: true +}); + +server.on("request", (request, send, rinfo) => { + for (let question of request.questions) { + console.log(question.name, type(question.type), question.class); + + const response = Packet.createResponseFromRequest(request); + + if (question.name === "existing.com") { + + if (question.type === Packet.TYPE.A) { + response.answers.push({ + name: question.name, + type: question.type, + class: question.class, + ttl: 300, + address: "1.2.3.4" + }); + } if (question.type === Packet.TYPE.AAAA) { + response.answers.push({ + name: question.name, + type: question.type, + class: question.class, + ttl: 300, + address: "fe80::::1234:5678:abcd:ef00", + }); + } else if (question.type === Packet.TYPE.CNAME) { + response.answers.push({ + name: question.name, + type: question.type, + class: question.class, + ttl: 300, + domain: "cname1.existing.com", + }); + } else if (question.type === Packet.TYPE.MX) { + response.answers.push({ + name: question.name, + type: question.type, + class: question.class, + ttl: 300, + exchange: "mx1.existing.com", + priority: 5 + }); + } else if (question.type === Packet.TYPE.NS) { + response.answers.push({ + name: question.name, + type: question.type, + class: question.class, + ttl: 300, + ns: "ns1.existing.com", + }); + } else if (question.type === Packet.TYPE.SOA) { + response.answers.push({ + name: question.name, + type: question.type, + class: question.class, + ttl: 300, + primary: "existing.com", + admin: "admin@existing.com", + serial: 2021082701, + refresh: 300, + retry: 3, + expiration: 10, + minimum: 10, + }); + } else if (question.type === Packet.TYPE.SRV) { + response.answers.push({ + name: question.name, + type: question.type, + class: question.class, + ttl: 300, + priority: 5, + weight: 5, + port: 8080, + target: "srv1.existing.com", + }); + } else if (question.type === Packet.TYPE.TXT) { + response.answers.push({ + name: question.name, + type: question.type, + class: question.class, + ttl: 300, + data: "#v=spf1 include:_spf.existing.com ~all", + }); + } else if (question.type === Packet.TYPE.CAA) { + response.answers.push({ + name: question.name, + type: question.type, + class: question.class, + ttl: 300, + flags: 0, + tag: "issue", + value: "ca.existing.com", + }); + } + + } + + if (question.name === "4.3.2.1.in-addr.arpa") { + if (question.type === Packet.TYPE.PTR) { + response.answers.push({ + name: question.name, + type: question.type, + class: question.class, + ttl: 300, + domain: "ptr1.existing.com", + }); + } + } + + send(response); + } +}); + +server.on("listening", () => { + console.log("Listening"); + console.log(server.addresses()); +}); + +server.on("close", () => { + console.log("server closed"); +}); + +server.listen({ + udp: 5300 +}); + +function type(code) { + for (let name in Packet.TYPE) { + if (Packet.TYPE[name] === code) { + return name; + } + } +} diff --git a/extra/update-language-files/.gitignore b/extra/update-language-files/.gitignore new file mode 100644 index 0000000..410c913 --- /dev/null +++ b/extra/update-language-files/.gitignore @@ -0,0 +1,3 @@ +package-lock.json +test.js +languages/ diff --git a/extra/update-language-files/index.js b/extra/update-language-files/index.js new file mode 100644 index 0000000..ee7c0b5 --- /dev/null +++ b/extra/update-language-files/index.js @@ -0,0 +1,78 @@ +// Need to use es6 to read language files + +import fs from "fs"; +import path from "path"; +import util from "util"; + +// https://stackoverflow.com/questions/13786160/copy-folder-recursively-in-node-js +/** + * Look ma, it's cp -R. + * @param {string} src The path to the thing to copy. + * @param {string} dest The path to the new copy. + */ +const copyRecursiveSync = function (src, dest) { + let exists = fs.existsSync(src); + let stats = exists && fs.statSync(src); + let isDirectory = exists && stats.isDirectory(); + if (isDirectory) { + fs.mkdirSync(dest); + fs.readdirSync(src).forEach(function (childItemName) { + copyRecursiveSync(path.join(src, childItemName), + path.join(dest, childItemName)); + }); + } else { + fs.copyFileSync(src, dest); + } +}; +console.log(process.argv) +const baseLangCode = process.argv[2] || "zh-HK"; +console.log("Base Lang: " + baseLangCode); +fs.rmdirSync("./languages", { recursive: true }); +copyRecursiveSync("../../src/languages", "./languages"); + +const en = (await import("./languages/en.js")).default; +const baseLang = (await import(`./languages/${baseLangCode}.js`)).default; +const files = fs.readdirSync("./languages"); +console.log(files); +for (const file of files) { + if (file.endsWith(".js")) { + console.log("Processing " + file); + const lang = await import("./languages/" + file); + + let obj; + + if (lang.default) { + console.log("is js module"); + obj = lang.default; + } else { + console.log("empty file"); + obj = { + languageName: "" + }; + } + + // En first + for (const key in en) { + if (! obj[key]) { + obj[key] = en[key]; + } + } + + // Base second + for (const key in baseLang) { + if (! obj[key]) { + obj[key] = key; + } + } + + const code = "export default " + util.inspect(obj, { + depth: null, + }); + + fs.writeFileSync(`../../src/languages/${file}`, code); + + } +} + +fs.rmdirSync("./languages", { recursive: true }); +console.log("Done, fix the format by eslint now"); diff --git a/extra/update-language-files/package.json b/extra/update-language-files/package.json new file mode 100644 index 0000000..c729517 --- /dev/null +++ b/extra/update-language-files/package.json @@ -0,0 +1,12 @@ +{ + "name": "update-language-files", + "type": "module", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC" +} diff --git a/extra/update-version.js b/extra/update-version.js index 697a640..ca810a4 100644 --- a/extra/update-version.js +++ b/extra/update-version.js @@ -23,6 +23,8 @@ if (! exists) { pkg.version = newVersion; pkg.scripts.setup = pkg.scripts.setup.replaceAll(oldVersion, newVersion); pkg.scripts["build-docker"] = pkg.scripts["build-docker"].replaceAll(oldVersion, newVersion); + pkg.scripts["build-docker-alpine"] = pkg.scripts["build-docker-alpine"].replaceAll(oldVersion, newVersion); + pkg.scripts["build-docker-debian"] = pkg.scripts["build-docker-debian"].replaceAll(oldVersion, newVersion); fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n"); commit(newVersion); diff --git a/install.sh b/install.sh index a840bb2..37d6753 100644 --- a/install.sh +++ b/install.sh @@ -166,7 +166,7 @@ fi cd $installPath git clone https://github.com/louislam/uptime-kuma.git . npm run setup - pm2 start npm --name uptime-kuma -- run start-server -- --port=$port + pm2 start server/server.js --name uptime-kuma -- --port=$port else defaultVolume="uptime-kuma" check=$(docker -v) @@ -176,8 +176,8 @@ else fi check=$(docker info) if [[ "$check" == *"Is the docker daemon running"* ]]; then - echo "Error: docker is not running" - exit 1 + "echo" "Error: docker is not running" + "exit" "1" fi if [ "$3" != "" ]; then port="$3" diff --git a/package.json b/package.json index 1aa680b..d9e1d4c 100644 --- a/package.json +++ b/package.json @@ -10,51 +10,60 @@ "vite-preview-dist": "vite preview --host" }, "dependencies": { - "@fortawesome/fontawesome-svg-core": "1.2.35", - "@fortawesome/free-regular-svg-icons": "5.15.3", - "@fortawesome/free-solid-svg-icons": "5.15.3", - "@fortawesome/vue-fontawesome": "3.0.0-4", - "@popperjs/core": "2.9.2", - "args-parser": "1.3.0", - "axios": "0.21.1", - "bcrypt": "5.0.1", - "bootstrap": "5.0.2", + "@fortawesome/fontawesome-svg-core": "^1.2.36", + "@fortawesome/free-regular-svg-icons": "^5.15.4", + "@fortawesome/free-solid-svg-icons": "^5.15.4", + "@fortawesome/vue-fontawesome": "^3.0.0-4", + "@popperjs/core": "^2.9.3", + "args-parser": "^1.3.0", + "axios": "^0.21.1", + "bcryptjs": "^2.4.3", + "bootstrap": "^5.1.0", "chart.js": "^3.5.1", "chartjs-adapter-dayjs": "^1.0.0", - "command-exists": "1.2.9", + "command-exists": "^1.2.9", "compare-versions": "^3.6.0", - "dayjs": "1.10.6", - "express": "4.17.1", - "express-basic-auth": "1.2.0", - "form-data": "4.0.0", - "http-graceful-shutdown": "3.1.3", - "jsonwebtoken": "8.5.1", - "nodemailer": "6.6.3", - "password-hash": "1.2.2", - "prom-client": "13.1.0", - "prometheus-api-metrics": "3.2.0", - "redbean-node": "0.0.20", - "socket.io": "4.1.3", - "socket.io-client": "4.1.3", - "sqlite3": "5.0.2", - "tcp-ping": "0.1.1", - "v-pagination-3": "0.1.6", - "vue": "3.0.5", + "dayjs": "^1.10.6", + "express": "^4.17.1", + "express-basic-auth": "^1.2.0", + "form-data": "^4.0.0", + "http-graceful-shutdown": "^3.1.4", + "jsonwebtoken": "^8.5.1", + "nodemailer": "^6.6.3", + "password-hash": "^1.2.2", + "prom-client": "^13.2.0", + "prometheus-api-metrics": "^3.2.0", + "redbean-node": "0.1.2", + "socket.io": "^4.2.0", + "socket.io-client": "^4.2.0", + "sqlite3": "github:mapbox/node-sqlite3#593c9d", + "tcp-ping": "^0.1.1", + "v-pagination-3": "^0.1.6", + "vue": "^3.2.8", "vue-chart-3": "^0.5.7", - "vue-confirm-dialog": "1.0.2", - "vue-i18n": "^8.25.0", - "vue-router": "4.0.10", - "vue-toastification": "2.0.0-rc.1" + "vue-confirm-dialog": "^1.0.2", + "vue-i18n": "^9.1.7", + "vue-multiselect": "^3.0.0-alpha.2", + "vue-router": "^4.0.11", + "vue-toastification": "^2.0.0-rc.1" }, "devDependencies": { - "@vitejs/plugin-legacy": "1.5.1", - "@vitejs/plugin-vue": "1.3.0", - "@vue/compiler-sfc": "3.1.5", "auto-changelog": "2.3.0", - "core-js": "3.16.0", "release-it": "14.10.1", - "sass": "1.37.2", - "vite": "2.4.4" + "@babel/eslint-parser": "^7.15.0", + "@types/bootstrap": "^5.1.2", + "@vitejs/plugin-legacy": "^1.5.2", + "@vitejs/plugin-vue": "^1.6.0", + "@vue/compiler-sfc": "^3.2.6", + "core-js": "^3.17.0", + "dns2": "^2.0.1", + "eslint": "^7.32.0", + "eslint-plugin-vue": "^7.17.0", + "sass": "^1.38.2", + "stylelint": "^13.13.1", + "stylelint-config-standard": "^22.0.0", + "typescript": "^4.4.2", + "vite": "^2.5.3" }, "release-it": { "git": { @@ -76,4 +85,4 @@ "volta": { "node": "16.6.0" } -} +} \ No newline at end of file diff --git a/server/client.js b/server/client.js new file mode 100644 index 0000000..4f28a2f --- /dev/null +++ b/server/client.js @@ -0,0 +1,91 @@ +/* + * For Client Socket + */ +const { TimeLogger } = require("../src/util"); +const { R } = require("redbean-node"); +const { io } = require("./server"); + +async function sendNotificationList(socket) { + const timeLogger = new TimeLogger(); + + let result = []; + let list = await R.find("notification", " user_id = ? ", [ + socket.userID, + ]); + + for (let bean of list) { + result.push(bean.export()) + } + + io.to(socket.userID).emit("notificationList", result) + + timeLogger.print("Send Notification List"); + + return list; +} + +/** + * Send Heartbeat History list to socket + * @param toUser True = send to all browsers with the same user id, False = send to the current browser only + * @param overwrite Overwrite client-side's heartbeat list + */ +async function sendHeartbeatList(socket, monitorID, toUser = false, overwrite = false) { + const timeLogger = new TimeLogger(); + + let list = await R.find("heartbeat", ` + monitor_id = ? + ORDER BY time DESC + LIMIT 100 + `, [ + monitorID, + ]) + + let result = []; + + for (let bean of list) { + result.unshift(bean.toJSON()); + } + + if (toUser) { + io.to(socket.userID).emit("heartbeatList", monitorID, result, overwrite); + } else { + socket.emit("heartbeatList", monitorID, result, overwrite); + } + + timeLogger.print(`[Monitor: ${monitorID}] sendHeartbeatList`); +} + +/** + * Important Heart beat list (aka event list) + * @param socket + * @param monitorID + * @param toUser True = send to all browsers with the same user id, False = send to the current browser only + * @param overwrite Overwrite client-side's heartbeat list + */ +async function sendImportantHeartbeatList(socket, monitorID, toUser = false, overwrite = false) { + const timeLogger = new TimeLogger(); + + let list = await R.find("heartbeat", ` + monitor_id = ? + AND important = 1 + ORDER BY time DESC + LIMIT 500 + `, [ + monitorID, + ]) + + timeLogger.print(`[Monitor: ${monitorID}] sendImportantHeartbeatList`); + + if (toUser) { + io.to(socket.userID).emit("importantHeartbeatList", monitorID, list, overwrite); + } else { + socket.emit("importantHeartbeatList", monitorID, list, overwrite); + } + +} + +module.exports = { + sendNotificationList, + sendImportantHeartbeatList, + sendHeartbeatList, +} diff --git a/server/database.js b/server/database.js index 9b83fe7..b5b9c76 100644 --- a/server/database.js +++ b/server/database.js @@ -5,17 +5,15 @@ const { setSetting, setting } = require("./util-server"); class Database { static templatePath = "./db/kuma.db" - static path = "./data/kuma.db"; - static latestVersion = 6; + static dataDir; + static path; + static latestVersion = 8; static noReject = true; static sqliteInstance = null; static async connect() { const acquireConnectionTimeout = 120 * 1000; - R.useBetterSQLite3 = true; - R.betterSQLite3Options.timeout = acquireConnectionTimeout; - R.setup("sqlite", { filename: Database.path, useNullAsDefault: true, @@ -59,7 +57,7 @@ class Database { console.info("Database patch is needed") console.info("Backup the db") - const backupPath = "./data/kuma.db.bak" + version; + const backupPath = this.dataDir + "kuma.db.bak" + version; fs.copyFileSync(Database.path, backupPath); const shmPath = Database.path + "-shm"; @@ -124,11 +122,8 @@ class Database { return statement !== ""; }) - // Use better-sqlite3 to run, prevent "This statement does not return data. Use run() instead" - const db = await this.getBetterSQLite3Database(); - for (let statement of statements) { - db.prepare(statement).run(); + await R.exec(statement); } } diff --git a/server/model/monitor.js b/server/model/monitor.js index 17ab277..19f21d9 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -7,7 +7,7 @@ dayjs.extend(timezone) const axios = require("axios"); const { Prometheus } = require("../prometheus"); const { debug, UP, DOWN, PENDING, flipStatus, TimeLogger } = require("../../src/util"); -const { tcping, ping, checkCertificate, checkStatusCode } = require("../util-server"); +const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom } = require("../util-server"); const { R } = require("redbean-node"); const { BeanModel } = require("redbean-node/dist/bean-model"); const { Notification } = require("../notification") @@ -48,6 +48,9 @@ class Monitor extends BeanModel { upsideDown: this.isUpsideDown(), maxredirects: this.maxredirects, accepted_statuscodes: this.getAcceptedStatuscodes(), + dns_resolve_type: this.dns_resolve_type, + dns_resolve_server: this.dns_resolve_server, + dns_last_result: this.dns_last_result, notificationIDList, }; } @@ -174,6 +177,46 @@ class Monitor extends BeanModel { bean.ping = await ping(this.hostname); bean.msg = "" bean.status = UP; + } else if (this.type === "dns") { + let startTime = dayjs().valueOf(); + let dnsMessage = ""; + + let dnsRes = await dnsResolve(this.hostname, this.dns_resolve_server, this.dns_resolve_type); + bean.ping = dayjs().valueOf() - startTime; + + if (this.dns_resolve_type == "A" || this.dns_resolve_type == "AAAA" || this.dns_resolve_type == "TXT") { + dnsMessage += "Records: "; + dnsMessage += dnsRes.join(" | "); + } else if (this.dns_resolve_type == "CNAME" || this.dns_resolve_type == "PTR") { + dnsMessage = dnsRes[0]; + } else if (this.dns_resolve_type == "CAA") { + dnsMessage = dnsRes[0].issue; + } else if (this.dns_resolve_type == "MX") { + dnsRes.forEach(record => { + dnsMessage += `Hostname: ${record.exchange} - Priority: ${record.priority} | `; + }); + dnsMessage = dnsMessage.slice(0, -2) + } else if (this.dns_resolve_type == "NS") { + dnsMessage += "Servers: "; + dnsMessage += dnsRes.join(" | "); + } else if (this.dns_resolve_type == "SOA") { + dnsMessage += `NS-Name: ${dnsRes.nsname} | Hostmaster: ${dnsRes.hostmaster} | Serial: ${dnsRes.serial} | Refresh: ${dnsRes.refresh} | Retry: ${dnsRes.retry} | Expire: ${dnsRes.expire} | MinTTL: ${dnsRes.minttl}`; + } else if (this.dns_resolve_type == "SRV") { + dnsRes.forEach(record => { + dnsMessage += `Name: ${record.name} | Port: ${record.port} | Priority: ${record.priority} | Weight: ${record.weight} | `; + }); + dnsMessage = dnsMessage.slice(0, -2) + } + + if (this.dnsLastResult !== dnsMessage) { + R.exec("UPDATE `monitor` SET dns_last_result = ? WHERE id = ? ", [ + dnsMessage, + this.id + ]); + } + + bean.msg = dnsMessage; + bean.status = UP; } if (this.isUpsideDown()) { @@ -310,10 +353,16 @@ class Monitor extends BeanModel { } static async sendStats(io, monitorID, userID) { - await Monitor.sendAvgPing(24, io, monitorID, userID); - await Monitor.sendUptime(24, io, monitorID, userID); - await Monitor.sendUptime(24 * 30, io, monitorID, userID); - await Monitor.sendCertInfo(io, monitorID, userID); + const hasClients = getTotalClientInRoom(io, userID) > 0; + + if (hasClients) { + await Monitor.sendAvgPing(24, io, monitorID, userID); + await Monitor.sendUptime(24, io, monitorID, userID); + await Monitor.sendUptime(24 * 30, io, monitorID, userID); + await Monitor.sendCertInfo(io, monitorID, userID); + } else { + debug("No clients in the room, no need to send stats"); + } } /** diff --git a/server/notification.js b/server/notification.js index ae8891b..83d6993 100644 --- a/server/notification.js +++ b/server/notification.js @@ -4,6 +4,8 @@ const FormData = require("form-data"); const nodemailer = require("nodemailer"); const child_process = require("child_process"); +const { UP, DOWN } = require("../src/util"); + class Notification { /** @@ -80,7 +82,7 @@ class Notification { } } else if (notification.type === "smtp") { - return await Notification.smtp(notification, msg) + return await Notification.smtp(notification, msg, heartbeatJSON) } else if (notification.type === "discord") { try { @@ -95,12 +97,25 @@ class Notification { await axios.post(notification.discordWebhookUrl, discordtestdata) return okMsg; } + + let url; + + if (monitorJSON["type"] === "port") { + url = monitorJSON["hostname"]; + if (monitorJSON["port"]) { + url += ":" + monitorJSON["port"]; + } + + } else { + url = monitorJSON["url"]; + } + // If heartbeatJSON is not null, we go into the normal alerting loop. - if (heartbeatJSON["status"] == 0) { + if (heartbeatJSON["status"] == DOWN) { let discorddowndata = { username: discordDisplayName, embeds: [{ - title: "❌ One of your services went down. ❌", + title: "❌ Your service " + monitorJSON["name"] + " went down. ❌", color: 16711680, timestamp: heartbeatJSON["time"], fields: [ @@ -110,7 +125,7 @@ class Notification { }, { name: "Service URL", - value: monitorJSON["url"], + value: url, }, { name: "Time (UTC)", @@ -126,7 +141,7 @@ class Notification { await axios.post(notification.discordWebhookUrl, discorddowndata) return okMsg; - } else if (heartbeatJSON["status"] == 1) { + } else if (heartbeatJSON["status"] == UP) { let discordupdata = { username: discordDisplayName, embeds: [{ @@ -140,7 +155,7 @@ class Notification { }, { name: "Service URL", - value: "[Visit Service](" + monitorJSON["url"] + ")", + value: url.startsWith("http") ? "[Visit Service](" + url + ")" : url, }, { name: "Time (UTC)", @@ -279,6 +294,150 @@ class Notification { throwGeneralAxiosError(error) } + } else if (notification.type === "rocket.chat") { + try { + if (heartbeatJSON == null) { + let data = { + "text": "Uptime Kuma Rocket.chat testing successful.", + "channel": notification.rocketchannel, + "username": notification.rocketusername, + "icon_emoji": notification.rocketiconemo, + } + await axios.post(notification.rocketwebhookURL, data) + return okMsg; + } + + const time = heartbeatJSON["time"]; + let data = { + "text": "Uptime Kuma Alert", + "channel": notification.rocketchannel, + "username": notification.rocketusername, + "icon_emoji": notification.rocketiconemo, + "attachments": [ + { + "title": "Uptime Kuma Alert *Time (UTC)*\n" + time, + "title_link": notification.rocketbutton, + "text": "*Message*\n" + msg, + "color": "#32cd32" + } + ] + } + await axios.post(notification.rocketwebhookURL, data) + return okMsg; + } catch (error) { + throwGeneralAxiosError(error) + } + + } else if (notification.type === "mattermost") { + try { + const mattermostUserName = notification.mattermostusername || "Uptime Kuma"; + // If heartbeatJSON is null, assume we're testing. + if (heartbeatJSON == null) { + let mattermostTestData = { + username: mattermostUserName, + text: msg, + } + await axios.post(notification.mattermostWebhookUrl, mattermostTestData) + return okMsg; + } + + const mattermostChannel = notification.mattermostchannel; + const mattermostIconEmoji = notification.mattermosticonemo; + const mattermostIconUrl = notification.mattermosticonurl; + + if (heartbeatJSON["status"] == DOWN) { + let mattermostdowndata = { + username: mattermostUserName, + text: "Uptime Kuma Alert", + channel: mattermostChannel, + icon_emoji: mattermostIconEmoji, + icon_url: mattermostIconUrl, + attachments: [ + { + fallback: + "Your " + + monitorJSON["name"] + + " service went down.", + color: "#FF0000", + title: + "❌ " + + monitorJSON["name"] + + " service went down. ❌", + title_link: monitorJSON["url"], + fields: [ + { + short: true, + title: "Service Name", + value: monitorJSON["name"], + }, + { + short: true, + title: "Time (UTC)", + value: heartbeatJSON["time"], + }, + { + short: false, + title: "Error", + value: heartbeatJSON["msg"], + }, + ], + }, + ], + }; + await axios.post( + notification.mattermostWebhookUrl, + mattermostdowndata + ); + return okMsg; + } else if (heartbeatJSON["status"] == UP) { + let mattermostupdata = { + username: mattermostUserName, + text: "Uptime Kuma Alert", + channel: mattermostChannel, + icon_emoji: mattermostIconEmoji, + icon_url: mattermostIconUrl, + attachments: [ + { + fallback: + "Your " + + monitorJSON["name"] + + " service went up!", + color: "#32CD32", + title: + "✅ " + + monitorJSON["name"] + + " service went up! ✅", + title_link: monitorJSON["url"], + fields: [ + { + short: true, + title: "Service Name", + value: monitorJSON["name"], + }, + { + short: true, + title: "Time (UTC)", + value: heartbeatJSON["time"], + }, + { + short: false, + title: "Ping", + value: heartbeatJSON["ping"] + "ms", + }, + ], + }, + ], + }; + await axios.post( + notification.mattermostWebhookUrl, + mattermostupdata + ); + return okMsg; + } + } catch (error) { + throwGeneralAxiosError(error); + } + } else if (notification.type === "pushover") { let pushoverlink = "https://api.pushover.net/1/messages.json" try { @@ -328,19 +487,19 @@ class Notification { return okMsg; } - if (heartbeatJSON["status"] == 0) { + if (heartbeatJSON["status"] == DOWN) { let downdata = { - "title": "UptimeKuma Alert:" + monitorJSON["name"], - "body": "[🔴 Down]" + heartbeatJSON["msg"] + "\nTime (UTC):" + heartbeatJSON["time"], + "title": "UptimeKuma Alert: " + monitorJSON["name"], + "body": "[🔴 Down] " + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"], } await axios.post(lunaseadevice, downdata) return okMsg; } - if (heartbeatJSON["status"] == 1) { + if (heartbeatJSON["status"] == UP) { let updata = { - "title": "UptimeKuma Alert:" + monitorJSON["name"], - "body": "[✅ Up]" + heartbeatJSON["msg"] + "\nTime (UTC):" + heartbeatJSON["time"], + "title": "UptimeKuma Alert: " + monitorJSON["name"], + "body": "[✅ Up] " + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"], } await axios.post(lunaseadevice, updata) return okMsg; @@ -366,18 +525,18 @@ class Notification { "body": "Testing Successful.", } await axios.post(pushbulletUrl, testdata, config) - } else if (heartbeatJSON["status"] == 0) { + } else if (heartbeatJSON["status"] == DOWN) { let downdata = { "type": "note", - "title": "UptimeKuma Alert:" + monitorJSON["name"], - "body": "[🔴 Down]" + heartbeatJSON["msg"] + "\nTime (UTC):" + heartbeatJSON["time"], + "title": "UptimeKuma Alert: " + monitorJSON["name"], + "body": "[🔴 Down] " + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"], } await axios.post(pushbulletUrl, downdata, config) - } else if (heartbeatJSON["status"] == 1) { + } else if (heartbeatJSON["status"] == UP) { let updata = { "type": "note", - "title": "UptimeKuma Alert:" + monitorJSON["name"], - "body": "[✅ Up]" + heartbeatJSON["msg"] + "\nTime (UTC):" + heartbeatJSON["time"], + "title": "UptimeKuma Alert: " + monitorJSON["name"], + "body": "[✅ Up] " + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"], } await axios.post(pushbulletUrl, updata, config) } @@ -405,7 +564,7 @@ class Notification { ] } await axios.post(lineAPIUrl, testMessage, config) - } else if (heartbeatJSON["status"] == 0) { + } else if (heartbeatJSON["status"] == DOWN) { let downMessage = { "to": notification.lineUserID, "messages": [ @@ -416,7 +575,7 @@ class Notification { ] } await axios.post(lineAPIUrl, downMessage, config) - } else if (heartbeatJSON["status"] == 1) { + } else if (heartbeatJSON["status"] == UP) { let upMessage = { "to": notification.lineUserID, "messages": [ @@ -473,7 +632,7 @@ class Notification { await R.trash(bean) } - static async smtp(notification, msg) { + static async smtp(notification, msg, heartbeatJSON = null) { const config = { host: notification.smtpHost, @@ -491,12 +650,17 @@ class Notification { let transporter = nodemailer.createTransport(config); + let bodyTextContent = msg; + if(heartbeatJSON) { + bodyTextContent = `${msg}\nTime (UTC): ${heartbeatJSON["time"]}`; + } + // send mail with defined transport object await transporter.sendMail({ from: `"Uptime Kuma" <${notification.smtpFrom}>`, to: notification.smtpTo, subject: msg, - text: msg, + text: bodyTextContent, }); return "Sent Successfully."; diff --git a/server/password-hash.js b/server/password-hash.js index 52e26b9..91e5e1a 100644 --- a/server/password-hash.js +++ b/server/password-hash.js @@ -1,5 +1,5 @@ const passwordHashOld = require("password-hash"); -const bcrypt = require("bcrypt"); +const bcrypt = require("bcryptjs"); const saltRounds = 10; exports.generate = function (password) { diff --git a/server/ping-lite.js b/server/ping-lite.js index 42a704e..0af0e97 100644 --- a/server/ping-lite.js +++ b/server/ping-lite.js @@ -1,14 +1,13 @@ // https://github.com/ben-bradley/ping-lite/blob/master/ping-lite.js // Fixed on Windows const net = require("net"); -const spawn = require("child_process").spawn, - events = require("events"), - fs = require("fs"), - WIN = /^win/.test(process.platform), - LIN = /^linux/.test(process.platform), - MAC = /^darwin/.test(process.platform); - FBSD = /^freebsd/.test(process.platform); -const { debug } = require("../src/util"); +const spawn = require("child_process").spawn; +const events = require("events"); +const fs = require("fs"); +const WIN = /^win/.test(process.platform); +const LIN = /^linux/.test(process.platform); +const MAC = /^darwin/.test(process.platform); +const FBSD = /^freebsd/.test(process.platform); module.exports = Ping; @@ -22,15 +21,17 @@ function Ping(host, options) { events.EventEmitter.call(this); + const timeout = 10; + if (WIN) { this._bin = "c:/windows/system32/ping.exe"; - this._args = (options.args) ? options.args : [ "-n", "1", "-w", "5000", host ]; + this._args = (options.args) ? options.args : [ "-n", "1", "-w", timeout * 1000, host ]; this._regmatch = /[><=]([0-9.]+?)ms/; } else if (LIN) { this._bin = "/bin/ping"; - const defaultArgs = [ "-n", "-w", "2", "-c", "1", host ]; + const defaultArgs = [ "-n", "-w", timeout, "-c", "1", host ]; if (net.isIPv6(host) || options.ipv6) { defaultArgs.unshift("-6"); @@ -47,13 +48,13 @@ function Ping(host, options) { this._bin = "/sbin/ping"; } - this._args = (options.args) ? options.args : [ "-n", "-t", "2", "-c", "1", host ]; + this._args = (options.args) ? options.args : [ "-n", "-t", timeout, "-c", "1", host ]; this._regmatch = /=([0-9.]+?) ms/; - + } else if (FBSD) { this._bin = "/sbin/ping"; - const defaultArgs = [ "-n", "-t", "2", "-c", "1", host ]; + const defaultArgs = [ "-n", "-t", timeout, "-c", "1", host ]; if (net.isIPv6(host) || options.ipv6) { defaultArgs.unshift("-6"); @@ -88,7 +89,9 @@ Ping.prototype.send = function (callback) { return self.emit("result", ms); }; - let _ended, _exited, _errored; + let _ended; + let _exited; + let _errored; this._ping = spawn(this._bin, this._args); // spawn the binary @@ -120,9 +123,9 @@ Ping.prototype.send = function (callback) { }); function onEnd() { - let stdout = this.stdout._stdout, - stderr = this.stderr._stderr, - ms; + let stdout = this.stdout._stdout; + let stderr = this.stderr._stderr; + let ms; if (stderr) { return callback(new Error(stderr)); diff --git a/server/server.js b/server/server.js index 6a55488..5c6d433 100644 --- a/server/server.js +++ b/server/server.js @@ -6,6 +6,7 @@ const { sleep, debug, TimeLogger, getRandomInt } = require("../src/util"); console.log("Importing Node libraries") const fs = require("fs"); const http = require("http"); +const https = require("https"); console.log("Importing 3rd-party libraries") debug("Importing express"); @@ -45,11 +46,48 @@ console.info("Version: " + checkVersion.version); const hostname = process.env.HOST || args.host; const port = parseInt(process.env.PORT || args.port || 3001); +// SSL +const sslKey = process.env.SSL_KEY || args["ssl-key"] || undefined; +const sslCert = process.env.SSL_CERT || args["ssl-cert"] || undefined; + +// Demo Mode? +const demoMode = args["demo"] || false; + +if (demoMode) { + console.log("==== Demo Mode ===="); +} + +// Data Directory (must be end with "/") +Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/"; +Database.path = Database.dataDir + "kuma.db"; +if (! fs.existsSync(Database.dataDir)) { + fs.mkdirSync(Database.dataDir, { recursive: true }); +} +console.log(`Data Dir: ${Database.dataDir}`); + console.log("Creating express and socket.io instance") const app = express(); -const server = http.createServer(app); + +let server; + +if (sslKey && sslCert) { + console.log("Server Type: HTTPS"); + server = https.createServer({ + key: fs.readFileSync(sslKey), + cert: fs.readFileSync(sslCert) + }, app); +} else { + console.log("Server Type: HTTP"); + server = http.createServer(app); +} + const io = new Server(server); -app.use(express.json()) +module.exports.io = io; + +// Must be after io instantiation +const { sendNotificationList, sendHeartbeatList, sendImportantHeartbeatList } = require("./client"); + +app.use(express.json()); /** * Total WebSocket client connected to server currently, no actual use @@ -291,6 +329,8 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString(); bean.upsideDown = monitor.upsideDown; bean.maxredirects = monitor.maxredirects; bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes); + bean.dns_resolve_type = monitor.dns_resolve_type; + bean.dns_resolve_server = monitor.dns_resolve_server; await R.store(bean) @@ -541,6 +581,76 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString(); } }); + socket.on("clearEvents", async (monitorID, callback) => { + try { + checkLogin(socket) + + console.log(`Clear Events Monitor: ${monitorID} User ID: ${socket.userID}`) + + await R.exec("UPDATE heartbeat SET msg = ?, important = ? WHERE monitor_id = ? ", [ + "", + "0", + monitorID, + ]); + + await sendImportantHeartbeatList(socket, monitorID, true, true); + + callback({ + ok: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("clearHeartbeats", async (monitorID, callback) => { + try { + checkLogin(socket) + + console.log(`Clear Heartbeats Monitor: ${monitorID} User ID: ${socket.userID}`) + + await R.exec("DELETE FROM heartbeat WHERE monitor_id = ?", [ + monitorID + ]); + + await sendHeartbeatList(socket, monitorID, true, true); + + callback({ + ok: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("clearStatistics", async (callback) => { + try { + checkLogin(socket) + + console.log(`Clear Statistics User ID: ${socket.userID}`) + + await R.exec("DELETE FROM heartbeat"); + + callback({ + ok: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + debug("added all socket handlers") // *************************** @@ -609,25 +719,6 @@ async function sendMonitorList(socket) { return list; } -async function sendNotificationList(socket) { - const timeLogger = new TimeLogger(); - - let result = []; - let list = await R.find("notification", " user_id = ? ", [ - socket.userID, - ]); - - for (let bean of list) { - result.push(bean.export()) - } - - io.to(socket.userID).emit("notificationList", result) - - timeLogger.print("Send Notification List"); - - return list; -} - async function afterLogin(socket, user) { socket.userID = user.id; socket.join(user.id) @@ -762,48 +853,6 @@ async function startMonitors() { } } -/** - * Send Heartbeat History list to socket - */ -async function sendHeartbeatList(socket, monitorID) { - const timeLogger = new TimeLogger(); - - let list = await R.find("heartbeat", ` - monitor_id = ? - ORDER BY time DESC - LIMIT 100 - `, [ - monitorID, - ]) - - let result = []; - - for (let bean of list) { - result.unshift(bean.toJSON()) - } - - socket.emit("heartbeatList", monitorID, result) - - timeLogger.print(`[Monitor: ${monitorID}] sendHeartbeatList`) -} - -async function sendImportantHeartbeatList(socket, monitorID) { - const timeLogger = new TimeLogger(); - - let list = await R.find("heartbeat", ` - monitor_id = ? - AND important = 1 - ORDER BY time DESC - LIMIT 500 - `, [ - monitorID, - ]) - - timeLogger.print(`[Monitor: ${monitorID}] sendImportantHeartbeatList`); - - socket.emit("importantHeartbeatList", monitorID, list) -} - async function shutdownFunction(signal) { console.log("Shutdown requested"); console.log("Called signal: " + signal); diff --git a/server/util-server.js b/server/util-server.js index 8a2f038..a2fef06 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -4,6 +4,7 @@ const { R } = require("redbean-node"); const { debug } = require("../src/util"); const passwordHash = require("./password-hash"); const dayjs = require("dayjs"); +const { Resolver } = require("dns"); /** * Init or reset JWT secret @@ -76,6 +77,30 @@ exports.pingAsync = function (hostname, ipv6 = false) { }); } +exports.dnsResolve = function (hostname, resolver_server, rrtype) { + const resolver = new Resolver(); + resolver.setServers([resolver_server]); + return new Promise((resolve, reject) => { + if (rrtype == "PTR") { + resolver.reverse(hostname, (err, records) => { + if (err) { + reject(err); + } else { + resolve(records); + } + }); + } else { + resolver.resolve(hostname, rrtype, (err, records) => { + if (err) { + reject(err); + } else { + resolve(records); + } + }); + } + }) +} + exports.setting = async function (key) { let value = await R.getCell("SELECT `value` FROM setting WHERE `key` = ? ", [ key, @@ -223,3 +248,26 @@ exports.checkStatusCode = function (status, accepted_codes) { return false; } + +exports.getTotalClientInRoom = (io, roomName) => { + + const sockets = io.sockets; + + if (! sockets) { + return 0; + } + + const adapter = sockets.adapter; + + if (! adapter) { + return 0; + } + + const room = adapter.rooms.get(roomName); + + if (room) { + return room.size; + } else { + return 0; + } +} diff --git a/src/assets/app.scss b/src/assets/app.scss index 289007a..1f75e97 100644 --- a/src/assets/app.scss +++ b/src/assets/app.scss @@ -1,46 +1,52 @@ @import "vars.scss"; @import "node_modules/bootstrap/scss/bootstrap"; -html, -body, -input, -.modal-content { - background: var(--page-background); - color: var(--main-font-color); +#app { + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, segoe ui, Roboto, helvetica neue, Arial, noto sans, sans-serif, apple color emoji, segoe ui emoji, segoe ui symbol, noto color emoji; } -a, -.table, -.nav-link { - color: var(--main-font-color); + +h1 { + font-size: 32px; } -.nav-pills .nav-link.active, -.nav-pills .show > .nav-link { - color: #0a0a0a; + +h2 { + font-size: 26px; } -.nav-link:hover, -.nav-link:focus { - color: #5cdd8b; +::-webkit-scrollbar { + width: 10px; } -.form-control, -.form-control:focus, -.form-select, -.form-select:focus { - color: var(--main-font-color); - background-color: var(--background-4); +::-webkit-scrollbar-thumb { + background: #ccc; + border-radius: 20px; } -#app { - font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, - segoe ui, Roboto, helvetica neue, Arial, noto sans, sans-serif, - apple color emoji, segoe ui emoji, segoe ui symbol, noto color emoji; +.modal { + backdrop-filter: blur(3px); +} + +.modal-content { + border-radius: 1rem; + box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1); + + .dark & { + box-shadow: 0 15px 70px rgb(0 0 0); + background-color: $dark-bg; + } +} + +.VuePagination__count { + font-size: 13px; + text-align: center; } .shadow-box { - overflow: hidden; + //overflow: hidden; // Forget why add this, but multiple select hide by this box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1); padding: 10px; + border-radius: 10px; + &.big-padding { padding: 20px; } @@ -52,26 +58,233 @@ a, } .btn-primary { - // color: white; - color: #0a0a0a; - - &:hover, - &:active, - &:focus, - &.active { - color: #0a0a0a; + color: white; + + &:hover, &:active, &:focus, &.active { + color: white; background-color: $highlight; border-color: $highlight; } + + .dark & { + color: $dark-font-color2; + } } -.modal-content { - border-radius: 1rem; - backdrop-filter: blur(3px); +.btn-warning { + color: white; + + &:hover, &:active, &:focus, &.active { + color: white; + } } -@media (prefers-color-scheme: dark) { - a:hover { - color: #7ce8a4; +.btn-info { + color: white; + + &:hover, &:active, &:focus, &.active { + color: white; + } +} + +@media (max-width: 550px) { + .table-shadow-box { + padding: 10px !important; + + thead { + display: none; + } + + tbody { + .shadow-box { + background-color: white; + } + } + + tr { + margin-top: 0 !important; + padding: 4px 10px !important; + display: block; + margin-bottom: 6px; + + td:first-child { + font-weight: bold; + } + + td:nth-child(-n+3) { + text-align: center; + } + + td:last-child { + text-align: left; + } + + td { + border-bottom: 1px solid $dark-font-color; + display: block; + padding: 4px; + + .badge { + margin: auto; + display: block; + width: 30%; + } + } + } + } +} + +// Dark Theme override here +.dark { + background-color: #090c10; + color: $dark-font-color; + + &::-webkit-scrollbar-thumb, ::-webkit-scrollbar-thumb { + background: $dark-border-color; + } + + .shadow-box { + background-color: $dark-bg; + } + + .form-check-input { + background-color: $dark-bg2; + } + + .form-switch .form-check-input { + background-color: #131a21; + } + + a, + .table, + .nav-link { + color: $dark-font-color; + + &.btn-info { + color: white; + } + } + + .form-control, + .form-control:focus, + .form-select, + .form-select:focus { + color: $dark-font-color; + background-color: $dark-bg2; + } + + .form-control, .form-select { + border-color: $dark-border-color; + } + + .table-hover > tbody > tr:hover { + --bs-table-accent-bg: #070a10; + color: $dark-font-color; + } + + .nav-pills .nav-link.active, .nav-pills .show > .nav-link { + color: $dark-font-color2; + } + + .bg-primary { + color: $dark-font-color2; + } + + .btn-secondary { + color: white; + } + + .btn-warning { + color: $dark-font-color2; + + &:hover, &:active, &:focus, &.active { + color: $dark-font-color2; + } + } + + .btn-close { + box-shadow: none; + filter: invert(1); + + &:hover { + opacity: 0.6; + } + } + + .modal-header { + border-color: $dark-bg; + } + + .modal-footer { + border-color: $dark-bg; + } + + // Pagination + .page-item.disabled .page-link { + background-color: $dark-bg; + border-color: $dark-border-color; + } + + .page-link { + background-color: $dark-bg; + border-color: $dark-border-color; + color: $dark-font-color; + } + + // Multiselect + .multiselect__tags { + background-color: $dark-bg2; + border-color: $dark-border-color; + } + + .multiselect__input, .multiselect__single { + background-color: $dark-bg2; + color: $dark-font-color; + } + + .multiselect__content-wrapper { + background-color: $dark-bg2; + border-color: $dark-border-color; + } + + .multiselect--above .multiselect__content-wrapper { + border-color: $dark-border-color; + } + + .multiselect__option--selected { + background-color: $dark-bg; + } + + @media (max-width: 550px) { + .table-shadow-box { + tbody { + .shadow-box { + background-color: $dark-bg2; + + td { + border-bottom: 1px solid $dark-border-color; + } + } + } + } } -} \ No newline at end of file +} + +/* + * Transitions + */ + +// page-change +.slide-fade-enter-active { + transition: all 0.2s $easing-in; +} + +.slide-fade-leave-active { + transition: all 0.2s $easing-in; +} + +.slide-fade-enter-from, +.slide-fade-leave-to { + transform: translateY(50px); + opacity: 0; +} diff --git a/src/assets/vars.scss b/src/assets/vars.scss index 60feaca..2f43698 100644 --- a/src/assets/vars.scss +++ b/src/assets/vars.scss @@ -1,32 +1,20 @@ $primary: #5cdd8b; $danger: #dc3545; -$warning: #dca235; +$warning: #f8a306; $link-color: #111; -$border-radius: .25rem; +$border-radius: 50rem; $highlight: #7ce8a4; $highlight-white: #e7faec; -:root { - color-scheme: light dark; - // - --page-background: #fafafa; - --background-secondary: #d0d3d5; - --background-4: #d0d3d5; - --background-ternary: #8e8e8e; - --background-sidebar-active: #e4e4e4; - --background-navbar: #FFF; - --main-font-color: #212529; -} +$dark-font-color: #b1b8c0; +$dark-font-color2: #020b05; +$dark-bg: #0d1117; +$dark-bg2: #070a10; +$dark-border-color: #1d2634; -@media (prefers-color-scheme: dark) { - :root { - --page-background: #0a0a0a; - --background-secondary: #656565; - --background-4: #313131; - --background-ternary: #a7a7a7; - --background-sidebar-active: #777777; - --background-navbar: #333333; - --main-font-color: #e4e4e4; - } -} \ No newline at end of file +$easing-in: cubic-bezier(0.54, 0.78, 0.55, 0.97); +$easing-out: cubic-bezier(0.25, 0.46, 0.45, 0.94); +$easing-in-out: cubic-bezier(0.79, 0.14, 0.15, 0.86); + +$dropdown-border-radius: 0.5rem; diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue index 1f403dd..04d046b 100644 --- a/src/components/HeartbeatBar.vue +++ b/src/components/HeartbeatBar.vue @@ -31,7 +31,7 @@ export default { beatWidth: 10, beatHeight: 30, hoverScale: 1.5, - beatMargin: 3, // Odd number only, even = blurry + beatMargin: 4, move: false, maxBeat: -1, } @@ -122,11 +122,26 @@ export default { this.$root.heartbeatList[this.monitorId] = []; } }, + mounted() { if (this.size === "small") { - this.beatWidth = 5.6; - this.beatMargin = 2.4; - this.beatHeight = 16 + this.beatWidth = 5; + this.beatHeight = 16; + this.beatMargin = 2; + } + + // Suddenly, have an idea how to handle it universally. + // If the pixel * ratio != Integer, then it causes render issue, round it to solve it!! + const actualWidth = this.beatWidth * window.devicePixelRatio; + const actualMargin = this.beatMargin * window.devicePixelRatio; + + if (! Number.isInteger(actualWidth)) { + this.beatWidth = Math.round(actualWidth) / window.devicePixelRatio; + console.log(this.beatWidth); + } + + if (! Number.isInteger(actualMargin)) { + this.beatMargin = Math.round(actualMargin) / window.devicePixelRatio; } window.addEventListener("resize", this.resize); @@ -163,10 +178,6 @@ export default { &.empty { background-color: aliceblue; - - .dark & { - background-color: #d0d3d5; - } } &.down { diff --git a/src/components/HiddenInput.vue b/src/components/HiddenInput.vue new file mode 100644 index 0000000..7ec9f2e --- /dev/null +++ b/src/components/HiddenInput.vue @@ -0,0 +1,102 @@ + + + diff --git a/src/components/MonitorList.vue b/src/components/MonitorList.vue index cb44280..75fd18c 100644 --- a/src/components/MonitorList.vue +++ b/src/components/MonitorList.vue @@ -1,5 +1,5 @@