Merge branch 'master' into dns-monitor

This commit is contained in:
LouisLam 2021-08-26 01:05:46 +08:00
commit 46ac753c46
30 changed files with 1571 additions and 3972 deletions

View file

@ -16,6 +16,9 @@ module.exports = {
requireConfigFile: false, requireConfigFile: false,
}, },
rules: { rules: {
"camelcase": ["warn", {
"properties": "never"
}],
// override/add rules settings here, such as: // override/add rules settings here, such as:
// 'vue/no-unused-vars': 'error' // 'vue/no-unused-vars': 'error'
"no-unused-vars": "warn", "no-unused-vars": "warn",
@ -74,5 +77,6 @@ module.exports = {
"no-empty": ["error", { "no-empty": ["error", {
"allowEmptyCatch": true "allowEmptyCatch": true
}], }],
"no-control-regex": "off"
}, },
} }

View file

@ -1,3 +1,9 @@
{ {
"extends": "stylelint-config-recommended" "extends": "stylelint-config-standard",
"rules": {
"indentation": 4,
"no-descending-specificity": null,
"selector-list-comma-newline-after": null,
"declaration-empty-line-before": null
}
} }

View file

@ -6,7 +6,38 @@ The project was created with vite.js (vue3). Then I created a sub-directory call
The frontend code build into "dist" directory. The server uses "dist" as root. This is how production is working. The frontend code build into "dist" directory. The server uses "dist" as root. This is how production is working.
Your IDE should follow the config in ".editorconfig". The most special thing is I set it to 4 spaces indentation. I know 2 spaces indentation became a kind of standard nowadays for js, but my eyes is not so comfortable for this. In my opinion, there is no callback-hell nowadays, it is good to go back 4 spaces world again. # 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 # Project Styles
@ -19,16 +50,27 @@ For example, recently, because I am not a python expert, I spent a 2 hours to re
- All settings in frontend. - All settings in frontend.
- Easy to use - Easy to use
# Coding Styles
- Follow .editorconfig
- Follow eslint
## Name convention
- Javascript/Typescript: camelCaseType
- SQLite: underscore_type
- CSS/SCSS: dash-type
# Tools # Tools
- Node.js >= 14 - Node.js >= 14
- Git - Git
- IDE that supports .editorconfig (I am using Intellji Idea) - IDE that supports .editorconfig and eslint (I am using Intellji Idea)
- A SQLite tool (I am using SQLite Expert Personal) - A SQLite tool (I am using SQLite Expert Personal)
# Prepare the dev # Install dependencies
```bash ```bash
npm install npm install --dev
``` ```
# Backend Dev # Backend Dev
@ -39,7 +81,6 @@ npm run start-server
# Or # Or
node server/server.js node server/server.js
``` ```
It binds to 0.0.0.0:3001 by default. It binds to 0.0.0.0:3001 by default.
@ -92,7 +133,8 @@ The data and socket logic in "src/mixins/socket.js"
# Database Migration # Database Migration
TODO 1. create `patch{num}.sql` in `./db/`
1. update `latestVersion` in `./server/database.js`
# Unit Test # Unit Test

View file

@ -2,10 +2,10 @@
FROM node:14-alpine3.12 AS release FROM node:14-alpine3.12 AS release
WORKDIR /app WORKDIR /app
# split the sqlite install here, so that it can caches the arm prebuilt # split the sqlite install here, so that it can caches the prebuilt
RUN apk add --no-cache --virtual .build-deps make g++ python3 python3-dev && \ RUN apk add --no-cache --virtual .build-deps make g++ python3 python3-dev && \
ln -s /usr/bin/python3 /usr/bin/python && \ ln -s /usr/bin/python3 /usr/bin/python && \
npm install @louislam/sqlite3@5.0.3 bcrypt@5.0.1 && \ npm install better-sqlite3@7.4.3 bcrypt@5.0.1 && \
apk del .build-deps && \ apk del .build-deps && \
rm -f /usr/bin/python rm -f /usr/bin/python

4507
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,9 @@
"node": "14.*" "node": "14.*"
}, },
"scripts": { "scripts": {
"lint:js": "eslint --ext \".js,.vue\" --ignore-path .gitignore .",
"lint:style": "stylelint \"**/*.{vue,css,scss}\" --ignore-path .gitignore",
"lint": "npm run lint:js && npm run lint:style",
"dev": "vite --host", "dev": "vite --host",
"start": "npm run start-server", "start": "npm run start-server",
"start-server": "node server/server.js", "start-server": "node server/server.js",
@ -19,7 +22,7 @@
"vite-preview-dist": "vite preview --host", "vite-preview-dist": "vite preview --host",
"build-docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma -t louislam/uptime-kuma:1 -t louislam/uptime-kuma:1.3.2 --target release . --push", "build-docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma -t louislam/uptime-kuma:1 -t louislam/uptime-kuma:1.3.2 --target release . --push",
"build-docker-nightly": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:nightly --target nightly . --push", "build-docker-nightly": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:nightly --target nightly . --push",
"build-docker-nightly-amd64": "docker buildx build --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push", "build-docker-nightly-amd64": "docker buildx build --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push --progress plain",
"setup": "git checkout 1.3.2 && npm install && npm run build", "setup": "git checkout 1.3.2 && npm install && npm run build",
"update-version": "node extra/update-version.js", "update-version": "node extra/update-version.js",
"mark-as-nightly": "node extra/mark-as-nightly.js", "mark-as-nightly": "node extra/mark-as-nightly.js",
@ -36,11 +39,11 @@
"@fortawesome/free-regular-svg-icons": "^5.15.4", "@fortawesome/free-regular-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/vue-fontawesome": "^3.0.0-4", "@fortawesome/vue-fontawesome": "^3.0.0-4",
"@louislam/sqlite3": "^5.0.3",
"@popperjs/core": "^2.9.3", "@popperjs/core": "^2.9.3",
"args-parser": "^1.3.0", "args-parser": "^1.3.0",
"axios": "^0.21.1", "axios": "^0.21.1",
"bcrypt": "^5.0.1", "bcrypt": "^5.0.1",
"better-sqlite3": "^7.4.3",
"bootstrap": "^5.1.0", "bootstrap": "^5.1.0",
"chart.js": "^3.5.0", "chart.js": "^3.5.0",
"chartjs-adapter-dayjs": "^1.0.0", "chartjs-adapter-dayjs": "^1.0.0",
@ -56,7 +59,7 @@
"password-hash": "^1.2.2", "password-hash": "^1.2.2",
"prom-client": "^13.2.0", "prom-client": "^13.2.0",
"prometheus-api-metrics": "^3.2.0", "prometheus-api-metrics": "^3.2.0",
"redbean-node": "0.0.21", "redbean-node": "0.1.1",
"socket.io": "^4.1.3", "socket.io": "^4.1.3",
"socket.io-client": "^4.1.3", "socket.io-client": "^4.1.3",
"tcp-ping": "^0.1.1", "tcp-ping": "^0.1.1",
@ -64,6 +67,7 @@
"vue": "^3.2.2", "vue": "^3.2.2",
"vue-chart-3": "^0.5.7", "vue-chart-3": "^0.5.7",
"vue-confirm-dialog": "^1.0.2", "vue-confirm-dialog": "^1.0.2",
"vue-i18n": "^9.1.7",
"vue-multiselect": "^3.0.0-alpha.2", "vue-multiselect": "^3.0.0-alpha.2",
"vue-router": "^4.0.11", "vue-router": "^4.0.11",
"vue-toastification": "^2.0.0-rc.1" "vue-toastification": "^2.0.0-rc.1"
@ -79,7 +83,6 @@
"eslint-plugin-vue": "^7.16.0", "eslint-plugin-vue": "^7.16.0",
"sass": "^1.37.5", "sass": "^1.37.5",
"stylelint": "^13.13.1", "stylelint": "^13.13.1",
"stylelint-config-recommended": "^5.0.0",
"stylelint-config-standard": "^22.0.0", "stylelint-config-standard": "^22.0.0",
"typescript": "^4.3.5", "typescript": "^4.3.5",
"vite": "^2.4.4" "vite": "^2.4.4"

View file

@ -1,9 +1,6 @@
const fs = require("fs"); const fs = require("fs");
const { sleep, debug, isDev } = require("../src/util");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { setSetting, setting } = require("./util-server"); const { setSetting, setting } = require("./util-server");
const knex = require("knex");
const sqlite3 = require("@louislam/sqlite3");
class Database { class Database {
@ -14,48 +11,23 @@ class Database {
static sqliteInstance = null; static sqliteInstance = null;
static async connect() { static async connect() {
const acquireConnectionTimeout = 120 * 1000;
if (! this.sqliteInstance) { R.useBetterSQLite3 = true;
this.sqliteInstance = new sqlite3.Database(Database.path); R.betterSQLite3Options.timeout = acquireConnectionTimeout;
this.sqliteInstance.run("PRAGMA journal_mode = WAL");
}
const Dialect = require("knex/lib/dialects/sqlite3/index.js"); R.setup("sqlite", {
Dialect.prototype._driver = () => sqlite3; filename: Database.path,
if (isDev) {
Dialect.prototype.acquireConnectionOrg = Dialect.prototype.acquireConnection;
Dialect.prototype.acquireConnection = async function () {
let a = await this.acquireConnectionOrg();
debug("acquired Connection");
return a;
};
}
// Always return same connection.
Dialect.prototype.acquireRawConnection = async function () {
return Database.sqliteInstance;
};
Dialect.prototype.destroyRawConnection = async () => { }
const knexInstance = knex({
client: Dialect,
connection: { }, // Do not remove, Leave it empty is ok
useNullAsDefault: true, useNullAsDefault: true,
pool: { acquireConnectionTimeout: acquireConnectionTimeout,
min: 1, }, {
max: 1, min: 1,
idleTimeoutMillis: 30000, max: 1,
} idleTimeoutMillis: 120 * 1000,
propagateCreateError: false,
acquireTimeoutMillis: acquireConnectionTimeout,
}); });
console.log( knexInstance.pool)
console.log("pool size")
R.setup(knexInstance);
if (process.env.SQL_LOG === "1") { if (process.env.SQL_LOG === "1") {
R.debug(true); R.debug(true);
} }
@ -63,6 +35,10 @@ class Database {
// Auto map the model to a bean object // Auto map the model to a bean object
R.freeze(true) R.freeze(true)
await R.autoloadModels("./server/model"); await R.autoloadModels("./server/model");
// Change to WAL
await R.exec("PRAGMA journal_mode = WAL");
console.log(await R.getAll("PRAGMA journal_mode"));
} }
static async patch() { static async patch() {
@ -148,11 +124,18 @@ class Database {
return statement !== ""; 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) { for (let statement of statements) {
await R.exec(statement); db.prepare(statement).run();
} }
} }
static getBetterSQLite3Database() {
return R.knex.client.acquireConnection();
}
/** /**
* Special handle, because tarn.js throw a promise reject that cannot be caught * Special handle, because tarn.js throw a promise reject that cannot be caught
* @returns {Promise<void>} * @returns {Promise<void>}

View file

@ -112,10 +112,9 @@ class Monitor extends BeanModel {
try { try {
if (this.type === "http" || this.type === "keyword") { if (this.type === "http" || this.type === "keyword") {
// Do not do any queries/high loading things before the "bean.ping"
let startTime = dayjs().valueOf(); let startTime = dayjs().valueOf();
// Use Custom agent to disable session reuse
// https://github.com/nodejs/node/issues/3940
let res = await axios.get(this.url, { let res = await axios.get(this.url, {
timeout: this.interval * 1000 * 0.8, timeout: this.interval * 1000 * 0.8,
headers: { headers: {
@ -123,7 +122,7 @@ class Monitor extends BeanModel {
"User-Agent": "Uptime-Kuma/" + version, "User-Agent": "Uptime-Kuma/" + version,
}, },
httpsAgent: new https.Agent({ httpsAgent: new https.Agent({
maxCachedSessions: 0, maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
rejectUnauthorized: ! this.getIgnoreTls(), rejectUnauthorized: ! this.getIgnoreTls(),
}), }),
maxRedirects: this.maxredirects, maxRedirects: this.maxredirects,
@ -276,7 +275,8 @@ class Monitor extends BeanModel {
try { try {
await Notification.send(JSON.parse(notification.config), msg, await this.toJSON(), bean.toJSON()) await Notification.send(JSON.parse(notification.config), msg, await this.toJSON(), bean.toJSON())
} catch (e) { } catch (e) {
console.error("Cannot send notification to " + notification.name) console.error("Cannot send notification to " + notification.name);
console.log(e);
} }
} }
} }
@ -293,22 +293,22 @@ class Monitor extends BeanModel {
console.warn(`Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Type: ${this.type}`) console.warn(`Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Type: ${this.type}`)
} }
prometheus.update(bean, tlsInfo)
io.to(this.user_id).emit("heartbeat", bean.toJSON()); io.to(this.user_id).emit("heartbeat", bean.toJSON());
await R.store(bean)
Monitor.sendStats(io, this.id, this.user_id) Monitor.sendStats(io, this.id, this.user_id)
await R.store(bean);
prometheus.update(bean, tlsInfo);
previousBeat = bean; previousBeat = bean;
this.heartbeatInterval = setTimeout(beat, this.interval * 1000);
} }
beat(); beat();
this.heartbeatInterval = setInterval(beat, this.interval * 1000);
} }
stop() { stop() {
clearInterval(this.heartbeatInterval) clearTimeout(this.heartbeatInterval);
} }
/** /**

View file

@ -279,6 +279,116 @@ class Notification {
throwGeneralAxiosError(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"] == 0) {
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"] == 1) {
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") { } else if (notification.type === "pushover") {
let pushoverlink = "https://api.pushover.net/1/messages.json" let pushoverlink = "https://api.pushover.net/1/messages.json"
try { try {
@ -404,7 +514,7 @@ class Notification {
"messages": [ "messages": [
{ {
"type": "text", "type": "text",
"text":"Test Successful!" "text": "Test Successful!"
} }
] ]
} }
@ -415,7 +525,7 @@ class Notification {
"messages": [ "messages": [
{ {
"type": "text", "type": "text",
"text":"UptimeKuma Alert: [🔴 Down]\n" + "Name: " + monitorJSON["name"] + " \n" + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"] "text": "UptimeKuma Alert: [🔴 Down]\n" + "Name: " + monitorJSON["name"] + " \n" + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"]
} }
] ]
} }
@ -426,7 +536,7 @@ class Notification {
"messages": [ "messages": [
{ {
"type": "text", "type": "text",
"text":"UptimeKuma Alert: [✅ Up]\n" + "Name: " + monitorJSON["name"] + " \n" + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"] "text": "UptimeKuma Alert: [✅ Up]\n" + "Name: " + monitorJSON["name"] + " \n" + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"]
} }
] ]
} }

View file

@ -12,8 +12,6 @@ debug("Importing express");
const express = require("express"); const express = require("express");
debug("Importing socket.io"); debug("Importing socket.io");
const { Server } = require("socket.io"); const { Server } = require("socket.io");
debug("Importing dayjs");
const dayjs = require("dayjs");
debug("Importing redbean-node"); debug("Importing redbean-node");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
debug("Importing jsonwebtoken"); debug("Importing jsonwebtoken");
@ -150,7 +148,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
if (user) { if (user) {
debug("afterLogin") debug("afterLogin")
await afterLogin(socket, user) afterLogin(socket, user)
debug("afterLogin ok") debug("afterLogin ok")
@ -178,7 +176,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
let user = await login(data.username, data.password) let user = await login(data.username, data.password)
if (user) { if (user) {
await afterLogin(socket, user) afterLogin(socket, user)
callback({ callback({
ok: true, ok: true,
@ -563,7 +561,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
debug("check auto login") debug("check auto login")
if (await setting("disableAuth")) { if (await setting("disableAuth")) {
console.log("Disabled Auth: auto login to admin") console.log("Disabled Auth: auto login to admin")
await afterLogin(socket, await R.findOne("user")) afterLogin(socket, await R.findOne("user"))
socket.emit("autoLogin") socket.emit("autoLogin")
} else { } else {
debug("need auth") debug("need auth")
@ -623,6 +621,8 @@ async function sendMonitorList(socket) {
} }
async function sendNotificationList(socket) { async function sendNotificationList(socket) {
const timeLogger = new TimeLogger();
let result = []; let result = [];
let list = await R.find("notification", " user_id = ? ", [ let list = await R.find("notification", " user_id = ? ", [
socket.userID, socket.userID,
@ -633,6 +633,9 @@ async function sendNotificationList(socket) {
} }
io.to(socket.userID).emit("notificationList", result) io.to(socket.userID).emit("notificationList", result)
timeLogger.print("Send Notification List");
return list; return list;
} }
@ -641,24 +644,27 @@ async function afterLogin(socket, user) {
socket.join(user.id) socket.join(user.id)
let monitorList = await sendMonitorList(socket) let monitorList = await sendMonitorList(socket)
sendNotificationList(socket) sendNotificationList(socket)
// Delay a bit, so that it let the main page to query the data first, since SQLite can process one sql at the same time only. await sleep(500);
// For example, query the edit data first.
setTimeout(async () => { for (let monitorID in monitorList) {
for (let monitorID in monitorList) { await sendHeartbeatList(socket, monitorID);
sendHeartbeatList(socket, monitorID); }
sendImportantHeartbeatList(socket, monitorID);
Monitor.sendStats(io, monitorID, user.id) for (let monitorID in monitorList) {
} await sendImportantHeartbeatList(socket, monitorID);
}, 500); }
for (let monitorID in monitorList) {
await Monitor.sendStats(io, monitorID, user.id)
}
} }
async function getMonitorJSONList(userID) { async function getMonitorJSONList(userID) {
let result = {}; let result = {};
let monitorList = await R.find("monitor", " user_id = ? ", [ let monitorList = await R.find("monitor", " user_id = ? ORDER BY weight DESC, name", [
userID, userID,
]) ])
@ -788,6 +794,8 @@ async function sendHeartbeatList(socket, monitorID) {
} }
socket.emit("heartbeatList", monitorID, result) socket.emit("heartbeatList", monitorID, result)
timeLogger.print(`[Monitor: ${monitorID}] sendHeartbeatList`)
} }
async function sendImportantHeartbeatList(socket, monitorID) { async function sendImportantHeartbeatList(socket, monitorID) {

View file

@ -2,7 +2,7 @@
@import "node_modules/bootstrap/scss/bootstrap"; @import "node_modules/bootstrap/scss/bootstrap";
#app { #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; 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;
} }
h1 { h1 {
@ -18,7 +18,7 @@ h2 {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #CCC; background: #ccc;
border-radius: 20px; border-radius: 20px;
} }
@ -28,7 +28,7 @@ h2 {
.modal-content { .modal-content {
border-radius: 1rem; border-radius: 1rem;
box-shadow: 0 15px 70px rgba(0, 0, 0, .1); box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1);
.dark & { .dark & {
box-shadow: 0 15px 70px rgb(0 0 0); box-shadow: 0 15px 70px rgb(0 0 0);
@ -41,10 +41,9 @@ h2 {
text-align: center; text-align: center;
} }
.shadow-box { .shadow-box {
//overflow: hidden; // Forget why add this, but multiple select hide by this //overflow: hidden; // Forget why add this, but multiple select hide by this
box-shadow: 0 15px 70px rgba(0, 0, 0, .1); box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1);
padding: 10px; padding: 10px;
border-radius: 10px; border-radius: 10px;
@ -80,9 +79,56 @@ h2 {
} }
} }
@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 Theme override here
.dark { .dark {
background-color: #090C10; background-color: #090c10;
color: $dark-font-color; color: $dark-font-color;
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
@ -124,7 +170,7 @@ h2 {
} }
.table-hover > tbody > tr:hover { .table-hover > tbody > tr:hover {
--bs-table-accent-bg: #070A10; --bs-table-accent-bg: #070a10;
color: $dark-font-color; color: $dark-font-color;
} }
@ -192,6 +238,20 @@ h2 {
.multiselect__option--selected { .multiselect__option--selected {
background-color: $dark-bg; 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;
}
}
}
}
}
} }
/* /*
@ -200,11 +260,13 @@ h2 {
// page-change // page-change
.slide-fade-enter-active { .slide-fade-enter-active {
transition: all 0.20s $easing-in; transition: all 0.2s $easing-in;
} }
.slide-fade-leave-active { .slide-fade-leave-active {
transition: all 0.20s $easing-in; transition: all 0.2s $easing-in;
} }
.slide-fade-enter-from, .slide-fade-enter-from,
.slide-fade-leave-to { .slide-fade-leave-to {
transform: translateY(50px); transform: translateY(50px);

View file

@ -1,5 +1,5 @@
$primary: #5CDD8B; $primary: #5cdd8b;
$danger: #DC3545; $danger: #dc3545;
$warning: #f8a306; $warning: #f8a306;
$link-color: #111; $link-color: #111;
$border-radius: 50rem; $border-radius: 50rem;
@ -9,10 +9,10 @@ $highlight-white: #e7faec;
$dark-font-color: #b1b8c0; $dark-font-color: #b1b8c0;
$dark-font-color2: #020b05; $dark-font-color2: #020b05;
$dark-bg: #0D1117; $dark-bg: #0d1117;
$dark-bg2: #070A10; $dark-bg2: #070a10;
$dark-border-color: #1d2634; $dark-border-color: #1d2634;
$easing-in: cubic-bezier(0.54,0.78,0.55,0.97); $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-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);
$easing-in-out: cubic-bezier(0.79, 0.14, 0.15, 0.86); $easing-in-out: cubic-bezier(0.79, 0.14, 0.15, 0.86);

View file

@ -4,7 +4,7 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 id="exampleModalLabel" class="modal-title"> <h5 id="exampleModalLabel" class="modal-title">
Confirm {{ $t("Confirm") }}
</h5> </h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" /> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div> </div>
@ -35,7 +35,7 @@ export default {
}, },
yesText: { yesText: {
type: String, type: String,
default: "Yes", default: "Yes", // TODO: No idea what to translate this
}, },
noText: { noText: {
type: String, type: String,

View file

@ -163,10 +163,6 @@ export default {
&.empty { &.empty {
background-color: aliceblue; background-color: aliceblue;
.dark & {
background-color: #d0d3d5;
}
} }
&.down { &.down {
@ -186,7 +182,7 @@ export default {
} }
.dark { .dark {
.hp-bar-big .beat.empty{ .hp-bar-big .beat.empty {
background-color: #848484; background-color: #848484;
} }
} }

View file

@ -6,12 +6,12 @@
<div class="form-floating"> <div class="form-floating">
<input id="floatingInput" v-model="username" type="text" class="form-control" placeholder="Username"> <input id="floatingInput" v-model="username" type="text" class="form-control" placeholder="Username">
<label for="floatingInput">Username</label> <label for="floatingInput">{{ $t("Username") }}</label>
</div> </div>
<div class="form-floating mt-3"> <div class="form-floating mt-3">
<input id="floatingPassword" v-model="password" type="password" class="form-control" placeholder="Password"> <input id="floatingPassword" v-model="password" type="password" class="form-control" placeholder="Password">
<label for="floatingPassword">Password</label> <label for="floatingPassword">{{ $t("Password") }}</label>
</div> </div>
<div class="form-check mb-3 mt-3 d-flex justify-content-center pe-4"> <div class="form-check mb-3 mt-3 d-flex justify-content-center pe-4">
@ -19,12 +19,12 @@
<input id="remember" v-model="$root.remember" type="checkbox" value="remember-me" class="form-check-input"> <input id="remember" v-model="$root.remember" type="checkbox" value="remember-me" class="form-check-input">
<label class="form-check-label" for="remember"> <label class="form-check-label" for="remember">
Remember me {{ $t("Remember me") }}
</label> </label>
</div> </div>
</div> </div>
<button class="w-100 btn btn-primary" type="submit" :disabled="processing"> <button class="w-100 btn btn-primary" type="submit" :disabled="processing">
Login {{ $t("Login") }}
</button> </button>
<div v-if="res && !res.ok" class="alert alert-danger mt-3" role="alert"> <div v-if="res && !res.ok" class="alert alert-danger mt-3" role="alert">

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="shadow-box list mb-4"> <div class="shadow-box list mb-4">
<div v-if="Object.keys($root.monitorList).length === 0" class="text-center mt-3"> <div v-if="Object.keys($root.monitorList).length === 0" class="text-center mt-3">
No Monitors, please <router-link to="/add">add one</router-link>. {{ $t("No Monitors, please") }} <router-link to="/add">{{ $t("add one") }}</router-link>
</div> </div>
<router-link v-for="(item, index) in sortedMonitorList" :key="index" :to="monitorURL(item.id)" class="item" :class="{ 'disabled': ! item.active }"> <router-link v-for="(item, index) in sortedMonitorList" :key="index" :to="monitorURL(item.id)" class="item" :class="{ 'disabled': ! item.active }">

View file

@ -5,17 +5,17 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 id="exampleModalLabel" class="modal-title"> <h5 id="exampleModalLabel" class="modal-title">
Setup Notification {{ $t("Setup Notification") }}
</h5> </h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" /> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label for="type" class="form-label">Notification Type</label> <label for="type" class="form-label">{{ $t("Notification Type") }}</label>
<select id="type" v-model="notification.type" class="form-select"> <select id="type" v-model="notification.type" class="form-select">
<option value="telegram">Telegram</option> <option value="telegram">Telegram</option>
<option value="webhook">Webhook</option> <option value="webhook">Webhook</option>
<option value="smtp">Email (SMTP)</option> <option value="smtp">{{ $t("Email") }} (SMTP)</option>
<option value="discord">Discord</option> <option value="discord">Discord</option>
<option value="signal">Signal</option> <option value="signal">Signal</option>
<option value="gotify">Gotify</option> <option value="gotify">Gotify</option>
@ -27,11 +27,12 @@
<option value="apprise">Apprise (Support 50+ Notification services)</option> <option value="apprise">Apprise (Support 50+ Notification services)</option>
<option value="pushbullet">Pushbullet</option> <option value="pushbullet">Pushbullet</option>
<option value="line">Line Messenger</option> <option value="line">Line Messenger</option>
<option value="mattermost">Mattermost</option>
</select> </select>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="name" class="form-label">Friendly Name</label> <label for="name" class="form-label">{{ $t("Friendly Name") }}</label>
<input id="name" v-model="notification.name" type="text" class="form-control" required> <input id="name" v-model="notification.name" type="text" class="form-control" required>
</div> </div>
@ -210,7 +211,7 @@
<template v-if="notification.type === 'slack'"> <template v-if="notification.type === 'slack'">
<div class="mb-3"> <div class="mb-3">
<label for="slack-webhook-url" class="form-label">Webhook URL<span style="color:red;"><sup>*</sup></span></label> <label for="slack-webhook-url" class="form-label">Webhook URL<span style="color: red;"><sup>*</sup></span></label>
<input id="slack-webhook-url" v-model="notification.slackwebhookURL" type="text" class="form-control" required> <input id="slack-webhook-url" v-model="notification.slackwebhookURL" type="text" class="form-control" required>
<label for="slack-username" class="form-label">Username</label> <label for="slack-username" class="form-label">Username</label>
<input id="slack-username" v-model="notification.slackusername" type="text" class="form-control"> <input id="slack-username" v-model="notification.slackusername" type="text" class="form-control">
@ -221,7 +222,7 @@
<label for="slack-button-url" class="form-label">Uptime Kuma URL</label> <label for="slack-button-url" class="form-label">Uptime Kuma URL</label>
<input id="slack-button" v-model="notification.slackbutton" type="text" class="form-control"> <input id="slack-button" v-model="notification.slackbutton" type="text" class="form-control">
<div class="form-text"> <div class="form-text">
<span style="color:red;"><sup>*</sup></span>Required <span style="color: red;"><sup>*</sup></span>Required
<p style="margin-top: 8px;"> <p style="margin-top: 8px;">
More info about webhooks on: <a href="https://api.slack.com/messaging/webhooks" target="_blank">https://api.slack.com/messaging/webhooks</a> More info about webhooks on: <a href="https://api.slack.com/messaging/webhooks" target="_blank">https://api.slack.com/messaging/webhooks</a>
</p> </p>
@ -238,6 +239,39 @@
</div> </div>
</template> </template>
<template v-if="notification.type === 'mattermost'">
<div class="mb-3">
<label for="mattermost-webhook-url" class="form-label">Webhook URL<span style="color:red;"><sup>*</sup></span></label>
<input id="mattermost-webhook-url" v-model="notification.mattermostWebhookUrl" type="text" class="form-control" required>
<label for="mattermost-username" class="form-label">Username</label>
<input id="mattermost-username" v-model="notification.mattermostusername" type="text" class="form-control">
<label for="mattermost-iconurl" class="form-label">Icon URL</label>
<input id="mattermost-iconurl" v-model="notification.mattermosticonurl" type="text" class="form-control">
<label for="mattermost-iconemo" class="form-label">Icon Emoji</label>
<input id="mattermost-iconemo" v-model="notification.mattermosticonemo" type="text" class="form-control">
<label for="mattermost-channel" class="form-label">Channel Name</label>
<input id="mattermost-channel-name" v-model="notification.mattermostchannel" type="text" class="form-control">
<div class="form-text">
<span style="color:red;"><sup>*</sup></span>Required
<p style="margin-top: 8px;">
More info about webhooks on: <a href="https://docs.mattermost.com/developer/webhooks-incoming.html" target="_blank">https://docs.mattermost.com/developer/webhooks-incoming.html</a>
</p>
<p style="margin-top: 8px;">
You can override the default channel that webhook posts to by entering the channel name into "Channel Name" field. This needs to be enabled in Mattermost webhook settings. Ex: #other-channel
</p>
<p style="margin-top: 8px;">
If you leave the Uptime Kuma URL field blank, it will default to the Project Github page.
</p>
<p style="margin-top: 8px;">
You can provide a link to a picture in "Icon URL" to override the default profile picture. Will not be used if Icon Emoji is set.
</p>
<p style="margin-top: 8px;">
Emoji cheat sheet: <a href="https://www.webfx.com/tools/emoji-cheat-sheet/" target="_blank">https://www.webfx.com/tools/emoji-cheat-sheet/</a> Note: emoji takes preference over Icon URL.
</p>
</div>
</div>
</template>
<template v-if="notification.type === 'pushy'"> <template v-if="notification.type === 'pushy'">
<div class="mb-3"> <div class="mb-3">
<label for="pushy-app-token" class="form-label">API_KEY</label> <label for="pushy-app-token" class="form-label">API_KEY</label>
@ -288,9 +322,9 @@
<template v-if="notification.type === 'pushover'"> <template v-if="notification.type === 'pushover'">
<div class="mb-3"> <div class="mb-3">
<label for="pushover-user" class="form-label">User Key<span style="color:red;"><sup>*</sup></span></label> <label for="pushover-user" class="form-label">User Key<span style="color: red;"><sup>*</sup></span></label>
<input id="pushover-user" v-model="notification.pushoveruserkey" type="text" class="form-control" required> <input id="pushover-user" v-model="notification.pushoveruserkey" type="text" class="form-control" required>
<label for="pushover-app-token" class="form-label">Application Token<span style="color:red;"><sup>*</sup></span></label> <label for="pushover-app-token" class="form-label">Application Token<span style="color: red;"><sup>*</sup></span></label>
<input id="pushover-app-token" v-model="notification.pushoverapptoken" type="text" class="form-control" required> <input id="pushover-app-token" v-model="notification.pushoverapptoken" type="text" class="form-control" required>
<label for="pushover-device" class="form-label">Device</label> <label for="pushover-device" class="form-label">Device</label>
<input id="pushover-device" v-model="notification.pushoverdevice" type="text" class="form-control"> <input id="pushover-device" v-model="notification.pushoverdevice" type="text" class="form-control">
@ -330,7 +364,7 @@
<option>none</option> <option>none</option>
</select> </select>
<div class="form-text"> <div class="form-text">
<span style="color:red;"><sup>*</sup></span>Required <span style="color: red;"><sup>*</sup></span>Required
<p style="margin-top: 8px;"> <p style="margin-top: 8px;">
More info on: <a href="https://pushover.net/api" target="_blank">https://pushover.net/api</a> More info on: <a href="https://pushover.net/api" target="_blank">https://pushover.net/api</a>
</p> </p>
@ -366,10 +400,10 @@
<template v-if="notification.type === 'lunasea'"> <template v-if="notification.type === 'lunasea'">
<div class="mb-3"> <div class="mb-3">
<label for="lunasea-device" class="form-label">LunaSea Device ID<span style="color:red;"><sup>*</sup></span></label> <label for="lunasea-device" class="form-label">LunaSea Device ID<span style="color: red;"><sup>*</sup></span></label>
<input id="lunasea-device" v-model="notification.lunaseaDevice" type="text" class="form-control" required> <input id="lunasea-device" v-model="notification.lunaseaDevice" type="text" class="form-control" required>
<div class="form-text"> <div class="form-text">
<p><span style="color:red;"><sup>*</sup></span>Required</p> <p><span style="color: red;"><sup>*</sup></span>Required</p>
</div> </div>
</div> </div>
</template> </template>
@ -407,13 +441,13 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm"> <button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
Delete {{ $t("Delete") }}
</button> </button>
<button type="button" class="btn btn-warning" :disabled="processing" @click="test"> <button type="button" class="btn btn-warning" :disabled="processing" @click="test">
Test {{ $t("Test") }}
</button> </button>
<button type="submit" class="btn btn-primary" :disabled="processing"> <button type="submit" class="btn btn-primary" :disabled="processing">
Save {{ $t("Save") }}
</button> </button>
</div> </div>
</div> </div>

View file

@ -24,6 +24,7 @@ export default {
}, },
data() { data() {
return { return {
// Configurable filtering on top of the returned data
chartPeriodHrs: 6, chartPeriodHrs: 6,
}; };
}, },
@ -55,11 +56,10 @@ export default {
elements: { elements: {
point: { point: {
// Hide points on chart unless mouse-over
radius: 0, radius: 0,
hitRadius: 100,
}, },
bar: {
barThickness: "flex",
}
}, },
scales: { scales: {
x: { x: {
@ -77,9 +77,9 @@ export default {
maxRotation: 0, maxRotation: 0,
autoSkipPadding: 30, autoSkipPadding: 30,
}, },
bounds: "ticks",
grid: { grid: {
color: this.$root.theme === "light" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.1)", color: this.$root.theme === "light" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.1)",
offset: false,
}, },
}, },
y: { y: {
@ -109,8 +109,11 @@ export default {
mode: "nearest", mode: "nearest",
intersect: false, intersect: false,
padding: 10, padding: 10,
backgroundColor: this.$root.theme === "light" ? "rgba(212,232,222,1.0)" : "rgba(32,42,38,1.0)",
bodyColor: this.$root.theme === "light" ? "rgba(12,12,18,1.0)" : "rgba(220,220,220,1.0)",
titleColor: this.$root.theme === "light" ? "rgba(12,12,18,1.0)" : "rgba(220,220,220,1.0)",
filter: function (tooltipItem) { filter: function (tooltipItem) {
return tooltipItem.datasetIndex === 0; return tooltipItem.datasetIndex === 0; // Hide tooltip on Bar Chart
}, },
callbacks: { callbacks: {
label: (context) => { label: (context) => {
@ -125,32 +128,29 @@ export default {
} }
}, },
chartData() { chartData() {
let ping_data = []; let pingData = []; // Ping Data for Line Chart, y-axis contains ping time
let down_data = []; let downData = []; // Down Data for Bar Chart, y-axis is 1 if target is down, 0 if target is up
if (this.monitorId in this.$root.heartbeatList) { if (this.monitorId in this.$root.heartbeatList) {
ping_data = this.$root.heartbeatList[this.monitorId] this.$root.heartbeatList[this.monitorId]
.filter( .filter(
(beat) => dayjs.utc(beat.time).tz(this.$root.timezone).isAfter(dayjs().subtract(this.chartPeriodHrs, "hours"))) (beat) => dayjs.utc(beat.time).tz(this.$root.timezone).isAfter(dayjs().subtract(this.chartPeriodHrs, "hours")))
.map((beat) => { .map((beat) => {
return { const x = this.$root.datetime(beat.time);
x: dayjs.utc(beat.time).tz(this.$root.timezone).format("YYYY-MM-DD HH:mm:ss"), pingData.push({
x,
y: beat.ping, y: beat.ping,
}; });
}); downData.push({
down_data = this.$root.heartbeatList[this.monitorId] x,
.filter(
(beat) => dayjs.utc(beat.time).tz(this.$root.timezone).isAfter(dayjs().subtract(this.chartPeriodHrs, "hours")))
.map((beat) => {
return {
x: dayjs.utc(beat.time).tz(this.$root.timezone).format("YYYY-MM-DD HH:mm:ss"),
y: beat.status === 0 ? 1 : 0, y: beat.status === 0 ? 1 : 0,
}; })
}); });
} }
return { return {
datasets: [ datasets: [
{ {
data: ping_data, // Line Chart
data: pingData,
fill: "origin", fill: "origin",
tension: 0.2, tension: 0.2,
borderColor: "#5CDD8B", borderColor: "#5CDD8B",
@ -158,11 +158,15 @@ export default {
yAxisID: "y", yAxisID: "y",
}, },
{ {
// Bar Chart
type: "bar", type: "bar",
data: down_data, data: downData,
borderColor: "#00000000", borderColor: "#00000000",
backgroundColor: "#DC354568", backgroundColor: "#DC354568",
yAxisID: "y1", yAxisID: "y1",
barThickness: "flex",
barPercentage: 1,
categoryPercentage: 1,
}, },
], ],
}; };

View file

@ -27,18 +27,18 @@ export default {
text() { text() {
if (this.status === 0) { if (this.status === 0) {
return "Down" return this.$t("Down");
} }
if (this.status === 1) { if (this.status === 1) {
return "Up" return this.$t("Up");
} }
if (this.status === 2) { if (this.status === 2) {
return "Pending" return this.$t("Pending");
} }
return "Unknown" return this.$t("Unknown");
}, },
}, },
} }

12
src/languages/en.js Normal file
View file

@ -0,0 +1,12 @@
export default {
languageName: "English",
checkEverySecond: "Check every {0} seconds.",
"Avg.": "Avg. ",
retriesDescription: "Maximum retries before the service is marked as down and a notification is sent",
ignoreTLSError: "Ignore TLS/SSL error for HTTPS websites",
upsideDownModeDescription: "Flip the status upside down. If the service is reachable, it is DOWN.",
maxRedirectDescription: "Maximum number of redirects to follow. Set to 0 to disable redirects.",
acceptedStatusCodesDescription: "Select status codes which are considered as a successful response.",
passwordNotMatchMsg: "The repeat password does not match.",
notificationDescription: "Please assign a notification to monitor(s) to get it to work.",
}

97
src/languages/zh-HK.js Normal file
View file

@ -0,0 +1,97 @@
export default {
languageName: "繁體中文 (香港)",
Settings: "設定",
Dashboard: "錶板",
"New Update": "有更新",
Language: "語言",
Appearance: "外觀",
Theme: "主題",
General: "一般",
Version: "版本",
"Check Update On GitHub": "到 Github 查看更新",
List: "列表",
Add: "新增",
"Add New Monitor": "新增監測器",
"Quick Stats": "綜合數據",
Up: "上線",
Down: "離線",
Pending: "待定",
Unknown: "不明",
Pause: "暫停",
Name: "名稱",
Status: "狀態",
DateTime: "日期時間",
Message: "內容",
"No important events": "沒有重要事件",
Resume: "恢復",
Edit: "編輯",
Delete: "刪除",
Current: "目前",
Uptime: "上線率",
"Cert Exp.": "証書期限",
days: "日",
day: "日",
"-day": "日",
hour: "小時",
"-hour": "小時",
checkEverySecond: "每 {0} 秒檢查一次",
"Avg.": "平均",
Response: "反應時間",
Ping: "反應時間",
"Monitor Type": "監測器類型",
Keyword: "關鍵字",
"Friendly Name": "名稱",
URL: "網址 URL",
Hostname: "Hostname",
Port: "Port",
"Heartbeat Interval": "檢查間距",
Retries: "重試數次確定為離線",
retriesDescription: "重試多少次後才判定為離線及傳送通知。如數值為 0 會即判定為離線及傳送通知。",
Advanced: "進階",
ignoreTLSError: "忽略 TLS/SSL 錯誤",
"Upside Down Mode": "反轉模式",
upsideDownModeDescription: "反轉狀態,如網址是可正常瀏覽,會被判定為 '離線/DOWN'",
"Max. Redirects": "跟隨重新導向 (Redirect) 的次數",
maxRedirectDescription: "設為 0 即不跟蹤",
"Accepted Status Codes": "接受為上線的 HTTP 狀態碼",
acceptedStatusCodesDescription: "可多選",
Save: "儲存",
Notifications: "通知",
"Not available, please setup.": "無法使用,需要設定",
"Setup Notification": "設定通知",
Light: "明亮",
Dark: "暗黑",
Auto: "自動",
"Theme - Heartbeat Bar": "監測器列表 狀態條外觀",
Normal: "一般",
Bottom: "下方",
None: "沒有",
Timezone: "時區",
"Search Engine Visibility": "是否允許搜尋器索引",
"Allow indexing": "允許索引",
"Discourage search engines from indexing site": "不建議搜尋器索引",
"Change Password": "變更密碼",
"Current Password": "目前密碼",
"New Password": "新密碼",
"Repeat New Password": "確認新密碼",
passwordNotMatchMsg: "密碼不一致",
"Update Password": "更新密碼",
"Disable Auth": "取消登入認証",
"Enable Auth": "開啟登入認証",
Logout: "登出",
notificationDescription: "新增後,你需要在監測器裡啟用。",
Leave: "離開",
"I understand, please disable": "我明白,請取消登入認証",
Confirm: "確認",
Yes: "是",
No: "否",
Username: "帳號",
Password: "密碼",
"Remember me": "記住我",
Login: "登入",
"No Monitors, please": "沒有監測器,請",
"add one": "新增",
"Notification Type": "通知類型",
"Email": "電郵",
"Test": "測試",
}

View file

@ -9,23 +9,23 @@
<!-- Desktop header --> <!-- Desktop header -->
<header v-if="! $root.isMobile" class="d-flex flex-wrap justify-content-center py-3 mb-3 border-bottom"> <header v-if="! $root.isMobile" class="d-flex flex-wrap justify-content-center py-3 mb-3 border-bottom">
<router-link to="/dashboard" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none"> <router-link to="/dashboard" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
<object class="bi me-2 ms-4" width="40" height="40" data="/icon.svg" alt="Logo" /> <object class="bi me-2 ms-4" width="40" height="40" data="/icon.svg" />
<span class="fs-4 title">Uptime Kuma</span> <span class="fs-4 title">Uptime Kuma</span>
</router-link> </router-link>
<a v-if="hasNewVersion" target="_blank" href="https://github.com/louislam/uptime-kuma/releases" class="btn btn-info me-3"> <a v-if="hasNewVersion" target="_blank" href="https://github.com/louislam/uptime-kuma/releases" class="btn btn-info me-3">
<font-awesome-icon icon="arrow-alt-circle-up" /> New Update <font-awesome-icon icon="arrow-alt-circle-up" /> {{ $t("New Update") }}
</a> </a>
<ul class="nav nav-pills"> <ul class="nav nav-pills">
<li class="nav-item"> <li class="nav-item">
<router-link to="/dashboard" class="nav-link"> <router-link to="/dashboard" class="nav-link">
<font-awesome-icon icon="tachometer-alt" /> Dashboard <font-awesome-icon icon="tachometer-alt" /> {{ $t("Dashboard") }}
</router-link> </router-link>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<router-link to="/settings" class="nav-link"> <router-link to="/settings" class="nav-link">
<font-awesome-icon icon="cog" /> Settings <font-awesome-icon icon="cog" /> {{ $t("Settings") }}
</router-link> </router-link>
</li> </li>
</ul> </ul>
@ -48,32 +48,32 @@
<footer> <footer>
<div class="container-fluid"> <div class="container-fluid">
Uptime Kuma - Uptime Kuma -
Version: {{ $root.info.version }} - {{ $t("Version") }}: {{ $root.info.version }} -
<a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">Check Update On GitHub</a> <a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">{{ $t("Check Update On GitHub") }}</a>
</div> </div>
</footer> </footer>
<!-- Mobile Only --> <!-- Mobile Only -->
<div v-if="$root.isMobile" style="width: 100%;height: 60px;" /> <div v-if="$root.isMobile" style="width: 100%; height: 60px;" />
<nav v-if="$root.isMobile" class="bottom-nav"> <nav v-if="$root.isMobile" class="bottom-nav">
<router-link to="/dashboard" class="nav-link"> <router-link to="/dashboard" class="nav-link">
<div><font-awesome-icon icon="tachometer-alt" /></div> <div><font-awesome-icon icon="tachometer-alt" /></div>
Dashboard {{ $t("Dashboard") }}
</router-link> </router-link>
<router-link to="/list" class="nav-link"> <router-link to="/list" class="nav-link">
<div><font-awesome-icon icon="list" /></div> <div><font-awesome-icon icon="list" /></div>
List {{ $t("List") }}
</router-link> </router-link>
<router-link to="/add" class="nav-link"> <router-link to="/add" class="nav-link">
<div><font-awesome-icon icon="plus" /></div> <div><font-awesome-icon icon="plus" /></div>
Add {{ $t("Add") }}
</router-link> </router-link>
<router-link to="/settings" class="nav-link"> <router-link to="/settings" class="nav-link">
<div><font-awesome-icon icon="cog" /></div> <div><font-awesome-icon icon="cog" /></div>
Settings {{ $t("Settings") }}
</router-link> </router-link>
</nav> </nav>
</div> </div>
@ -173,7 +173,7 @@ export default {
} }
main { main {
min-height: calc(100vh - 160px) min-height: calc(100vh - 160px);
} }
.title { .title {
@ -191,7 +191,7 @@ main {
} }
footer { footer {
color: #AAA; color: #aaa;
font-size: 13px; font-size: 13px;
margin-top: 10px; margin-top: 10px;
padding-bottom: 30px; padding-bottom: 30px;
@ -201,11 +201,11 @@ footer {
.dark { .dark {
header { header {
background-color: #161B22; background-color: #161b22;
border-bottom-color: #161B22 !important; border-bottom-color: #161b22 !important;
span { span {
color: #F0F6FC; color: #f0f6fc;
} }
} }

View file

@ -1,5 +1,6 @@
import "bootstrap"; import "bootstrap";
import { createApp, h } from "vue"; import { createApp, h } from "vue";
import { createI18n } from "vue-i18n"
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import Toast from "vue-toastification"; import Toast from "vue-toastification";
import "vue-toastification/dist/index.css"; import "vue-toastification/dist/index.css";
@ -22,6 +23,9 @@ import List from "./pages/List.vue";
import { appName } from "./util.ts"; import { appName } from "./util.ts";
import en from "./languages/en";
import zhHK from "./languages/zh-HK";
const routes = [ const routes = [
{ {
path: "/", path: "/",
@ -83,6 +87,19 @@ const router = createRouter({
routes, routes,
}) })
const languageList = {
en,
"zh-HK": zhHK,
};
const i18n = createI18n({
locale: localStorage.locale || "en",
fallbackLocale: "en",
silentFallbackWarn: true,
silentTranslationWarn: true,
messages: languageList
});
const app = createApp({ const app = createApp({
mixins: [ mixins: [
socket, socket,
@ -98,7 +115,8 @@ const app = createApp({
render: () => h(App), render: () => h(App),
}) })
app.use(router) app.use(router);
app.use(i18n);
const options = { const options = {
position: "bottom-right", position: "bottom-right",

View file

@ -3,7 +3,7 @@
<div class="row"> <div class="row">
<div v-if="! $root.isMobile" class="col-12 col-md-5 col-xl-4"> <div v-if="! $root.isMobile" class="col-12 col-md-5 col-xl-4">
<div> <div>
<router-link to="/add" class="btn btn-primary mb-3"><font-awesome-icon icon="plus" /> Add New Monitor</router-link> <router-link to="/add" class="btn btn-primary mb-3"><font-awesome-icon icon="plus" /> {{ $t("Add New Monitor") }}</router-link>
</div> </div>
<MonitorList /> <MonitorList />
</div> </div>
@ -32,6 +32,6 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.container-fluid { .container-fluid {
width: 98% width: 98%;
} }
</style> </style>

View file

@ -2,63 +2,51 @@
<transition name="slide-fade" appear> <transition name="slide-fade" appear>
<div v-if="$route.name === 'DashboardHome'"> <div v-if="$route.name === 'DashboardHome'">
<h1 class="mb-3"> <h1 class="mb-3">
Quick Stats {{ $t("Quick Stats") }}
</h1> </h1>
<div class="shadow-box big-padding text-center"> <div class="shadow-box big-padding text-center">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3>Up</h3> <h3>{{ $t("Up") }}</h3>
<span class="num">{{ stats.up }}</span> <span class="num">{{ stats.up }}</span>
</div> </div>
<div class="col"> <div class="col">
<h3>Down</h3> <h3>{{ $t("Down") }}</h3>
<span class="num text-danger">{{ stats.down }}</span> <span class="num text-danger">{{ stats.down }}</span>
</div> </div>
<div class="col"> <div class="col">
<h3>Unknown</h3> <h3>{{ $t("Unknown") }}</h3>
<span class="num text-secondary">{{ stats.unknown }}</span> <span class="num text-secondary">{{ stats.unknown }}</span>
</div> </div>
<div class="col"> <div class="col">
<h3>Pause</h3> <h3>{{ $t("Pause") }}</h3>
<span class="num text-secondary">{{ stats.pause }}</span> <span class="num text-secondary">{{ stats.pause }}</span>
</div> </div>
</div> </div>
<div v-if="false" class="row">
<div class="col-3">
<h3>Uptime</h3>
<p>(24-hour)</p>
<span class="num" />
</div>
<div class="col-3">
<h3>Uptime</h3>
<p>(30-day)</p>
<span class="num" />
</div>
</div>
</div> </div>
<div class="shadow-box" style="margin-top: 25px;overflow-x: scroll"> <div class="shadow-box table-shadow-box" style="overflow-x: scroll;">
<table class="table table-borderless table-hover"> <table class="table table-borderless table-hover">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>{{ $t("Name") }}</th>
<th>Status</th> <th>{{ $t("Status") }}</th>
<th>DateTime</th> <th>{{ $t("DateTime") }}</th>
<th>Message</th> <th>{{ $t("Message") }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(beat, index) in displayedRecords" :key="index"> <tr v-for="(beat, index) in displayedRecords" :key="index" :class="{ 'shadow-box': $root.windowWidth <= 550}">
<td>{{ beat.name }}</td> <td>{{ beat.name }}</td>
<td><Status :status="beat.status" /></td> <td><Status :status="beat.status" /></td>
<td><Datetime :value="beat.time" /></td> <td :class="{ 'border-0':! beat.msg}"><Datetime :value="beat.time" /></td>
<td>{{ beat.msg }}</td> <td class="border-0">{{ beat.msg }}</td>
</tr> </tr>
<tr v-if="importantHeartBeatList.length === 0"> <tr v-if="importantHeartBeatList.length === 0">
<td colspan="4"> <td colspan="4">
No important events {{ $t("No important events") }}
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -182,6 +170,7 @@ export default {
.shadow-box { .shadow-box {
padding: 20px; padding: 20px;
margin-top: 25px;
} }
table { table {

View file

@ -1,6 +1,6 @@
<template> <template>
<transition name="slide-fade" appear> <transition name="slide-fade" appear>
<div> <div v-if="monitor">
<h1> {{ monitor.name }}</h1> <h1> {{ monitor.name }}</h1>
<p class="url"> <p class="url">
<a v-if="monitor.type === 'http' || monitor.type === 'keyword' " :href="monitor.url" target="_blank">{{ monitor.url }}</a> <a v-if="monitor.type === 'http' || monitor.type === 'keyword' " :href="monitor.url" target="_blank">{{ monitor.url }}</a>
@ -14,16 +14,16 @@
<div class="functions"> <div class="functions">
<button v-if="monitor.active" class="btn btn-light" @click="pauseDialog"> <button v-if="monitor.active" class="btn btn-light" @click="pauseDialog">
<font-awesome-icon icon="pause" /> Pause <font-awesome-icon icon="pause" /> {{ $t("Pause") }}
</button> </button>
<button v-if="! monitor.active" class="btn btn-primary" @click="resumeMonitor"> <button v-if="! monitor.active" class="btn btn-primary" @click="resumeMonitor">
<font-awesome-icon icon="play" /> Resume <font-awesome-icon icon="play" /> {{ $t("Resume") }}
</button> </button>
<router-link :to=" '/edit/' + monitor.id " class="btn btn-secondary"> <router-link :to=" '/edit/' + monitor.id " class="btn btn-secondary">
<font-awesome-icon icon="edit" /> Edit <font-awesome-icon icon="edit" /> {{ $t("Edit") }}
</router-link> </router-link>
<button class="btn btn-danger" @click="deleteDialog"> <button class="btn btn-danger" @click="deleteDialog">
<font-awesome-icon icon="trash" /> Delete <font-awesome-icon icon="trash" /> {{ $t("Delete") }}
</button> </button>
</div> </div>
@ -31,10 +31,10 @@
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-8">
<HeartbeatBar :monitor-id="monitor.id" /> <HeartbeatBar :monitor-id="monitor.id" />
<span class="word">Check every {{ monitor.interval }} seconds.</span> <span class="word">{{ $t("checkEverySecond", [ monitor.interval ]) }}</span>
</div> </div>
<div class="col-md-4 text-center"> <div class="col-md-4 text-center">
<span class="badge rounded-pill" :class=" 'bg-' + status.color " style="font-size: 30px">{{ status.text }}</span> <span class="badge rounded-pill" :class=" 'bg-' + status.color " style="font-size: 30px;">{{ status.text }}</span>
</div> </div>
</div> </div>
</div> </div>
@ -43,7 +43,7 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h4>{{ pingTitle }}</h4> <h4>{{ pingTitle }}</h4>
<p>(Current)</p> <p>({{ $t("Current") }})</p>
<span class="num"> <span class="num">
<a href="#" @click.prevent="showPingChartBox = !showPingChartBox"> <a href="#" @click.prevent="showPingChartBox = !showPingChartBox">
<CountUp :value="ping" /> <CountUp :value="ping" />
@ -51,26 +51,26 @@
</span> </span>
</div> </div>
<div class="col"> <div class="col">
<h4>Avg. {{ pingTitle }}</h4> <h4>{{ $t("Avg.") }}{{ pingTitle }}</h4>
<p>(24-hour)</p> <p>(24{{ $t("-hour") }})</p>
<span class="num"><CountUp :value="avgPing" /></span> <span class="num"><CountUp :value="avgPing" /></span>
</div> </div>
<div class="col"> <div class="col">
<h4>Uptime</h4> <h4>{{ $t("Uptime") }}</h4>
<p>(24-hour)</p> <p>(24{{ $t("-hour") }})</p>
<span class="num"><Uptime :monitor="monitor" type="24" /></span> <span class="num"><Uptime :monitor="monitor" type="24" /></span>
</div> </div>
<div class="col"> <div class="col">
<h4>Uptime</h4> <h4>{{ $t("Uptime") }}</h4>
<p>(30-day)</p> <p>(30{{ $t("-day") }})</p>
<span class="num"><Uptime :monitor="monitor" type="720" /></span> <span class="num"><Uptime :monitor="monitor" type="720" /></span>
</div> </div>
<div v-if="certInfo" class="col"> <div v-if="certInfo" class="col">
<h4>Cert Exp.</h4> <h4>{{ $t("Cert Exp.") }}</h4>
<p>(<Datetime :value="certInfo.validTo" date-only />)</p> <p>(<Datetime :value="certInfo.validTo" date-only />)</p>
<span class="num"> <span class="num">
<a href="#" @click.prevent="toggleCertInfoBox = !toggleCertInfoBox">{{ certInfo.daysRemaining }} days</a> <a href="#" @click.prevent="toggleCertInfoBox = !toggleCertInfoBox">{{ certInfo.daysRemaining }} {{ $t("days") }}</a>
</span> </span>
</div> </div>
</div> </div>
@ -128,25 +128,25 @@
</div> </div>
</div> </div>
<div class="shadow-box"> <div class="shadow-box table-shadow-box">
<table class="table table-borderless table-hover"> <table class="table table-borderless table-hover">
<thead> <thead>
<tr> <tr>
<th>Status</th> <th>{{ $t("Status") }}</th>
<th>DateTime</th> <th>{{ $t("DateTime") }}</th>
<th>Message</th> <th>{{ $t("Message") }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(beat, index) in displayedRecords" :key="index"> <tr v-for="(beat, index) in displayedRecords" :key="index" :class="{ 'shadow-box': $root.windowWidth <= 550}" style="padding: 10px;">
<td><Status :status="beat.status" /></td> <td><Status :status="beat.status" /></td>
<td><Datetime :value="beat.time" /></td> <td :class="{ 'border-0':! beat.msg}"><Datetime :value="beat.time" /></td>
<td>{{ beat.msg }}</td> <td class="border-0">{{ beat.msg }}</td>
</tr> </tr>
<tr v-if="importantHeartBeatList.length === 0"> <tr v-if="importantHeartBeatList.length === 0">
<td colspan="3"> <td colspan="3">
No important events {{ $t("No important events") }}
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -209,10 +209,9 @@ export default {
pingTitle() { pingTitle() {
if (this.monitor.type === "http") { if (this.monitor.type === "http") {
return "Response" return this.$t("Response");
} }
return this.$t("Ping");
return "Ping"
}, },
monitor() { monitor() {
@ -385,7 +384,7 @@ export default {
} }
.word { .word {
color: #AAA; color: #aaa;
font-size: 14px; font-size: 14px;
} }
@ -399,7 +398,7 @@ table {
.stats p { .stats p {
font-size: 13px; font-size: 13px;
color: #AAA; color: #aaa;
} }
.stats { .stats {
@ -414,7 +413,7 @@ table {
color: black; color: black;
} }
.dark { .dark {
.keyword { .keyword {
color: $dark-font-color; color: $dark-font-color;
} }

View file

@ -6,10 +6,10 @@
<div class="shadow-box"> <div class="shadow-box">
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<h2 class="mb-2">General</h2> <h2 class="mb-2">{{ $t("General") }}</h2>
<div class="my-3"> <div class="my-3">
<label for="type" class="form-label">Monitor Type</label> <label for="type" class="form-label">{{ $t("Monitor Type") }}</label>
<select id="type" v-model="monitor.type" class="form-select" aria-label="Default select example"> <select id="type" v-model="monitor.type" class="form-select" aria-label="Default select example">
<option value="http"> <option value="http">
HTTP(s) HTTP(s)
@ -21,7 +21,7 @@
Ping Ping
</option> </option>
<option value="keyword"> <option value="keyword">
HTTP(s) - Keyword HTTP(s) - {{ $t("Keyword") }}
</option> </option>
<option value="dns"> <option value="dns">
DNS DNS
@ -30,17 +30,17 @@
</div> </div>
<div class="my-3"> <div class="my-3">
<label for="name" class="form-label">Friendly Name</label> <label for="name" class="form-label">{{ $t("Friendly Name") }}</label>
<input id="name" v-model="monitor.name" type="text" class="form-control" required> <input id="name" v-model="monitor.name" type="text" class="form-control" required>
</div> </div>
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3"> <div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3">
<label for="url" class="form-label">URL</label> <label for="url" class="form-label">{{ $t("URL") }}</label>
<input id="url" v-model="monitor.url" type="url" class="form-control" pattern="https?://.+" required> <input id="url" v-model="monitor.url" type="url" class="form-control" pattern="https?://.+" required>
</div> </div>
<div v-if="monitor.type === 'keyword' " class="my-3"> <div v-if="monitor.type === 'keyword' " class="my-3">
<label for="keyword" class="form-label">Keyword</label> <label for="keyword" class="form-label">{{ $t("Keyword") }}</label>
<input id="keyword" v-model="monitor.keyword" type="text" class="form-control" required> <input id="keyword" v-model="monitor.keyword" type="text" class="form-control" required>
<div class="form-text"> <div class="form-text">
Search keyword in plain html or JSON response and it is case-sensitive Search keyword in plain html or JSON response and it is case-sensitive
@ -48,12 +48,12 @@
</div> </div>
<div v-if="monitor.type === 'port' || monitor.type === 'ping' " class="my-3"> <div v-if="monitor.type === 'port' || monitor.type === 'ping' " class="my-3">
<label for="hostname" class="form-label">Hostname</label> <label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
<input id="hostname" v-model="monitor.hostname" type="text" class="form-control" required> <input id="hostname" v-model="monitor.hostname" type="text" class="form-control" required>
</div> </div>
<div v-if="monitor.type === 'port' " class="my-3"> <div v-if="monitor.type === 'port' " class="my-3">
<label for="port" class="form-label">Port</label> <label for="port" class="form-label">{{ $t("Port") }}</label>
<input id="port" v-model="monitor.port" type="number" class="form-control" required min="0" max="65535" step="1"> <input id="port" v-model="monitor.port" type="number" class="form-control" required min="0" max="65535" step="1">
</div> </div>
@ -93,47 +93,47 @@
</div> </div>
<div class="my-3"> <div class="my-3">
<label for="interval" class="form-label">Heartbeat Interval (Every {{ monitor.interval }} seconds)</label> <label for="interval" class="form-label">{{ $t("Heartbeat Interval") }} ({{ $t("checkEverySecond", [ monitor.interval ]) }})</label>
<input id="interval" v-model="monitor.interval" type="number" class="form-control" required min="20" step="1"> <input id="interval" v-model="monitor.interval" type="number" class="form-control" required min="20" step="1">
</div> </div>
<div class="my-3"> <div class="my-3">
<label for="maxRetries" class="form-label">Retries</label> <label for="maxRetries" class="form-label">{{ $t("Retries") }}</label>
<input id="maxRetries" v-model="monitor.maxretries" type="number" class="form-control" required min="0" step="1"> <input id="maxRetries" v-model="monitor.maxretries" type="number" class="form-control" required min="0" step="1">
<div class="form-text"> <div class="form-text">
Maximum retries before the service is marked as down and a notification is sent {{ $t("retriesDescription") }}
</div> </div>
</div> </div>
<h2 class="mt-5 mb-2">Advanced</h2> <h2 class="mt-5 mb-2">{{ $t("Advanced") }}</h2>
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3 form-check"> <div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3 form-check">
<input id="ignore-tls" v-model="monitor.ignoreTls" class="form-check-input" type="checkbox" value=""> <input id="ignore-tls" v-model="monitor.ignoreTls" class="form-check-input" type="checkbox" value="">
<label class="form-check-label" for="ignore-tls"> <label class="form-check-label" for="ignore-tls">
Ignore TLS/SSL error for HTTPS websites {{ $t("ignoreTLSError") }}
</label> </label>
</div> </div>
<div class="my-3 form-check"> <div class="my-3 form-check">
<input id="upside-down" v-model="monitor.upsideDown" class="form-check-input" type="checkbox"> <input id="upside-down" v-model="monitor.upsideDown" class="form-check-input" type="checkbox">
<label class="form-check-label" for="upside-down"> <label class="form-check-label" for="upside-down">
Upside Down Mode {{ $t("Upside Down Mode") }}
</label> </label>
<div class="form-text"> <div class="form-text">
Flip the status upside down. If the service is reachable, it is DOWN. {{ $t("upsideDownModeDescription") }}
</div> </div>
</div> </div>
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3"> <div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3">
<label for="maxRedirects" class="form-label">Max. Redirects</label> <label for="maxRedirects" class="form-label">{{ $t("Max. Redirects") }}</label>
<input id="maxRedirects" v-model="monitor.maxredirects" type="number" class="form-control" required min="0" step="1"> <input id="maxRedirects" v-model="monitor.maxredirects" type="number" class="form-control" required min="0" step="1">
<div class="form-text"> <div class="form-text">
Maximum number of redirects to follow. Set to 0 to disable redirects. {{ $t("maxRedirectDescription") }}
</div> </div>
</div> </div>
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3"> <div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3">
<label for="acceptedStatusCodes" class="form-label">Accepted Status Codes</label> <label for="acceptedStatusCodes" class="form-label">{{ $t("Accepted Status Codes") }}</label>
<VueMultiselect <VueMultiselect
id="acceptedStatusCodes" id="acceptedStatusCodes"
@ -150,21 +150,21 @@
></VueMultiselect> ></VueMultiselect>
<div class="form-text"> <div class="form-text">
Select status codes which are considered as a successful response. {{ $t("acceptedStatusCodesDescription") }}
</div> </div>
</div> </div>
<div class="mt-5 mb-1"> <div class="mt-5 mb-1">
<button class="btn btn-primary" type="submit" :disabled="processing">Save</button> <button class="btn btn-primary" type="submit" :disabled="processing">{{ $t("Save") }}</button>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div v-if="$root.isMobile" class="mt-3" /> <div v-if="$root.isMobile" class="mt-3" />
<h2 class="mb-2">Notifications</h2> <h2 class="mb-2">{{ $t("Notifications") }}</h2>
<p v-if="$root.notificationList.length === 0"> <p v-if="$root.notificationList.length === 0">
Not available, please setup. {{ $t("Not available, please setup.") }}
</p> </p>
<div v-for="notification in $root.notificationList" :key="notification.id" class="form-check form-switch my-3"> <div v-for="notification in $root.notificationList" :key="notification.id" class="form-check form-switch my-3">
@ -172,12 +172,12 @@
<label class="form-check-label" :for=" 'notification' + notification.id"> <label class="form-check-label" :for=" 'notification' + notification.id">
{{ notification.name }} {{ notification.name }}
<a href="#" @click="$refs.notificationDialog.show(notification.id)">Edit</a> <a href="#" @click="$refs.notificationDialog.show(notification.id)">{{ $t("Edit") }}</a>
</label> </label>
</div> </div>
<button class="btn btn-primary me-2" type="button" @click="$refs.notificationDialog.show()"> <button class="btn btn-primary me-2" type="button" @click="$refs.notificationDialog.show()">
Setup Notification {{ $t("Setup Notification") }}
</button> </button>
</div> </div>
</div> </div>
@ -217,7 +217,7 @@ export default {
computed: { computed: {
pageName() { pageName() {
return (this.isAdd) ? "Add New Monitor" : "Edit" return this.$t((this.isAdd) ? "Add New Monitor" : "Edit");
}, },
isAdd() { isAdd() {
return this.$route.path === "/add"; return this.$route.path === "/add";

View file

@ -2,53 +2,63 @@
<transition name="slide-fade" appear> <transition name="slide-fade" appear>
<div> <div>
<h1 v-show="show" class="mb-3"> <h1 v-show="show" class="mb-3">
Settings {{ $t("Settings") }}
</h1> </h1>
<div class="shadow-box"> <div class="shadow-box">
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<h2 class="mb-2">General</h2> <h2 class="mb-2">{{ $t("Appearance") }}</h2>
<div class="mb-3">
<label for="language" class="form-label">{{ $t("Language") }}</label>
<select id="language" v-model="$i18n.locale" class="form-select">
<option v-for="(lang, i) in $i18n.availableLocales" :key="`Lang${i}`" :value="lang">
{{ $i18n.messages[lang].languageName }}
</option>
</select>
</div>
<div class="mb-3">
<label for="timezone" class="form-label">{{ $t("Theme") }}</label>
<div>
<div class="btn-group" role="group" aria-label="Basic checkbox toggle button group">
<input id="btncheck1" v-model="$root.userTheme" type="radio" class="btn-check" name="theme" autocomplete="off" value="light">
<label class="btn btn-outline-primary" for="btncheck1">{{ $t("Light") }}</label>
<input id="btncheck2" v-model="$root.userTheme" type="radio" class="btn-check" name="theme" autocomplete="off" value="dark">
<label class="btn btn-outline-primary" for="btncheck2">{{ $t("Dark") }}</label>
<input id="btncheck3" v-model="$root.userTheme" type="radio" class="btn-check" name="theme" autocomplete="off" value="auto">
<label class="btn btn-outline-primary" for="btncheck3">{{ $t("Auto") }}</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ $t("Theme - Heartbeat Bar") }}</label>
<div>
<div class="btn-group" role="group" aria-label="Basic checkbox toggle button group">
<input id="btncheck4" v-model="$root.userHeartbeatBar" type="radio" class="btn-check" name="heartbeatBarTheme" autocomplete="off" value="normal">
<label class="btn btn-outline-primary" for="btncheck4">{{ $t("Normal") }}</label>
<input id="btncheck5" v-model="$root.userHeartbeatBar" type="radio" class="btn-check" name="heartbeatBarTheme" autocomplete="off" value="bottom">
<label class="btn btn-outline-primary" for="btncheck5">{{ $t("Bottom") }}</label>
<input id="btncheck6" v-model="$root.userHeartbeatBar" type="radio" class="btn-check" name="heartbeatBarTheme" autocomplete="off" value="none">
<label class="btn btn-outline-primary" for="btncheck6">{{ $t("None") }}</label>
</div>
</div>
</div>
<h2 class="mt-5 mb-2">{{ $t("General") }}</h2>
<form class="mb-3" @submit.prevent="saveGeneral"> <form class="mb-3" @submit.prevent="saveGeneral">
<div class="mb-3"> <div class="mb-3">
<label for="timezone" class="form-label">Theme</label> <label for="timezone" class="form-label">{{ $t("Timezone") }}</label>
<div>
<div class="btn-group" role="group" aria-label="Basic checkbox toggle button group">
<input id="btncheck1" v-model="$root.userTheme" type="radio" class="btn-check" name="theme" autocomplete="off" value="light">
<label class="btn btn-outline-primary" for="btncheck1">Light</label>
<input id="btncheck2" v-model="$root.userTheme" type="radio" class="btn-check" name="theme" autocomplete="off" value="dark">
<label class="btn btn-outline-primary" for="btncheck2">Dark</label>
<input id="btncheck3" v-model="$root.userTheme" type="radio" class="btn-check" name="theme" autocomplete="off" value="auto">
<label class="btn btn-outline-primary" for="btncheck3">Auto</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Theme - Heartbeat Bar</label>
<div>
<div class="btn-group" role="group" aria-label="Basic checkbox toggle button group">
<input id="btncheck4" v-model="$root.userHeartbeatBar" type="radio" class="btn-check" name="heartbeatBarTheme" autocomplete="off" value="normal">
<label class="btn btn-outline-primary" for="btncheck4">Normal</label>
<input id="btncheck5" v-model="$root.userHeartbeatBar" type="radio" class="btn-check" name="heartbeatBarTheme" autocomplete="off" value="bottom">
<label class="btn btn-outline-primary" for="btncheck5">Bottom</label>
<input id="btncheck6" v-model="$root.userHeartbeatBar" type="radio" class="btn-check" name="heartbeatBarTheme" autocomplete="off" value="none">
<label class="btn btn-outline-primary" for="btncheck6">None</label>
</div>
</div>
</div>
<div class="mb-3">
<label for="timezone" class="form-label">Timezone</label>
<select id="timezone" v-model="$root.userTimezone" class="form-select"> <select id="timezone" v-model="$root.userTimezone" class="form-select">
<option value="auto"> <option value="auto">
Auto: {{ guessTimezone }} {{ $t("Auto") }}: {{ guessTimezone }}
</option> </option>
<option v-for="(timezone, index) in timezoneList" :key="index" :value="timezone.value"> <option v-for="(timezone, index) in timezoneList" :key="index" :value="timezone.value">
{{ timezone.name }} {{ timezone.name }}
@ -57,65 +67,65 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Search Engine Visibility</label> <label class="form-label">{{ $t("Search Engine Visibility") }}</label>
<div class="form-check"> <div class="form-check">
<input id="searchEngineIndexYes" v-model="settings.searchEngineIndex" class="form-check-input" type="radio" name="flexRadioDefault" :value="true" required> <input id="searchEngineIndexYes" v-model="settings.searchEngineIndex" class="form-check-input" type="radio" name="flexRadioDefault" :value="true" required>
<label class="form-check-label" for="searchEngineIndexYes"> <label class="form-check-label" for="searchEngineIndexYes">
Allow indexing {{ $t("Allow indexing") }}
</label> </label>
</div> </div>
<div class="form-check"> <div class="form-check">
<input id="searchEngineIndexNo" v-model="settings.searchEngineIndex" class="form-check-input" type="radio" name="flexRadioDefault" :value="false" required> <input id="searchEngineIndexNo" v-model="settings.searchEngineIndex" class="form-check-input" type="radio" name="flexRadioDefault" :value="false" required>
<label class="form-check-label" for="searchEngineIndexNo"> <label class="form-check-label" for="searchEngineIndexNo">
Discourage search engines from indexing site {{ $t("Discourage search engines from indexing site") }}
</label> </label>
</div> </div>
</div> </div>
<div> <div>
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit">
Save {{ $t("Save") }}
</button> </button>
</div> </div>
</form> </form>
<template v-if="loaded"> <template v-if="loaded">
<template v-if="! settings.disableAuth"> <template v-if="! settings.disableAuth">
<h2 class="mt-5 mb-2">Change Password</h2> <h2 class="mt-5 mb-2">{{ $t("Change Password") }}</h2>
<form class="mb-3" @submit.prevent="savePassword"> <form class="mb-3" @submit.prevent="savePassword">
<div class="mb-3"> <div class="mb-3">
<label for="current-password" class="form-label">Current Password</label> <label for="current-password" class="form-label">{{ $t("Current Password") }}</label>
<input id="current-password" v-model="password.currentPassword" type="password" class="form-control" required> <input id="current-password" v-model="password.currentPassword" type="password" class="form-control" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="new-password" class="form-label">New Password</label> <label for="new-password" class="form-label">{{ $t("New Password") }}</label>
<input id="new-password" v-model="password.newPassword" type="password" class="form-control" required> <input id="new-password" v-model="password.newPassword" type="password" class="form-control" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="repeat-new-password" class="form-label">Repeat New Password</label> <label for="repeat-new-password" class="form-label">{{ $t("Repeat New Password") }}</label>
<input id="repeat-new-password" v-model="password.repeatNewPassword" type="password" class="form-control" :class="{ 'is-invalid' : invalidPassword }" required> <input id="repeat-new-password" v-model="password.repeatNewPassword" type="password" class="form-control" :class="{ 'is-invalid' : invalidPassword }" required>
<div class="invalid-feedback"> <div class="invalid-feedback">
The repeat password does not match. {{ $t("passwordNotMatchMsg") }}
</div> </div>
</div> </div>
<div> <div>
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit">
Update Password {{ $t("Update Password") }}
</button> </button>
</div> </div>
</form> </form>
</template> </template>
<h2 class="mt-5 mb-2">Advanced</h2> <h2 class="mt-5 mb-2">{{ $t("Advanced") }}</h2>
<div class="mb-3"> <div class="mb-3">
<button v-if="settings.disableAuth" class="btn btn-outline-primary me-1" @click="enableAuth">Enable Auth</button> <button v-if="settings.disableAuth" class="btn btn-outline-primary me-1" @click="enableAuth">{{ $t("Enable Auth") }}</button>
<button v-if="! settings.disableAuth" class="btn btn-primary me-1" @click="confirmDisableAuth">Disable Auth</button> <button v-if="! settings.disableAuth" class="btn btn-primary me-1" @click="confirmDisableAuth">{{ $t("Disable Auth") }}</button>
<button v-if="! settings.disableAuth" class="btn btn-danger me-1" @click="$root.logout">Logout</button> <button v-if="! settings.disableAuth" class="btn btn-danger me-1" @click="$root.logout">{{ $t("Logout") }}</button>
</div> </div>
</template> </template>
</div> </div>
@ -123,23 +133,23 @@
<div class="notification-list col-md-6"> <div class="notification-list col-md-6">
<div v-if="$root.isMobile" class="mt-3" /> <div v-if="$root.isMobile" class="mt-3" />
<h2>Notifications</h2> <h2>{{ $t("Notifications") }}</h2>
<p v-if="$root.notificationList.length === 0"> <p v-if="$root.notificationList.length === 0">
Not available, please setup. {{ $t("Not available, please setup.") }}
</p> </p>
<p v-else> <p v-else>
Please assign a notification to monitor(s) to get it to work. {{ $t("notificationDescription") }}
</p> </p>
<ul class="list-group mb-3" style="border-radius: 1rem;"> <ul class="list-group mb-3" style="border-radius: 1rem;">
<li v-for="(notification, index) in $root.notificationList" :key="index" class="list-group-item"> <li v-for="(notification, index) in $root.notificationList" :key="index" class="list-group-item">
{{ notification.name }}<br> {{ notification.name }}<br>
<a href="#" @click="$refs.notificationDialog.show(notification.id)">Edit</a> <a href="#" @click="$refs.notificationDialog.show(notification.id)">{{ $t("Edit") }}</a>
</li> </li>
</ul> </ul>
<button class="btn btn-primary me-2" type="button" @click="$refs.notificationDialog.show()"> <button class="btn btn-primary me-2" type="button" @click="$refs.notificationDialog.show()">
Setup Notification {{ $t("Setup Notification") }}
</button> </button>
</div> </div>
</div> </div>
@ -147,10 +157,18 @@
<NotificationDialog ref="notificationDialog" /> <NotificationDialog ref="notificationDialog" />
<Confirm ref="confirmDisableAuth" btn-style="btn-danger" yes-text="I understand, please disable" no-text="Leave" @yes="disableAuth"> <Confirm ref="confirmDisableAuth" btn-style="btn-danger" :yes-text="$t('I understand, please disable')" :no-text="$t('Leave')" @yes="disableAuth">
<p>Are you sure want to <strong>disable auth</strong>?</p> <template v-if="$i18n.locale === 'en' ">
<p>It is for <strong>someone who have 3rd-party auth</strong> in front of Uptime Kuma such as Cloudflare Access.</p> <p>Are you sure want to <strong>disable auth</strong>?</p>
<p>Please use it carefully.</p> <p>It is for <strong>someone who have 3rd-party auth</strong> in front of Uptime Kuma such as Cloudflare Access.</p>
<p>Please use it carefully.</p>
</template>
<template v-if="$i18n.locale === 'zh-HK' ">
<p>你是否確認<strong>取消登入認証</strong></p>
<p>這個功能是設計給已有<strong>第三方認証</strong>的用家例如 Cloudflare Access</p>
<p>請小心使用</p>
</template>
</Confirm> </Confirm>
</div> </div>
</transition> </transition>
@ -195,6 +213,10 @@ export default {
"password.repeatNewPassword"() { "password.repeatNewPassword"() {
this.invalidPassword = false; this.invalidPassword = false;
}, },
"$i18n.locale"() {
localStorage.locale = this.$i18n.locale;
},
}, },
mounted() { mounted() {

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// Common Util for frontend and backend // Common Util for frontend and backend
// Backend uses the compiled file util.js // Backend uses the compiled file util.js
// Frontend uses util.ts // Frontend uses util.ts
@ -13,7 +12,7 @@ export const DOWN = 0;
export const UP = 1; export const UP = 1;
export const PENDING = 2; export const PENDING = 2;
export function flipStatus(s) { export function flipStatus(s: number) {
if (s === UP) { if (s === UP) {
return DOWN; return DOWN;
} }
@ -25,7 +24,7 @@ export function flipStatus(s) {
return s; return s;
} }
export function sleep(ms) { export function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
@ -33,8 +32,8 @@ export function sleep(ms) {
* PHP's ucfirst * PHP's ucfirst
* @param str * @param str
*/ */
export function ucfirst(str) { export function ucfirst(str: string) {
if (! str) { if (!str) {
return str; return str;
} }
@ -42,12 +41,15 @@ export function ucfirst(str) {
return firstLetter.toUpperCase() + str.substr(1); return firstLetter.toUpperCase() + str.substr(1);
} }
export function debug(msg) { export function debug(msg: any) {
if (isDev) { if (isDev) {
console.log(msg); console.log(msg);
} }
} }
declare global { interface String { replaceAll(str: string, newStr: string): string; } }
export function polyfill() { export function polyfill() {
/** /**
* String.prototype.replaceAll() polyfill * String.prototype.replaceAll() polyfill
@ -56,7 +58,7 @@ export function polyfill() {
* @license MIT * @license MIT
*/ */
if (!String.prototype.replaceAll) { if (!String.prototype.replaceAll) {
String.prototype.replaceAll = function (str, newStr) { String.prototype.replaceAll = function (str: string, newStr: string) {
// If a regex pattern // If a regex pattern
if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") { if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") {
@ -71,11 +73,13 @@ export function polyfill() {
} }
export class TimeLogger { export class TimeLogger {
startTime: number;
constructor() { constructor() {
this.startTime = dayjs().valueOf(); this.startTime = dayjs().valueOf();
} }
print(name) { print(name: string) {
if (isDev) { if (isDev) {
console.log(name + ": " + (dayjs().valueOf() - this.startTime) + "ms") console.log(name + ": " + (dayjs().valueOf() - this.startTime) + "ms")
} }
@ -85,7 +89,7 @@ export class TimeLogger {
/** /**
* Returns a random number between min (inclusive) and max (exclusive) * Returns a random number between min (inclusive) and max (exclusive)
*/ */
export function getRandomArbitrary(min, max) { export function getRandomArbitrary(min: number, max: number) {
return Math.random() * (max - min) + min; return Math.random() * (max - min) + min;
} }
@ -98,7 +102,7 @@ export function getRandomArbitrary(min, max) {
* lower than max if max isn't an integer). * lower than max if max isn't an integer).
* Using Math.round() will give you a non-uniform distribution! * Using Math.round() will give you a non-uniform distribution!
*/ */
export function getRandomInt(min, max) { export function getRandomInt(min: number, max: number) {
min = Math.ceil(min); min = Math.ceil(min);
max = Math.floor(max); max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min; return Math.floor(Math.random() * (max - min + 1)) + min;

View file

@ -4,14 +4,15 @@
"target": "es2018", "target": "es2018",
"module": "commonjs", "module": "commonjs",
"lib": [ "lib": [
"es2020" "es2020",
"DOM",
], ],
"removeComments": true, "removeComments": true,
"preserveConstEnums": true, "preserveConstEnums": true,
"sourceMap": false, "sourceMap": false,
"files.insertFinalNewline": true "strict": true
}, },
"files": [ "files": [
"./server/util.ts" "./src/util.ts"
] ]
} }