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,
},
rules: {
"camelcase": ["warn", {
"properties": "never"
}],
// override/add rules settings here, such as:
// 'vue/no-unused-vars': 'error'
"no-unused-vars": "warn",
@ -74,5 +77,6 @@ module.exports = {
"no-empty": ["error", {
"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.
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
@ -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.
- 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 (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)
# Prepare the dev
# Install dependencies
```bash
npm install
npm install --dev
```
# Backend Dev
@ -39,7 +81,6 @@ npm run start-server
# Or
node server/server.js
```
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
TODO
1. create `patch{num}.sql` in `./db/`
1. update `latestVersion` in `./server/database.js`
# Unit Test

View File

@ -2,10 +2,10 @@
FROM node:14-alpine3.12 AS release
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 && \
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 && \
rm -f /usr/bin/python

4491
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,9 @@
"node": "14.*"
},
"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",
"start": "npm run start-server",
"start-server": "node server/server.js",
@ -19,7 +22,7 @@
"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-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",
"update-version": "node extra/update-version.js",
"mark-as-nightly": "node extra/mark-as-nightly.js",
@ -36,11 +39,11 @@
"@fortawesome/free-regular-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/vue-fontawesome": "^3.0.0-4",
"@louislam/sqlite3": "^5.0.3",
"@popperjs/core": "^2.9.3",
"args-parser": "^1.3.0",
"axios": "^0.21.1",
"bcrypt": "^5.0.1",
"better-sqlite3": "^7.4.3",
"bootstrap": "^5.1.0",
"chart.js": "^3.5.0",
"chartjs-adapter-dayjs": "^1.0.0",
@ -56,7 +59,7 @@
"password-hash": "^1.2.2",
"prom-client": "^13.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-client": "^4.1.3",
"tcp-ping": "^0.1.1",
@ -64,6 +67,7 @@
"vue": "^3.2.2",
"vue-chart-3": "^0.5.7",
"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"
@ -79,7 +83,6 @@
"eslint-plugin-vue": "^7.16.0",
"sass": "^1.37.5",
"stylelint": "^13.13.1",
"stylelint-config-recommended": "^5.0.0",
"stylelint-config-standard": "^22.0.0",
"typescript": "^4.3.5",
"vite": "^2.4.4"

View File

@ -1,9 +1,6 @@
const fs = require("fs");
const { sleep, debug, isDev } = require("../src/util");
const { R } = require("redbean-node");
const { setSetting, setting } = require("./util-server");
const knex = require("knex");
const sqlite3 = require("@louislam/sqlite3");
class Database {
@ -14,48 +11,23 @@ class Database {
static sqliteInstance = null;
static async connect() {
const acquireConnectionTimeout = 120 * 1000;
if (! this.sqliteInstance) {
this.sqliteInstance = new sqlite3.Database(Database.path);
this.sqliteInstance.run("PRAGMA journal_mode = WAL");
}
R.useBetterSQLite3 = true;
R.betterSQLite3Options.timeout = acquireConnectionTimeout;
const Dialect = require("knex/lib/dialects/sqlite3/index.js");
Dialect.prototype._driver = () => sqlite3;
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
R.setup("sqlite", {
filename: Database.path,
useNullAsDefault: true,
pool: {
acquireConnectionTimeout: acquireConnectionTimeout,
}, {
min: 1,
max: 1,
idleTimeoutMillis: 30000,
}
idleTimeoutMillis: 120 * 1000,
propagateCreateError: false,
acquireTimeoutMillis: acquireConnectionTimeout,
});
console.log( knexInstance.pool)
console.log("pool size")
R.setup(knexInstance);
if (process.env.SQL_LOG === "1") {
R.debug(true);
}
@ -63,6 +35,10 @@ class Database {
// Auto map the model to a bean object
R.freeze(true)
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() {
@ -148,11 +124,18 @@ 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) {
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
* @returns {Promise<void>}

View File

@ -112,10 +112,9 @@ class Monitor extends BeanModel {
try {
if (this.type === "http" || this.type === "keyword") {
// Do not do any queries/high loading things before the "bean.ping"
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, {
timeout: this.interval * 1000 * 0.8,
headers: {
@ -123,7 +122,7 @@ class Monitor extends BeanModel {
"User-Agent": "Uptime-Kuma/" + version,
},
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(),
}),
maxRedirects: this.maxredirects,
@ -276,7 +275,8 @@ class Monitor extends BeanModel {
try {
await Notification.send(JSON.parse(notification.config), msg, await this.toJSON(), bean.toJSON())
} 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}`)
}
prometheus.update(bean, tlsInfo)
io.to(this.user_id).emit("heartbeat", bean.toJSON());
await R.store(bean)
Monitor.sendStats(io, this.id, this.user_id)
await R.store(bean);
prometheus.update(bean, tlsInfo);
previousBeat = bean;
this.heartbeatInterval = setTimeout(beat, this.interval * 1000);
}
beat();
this.heartbeatInterval = setInterval(beat, this.interval * 1000);
}
stop() {
clearInterval(this.heartbeatInterval)
clearTimeout(this.heartbeatInterval);
}
/**

View File

@ -279,6 +279,116 @@ class Notification {
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") {
let pushoverlink = "https://api.pushover.net/1/messages.json"
try {
@ -404,7 +514,7 @@ class Notification {
"messages": [
{
"type": "text",
"text":"Test Successful!"
"text": "Test Successful!"
}
]
}
@ -415,7 +525,7 @@ class Notification {
"messages": [
{
"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": [
{
"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");
debug("Importing socket.io");
const { Server } = require("socket.io");
debug("Importing dayjs");
const dayjs = require("dayjs");
debug("Importing redbean-node");
const { R } = require("redbean-node");
debug("Importing jsonwebtoken");
@ -150,7 +148,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
if (user) {
debug("afterLogin")
await afterLogin(socket, user)
afterLogin(socket, user)
debug("afterLogin ok")
@ -178,7 +176,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
let user = await login(data.username, data.password)
if (user) {
await afterLogin(socket, user)
afterLogin(socket, user)
callback({
ok: true,
@ -563,7 +561,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
debug("check auto login")
if (await setting("disableAuth")) {
console.log("Disabled Auth: auto login to admin")
await afterLogin(socket, await R.findOne("user"))
afterLogin(socket, await R.findOne("user"))
socket.emit("autoLogin")
} else {
debug("need auth")
@ -623,6 +621,8 @@ async function sendMonitorList(socket) {
}
async function sendNotificationList(socket) {
const timeLogger = new TimeLogger();
let result = [];
let list = await R.find("notification", " user_id = ? ", [
socket.userID,
@ -633,6 +633,9 @@ async function sendNotificationList(socket) {
}
io.to(socket.userID).emit("notificationList", result)
timeLogger.print("Send Notification List");
return list;
}
@ -641,24 +644,27 @@ async function afterLogin(socket, user) {
socket.join(user.id)
let monitorList = await sendMonitorList(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.
// For example, query the edit data first.
setTimeout(async () => {
await sleep(500);
for (let monitorID in monitorList) {
sendHeartbeatList(socket, monitorID);
sendImportantHeartbeatList(socket, monitorID);
Monitor.sendStats(io, monitorID, user.id)
await sendHeartbeatList(socket, monitorID);
}
for (let monitorID in monitorList) {
await sendImportantHeartbeatList(socket, monitorID);
}
for (let monitorID in monitorList) {
await Monitor.sendStats(io, monitorID, user.id)
}
}, 500);
}
async function getMonitorJSONList(userID) {
let result = {};
let monitorList = await R.find("monitor", " user_id = ? ", [
let monitorList = await R.find("monitor", " user_id = ? ORDER BY weight DESC, name", [
userID,
])
@ -788,6 +794,8 @@ async function sendHeartbeatList(socket, monitorID) {
}
socket.emit("heartbeatList", monitorID, result)
timeLogger.print(`[Monitor: ${monitorID}] sendHeartbeatList`)
}
async function sendImportantHeartbeatList(socket, monitorID) {

View File

@ -2,7 +2,7 @@
@import "node_modules/bootstrap/scss/bootstrap";
#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 {
@ -18,7 +18,7 @@ h2 {
}
::-webkit-scrollbar-thumb {
background: #CCC;
background: #ccc;
border-radius: 20px;
}
@ -28,7 +28,7 @@ h2 {
.modal-content {
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 & {
box-shadow: 0 15px 70px rgb(0 0 0);
@ -41,10 +41,9 @@ h2 {
text-align: center;
}
.shadow-box {
//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;
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 {
background-color: #090C10;
background-color: #090c10;
color: $dark-font-color;
&::-webkit-scrollbar-thumb {
@ -124,7 +170,7 @@ h2 {
}
.table-hover > tbody > tr:hover {
--bs-table-accent-bg: #070A10;
--bs-table-accent-bg: #070a10;
color: $dark-font-color;
}
@ -192,6 +238,20 @@ h2 {
.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;
}
}
}
}
}
}
/*
@ -200,11 +260,13 @@ h2 {
// page-change
.slide-fade-enter-active {
transition: all 0.20s $easing-in;
transition: all 0.2s $easing-in;
}
.slide-fade-leave-active {
transition: all 0.20s $easing-in;
transition: all 0.2s $easing-in;
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateY(50px);

View File

@ -1,5 +1,5 @@
$primary: #5CDD8B;
$danger: #DC3545;
$primary: #5cdd8b;
$danger: #dc3545;
$warning: #f8a306;
$link-color: #111;
$border-radius: 50rem;
@ -9,10 +9,10 @@ $highlight-white: #e7faec;
$dark-font-color: #b1b8c0;
$dark-font-color2: #020b05;
$dark-bg: #0D1117;
$dark-bg2: #070A10;
$dark-bg: #0d1117;
$dark-bg2: #070a10;
$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-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-header">
<h5 id="exampleModalLabel" class="modal-title">
Confirm
{{ $t("Confirm") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
@ -35,7 +35,7 @@ export default {
},
yesText: {
type: String,
default: "Yes",
default: "Yes", // TODO: No idea what to translate this
},
noText: {
type: String,

View File

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

View File

@ -6,12 +6,12 @@
<div class="form-floating">
<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 class="form-floating mt-3">
<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 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">
<label class="form-check-label" for="remember">
Remember me
{{ $t("Remember me") }}
</label>
</div>
</div>
<button class="w-100 btn btn-primary" type="submit" :disabled="processing">
Login
{{ $t("Login") }}
</button>
<div v-if="res && !res.ok" class="alert alert-danger mt-3" role="alert">

View File

@ -1,7 +1,7 @@
<template>
<div class="shadow-box list mb-4">
<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>
<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-header">
<h5 id="exampleModalLabel" class="modal-title">
Setup Notification
{{ $t("Setup Notification") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div class="modal-body">
<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">
<option value="telegram">Telegram</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="signal">Signal</option>
<option value="gotify">Gotify</option>
@ -27,11 +27,12 @@
<option value="apprise">Apprise (Support 50+ Notification services)</option>
<option value="pushbullet">Pushbullet</option>
<option value="line">Line Messenger</option>
<option value="mattermost">Mattermost</option>
</select>
</div>
<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>
</div>
@ -210,7 +211,7 @@
<template v-if="notification.type === 'slack'">
<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>
<label for="slack-username" class="form-label">Username</label>
<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>
<input id="slack-button" v-model="notification.slackbutton" type="text" class="form-control">
<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;">
More info about webhooks on: <a href="https://api.slack.com/messaging/webhooks" target="_blank">https://api.slack.com/messaging/webhooks</a>
</p>
@ -238,6 +239,39 @@
</div>
</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'">
<div class="mb-3">
<label for="pushy-app-token" class="form-label">API_KEY</label>
@ -288,9 +322,9 @@
<template v-if="notification.type === 'pushover'">
<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>
<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>
<label for="pushover-device" class="form-label">Device</label>
<input id="pushover-device" v-model="notification.pushoverdevice" type="text" class="form-control">
@ -330,7 +364,7 @@
<option>none</option>
</select>
<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;">
More info on: <a href="https://pushover.net/api" target="_blank">https://pushover.net/api</a>
</p>
@ -366,10 +400,10 @@
<template v-if="notification.type === 'lunasea'">
<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>
<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>
</template>
@ -407,13 +441,13 @@
</div>
<div class="modal-footer">
<button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
Delete
{{ $t("Delete") }}
</button>
<button type="button" class="btn btn-warning" :disabled="processing" @click="test">
Test
{{ $t("Test") }}
</button>
<button type="submit" class="btn btn-primary" :disabled="processing">
Save
{{ $t("Save") }}
</button>
</div>
</div>

View File

@ -24,6 +24,7 @@ export default {
},
data() {
return {
// Configurable filtering on top of the returned data
chartPeriodHrs: 6,
};
},
@ -55,11 +56,10 @@ export default {
elements: {
point: {
// Hide points on chart unless mouse-over
radius: 0,
hitRadius: 100,
},
bar: {
barThickness: "flex",
}
},
scales: {
x: {
@ -77,9 +77,9 @@ export default {
maxRotation: 0,
autoSkipPadding: 30,
},
bounds: "ticks",
grid: {
color: this.$root.theme === "light" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.1)",
offset: false,
},
},
y: {
@ -109,8 +109,11 @@ export default {
mode: "nearest",
intersect: false,
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) {
return tooltipItem.datasetIndex === 0;
return tooltipItem.datasetIndex === 0; // Hide tooltip on Bar Chart
},
callbacks: {
label: (context) => {
@ -125,32 +128,29 @@ export default {
}
},
chartData() {
let ping_data = [];
let down_data = [];
let pingData = []; // Ping Data for Line Chart, y-axis contains ping time
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) {
ping_data = this.$root.heartbeatList[this.monitorId]
this.$root.heartbeatList[this.monitorId]
.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"),
const x = this.$root.datetime(beat.time);
pingData.push({
x,
y: beat.ping,
};
});
down_data = this.$root.heartbeatList[this.monitorId]
.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"),
downData.push({
x,
y: beat.status === 0 ? 1 : 0,
};
})
});
}
return {
datasets: [
{
data: ping_data,
// Line Chart
data: pingData,
fill: "origin",
tension: 0.2,
borderColor: "#5CDD8B",
@ -158,11 +158,15 @@ export default {
yAxisID: "y",
},
{
// Bar Chart
type: "bar",
data: down_data,
data: downData,
borderColor: "#00000000",
backgroundColor: "#DC354568",
yAxisID: "y1",
barThickness: "flex",
barPercentage: 1,
categoryPercentage: 1,
},
],
};

View File

@ -27,18 +27,18 @@ export default {
text() {
if (this.status === 0) {
return "Down"
return this.$t("Down");
}
if (this.status === 1) {
return "Up"
return this.$t("Up");
}
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 -->
<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">
<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>
</router-link>
<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>
<ul class="nav nav-pills">
<li class="nav-item">
<router-link to="/dashboard" class="nav-link">
<font-awesome-icon icon="tachometer-alt" /> Dashboard
<font-awesome-icon icon="tachometer-alt" /> {{ $t("Dashboard") }}
</router-link>
</li>
<li class="nav-item">
<router-link to="/settings" class="nav-link">
<font-awesome-icon icon="cog" /> Settings
<font-awesome-icon icon="cog" /> {{ $t("Settings") }}
</router-link>
</li>
</ul>
@ -48,32 +48,32 @@
<footer>
<div class="container-fluid">
Uptime Kuma -
Version: {{ $root.info.version }} -
<a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">Check Update On GitHub</a>
{{ $t("Version") }}: {{ $root.info.version }} -
<a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">{{ $t("Check Update On GitHub") }}</a>
</div>
</footer>
<!-- 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">
<router-link to="/dashboard" class="nav-link">
<div><font-awesome-icon icon="tachometer-alt" /></div>
Dashboard
{{ $t("Dashboard") }}
</router-link>
<router-link to="/list" class="nav-link">
<div><font-awesome-icon icon="list" /></div>
List
{{ $t("List") }}
</router-link>
<router-link to="/add" class="nav-link">
<div><font-awesome-icon icon="plus" /></div>
Add
{{ $t("Add") }}
</router-link>
<router-link to="/settings" class="nav-link">
<div><font-awesome-icon icon="cog" /></div>
Settings
{{ $t("Settings") }}
</router-link>
</nav>
</div>
@ -173,7 +173,7 @@ export default {
}
main {
min-height: calc(100vh - 160px)
min-height: calc(100vh - 160px);
}
.title {
@ -191,7 +191,7 @@ main {
}
footer {
color: #AAA;
color: #aaa;
font-size: 13px;
margin-top: 10px;
padding-bottom: 30px;
@ -201,11 +201,11 @@ footer {
.dark {
header {
background-color: #161B22;
border-bottom-color: #161B22 !important;
background-color: #161b22;
border-bottom-color: #161b22 !important;
span {
color: #F0F6FC;
color: #f0f6fc;
}
}

View File

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

View File

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

View File

@ -2,63 +2,51 @@
<transition name="slide-fade" appear>
<div v-if="$route.name === 'DashboardHome'">
<h1 class="mb-3">
Quick Stats
{{ $t("Quick Stats") }}
</h1>
<div class="shadow-box big-padding text-center">
<div class="row">
<div class="col">
<h3>Up</h3>
<h3>{{ $t("Up") }}</h3>
<span class="num">{{ stats.up }}</span>
</div>
<div class="col">
<h3>Down</h3>
<h3>{{ $t("Down") }}</h3>
<span class="num text-danger">{{ stats.down }}</span>
</div>
<div class="col">
<h3>Unknown</h3>
<h3>{{ $t("Unknown") }}</h3>
<span class="num text-secondary">{{ stats.unknown }}</span>
</div>
<div class="col">
<h3>Pause</h3>
<h3>{{ $t("Pause") }}</h3>
<span class="num text-secondary">{{ stats.pause }}</span>
</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 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">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>DateTime</th>
<th>Message</th>
<th>{{ $t("Name") }}</th>
<th>{{ $t("Status") }}</th>
<th>{{ $t("DateTime") }}</th>
<th>{{ $t("Message") }}</th>
</tr>
</thead>
<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><Status :status="beat.status" /></td>
<td><Datetime :value="beat.time" /></td>
<td>{{ beat.msg }}</td>
<td :class="{ 'border-0':! beat.msg}"><Datetime :value="beat.time" /></td>
<td class="border-0">{{ beat.msg }}</td>
</tr>
<tr v-if="importantHeartBeatList.length === 0">
<td colspan="4">
No important events
{{ $t("No important events") }}
</td>
</tr>
</tbody>
@ -182,6 +170,7 @@ export default {
.shadow-box {
padding: 20px;
margin-top: 25px;
}
table {

View File

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

View File

@ -6,10 +6,10 @@
<div class="shadow-box">
<div class="row">
<div class="col-md-6">
<h2 class="mb-2">General</h2>
<h2 class="mb-2">{{ $t("General") }}</h2>
<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">
<option value="http">
HTTP(s)
@ -21,7 +21,7 @@
Ping
</option>
<option value="keyword">
HTTP(s) - Keyword
HTTP(s) - {{ $t("Keyword") }}
</option>
<option value="dns">
DNS
@ -30,17 +30,17 @@
</div>
<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>
</div>
<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>
</div>
<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>
<div class="form-text">
Search keyword in plain html or JSON response and it is case-sensitive
@ -48,12 +48,12 @@
</div>
<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>
</div>
<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">
</div>
@ -93,47 +93,47 @@
</div>
<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">
</div>
<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">
<div class="form-text">
Maximum retries before the service is marked as down and a notification is sent
{{ $t("retriesDescription") }}
</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">
<input id="ignore-tls" v-model="monitor.ignoreTls" class="form-check-input" type="checkbox" value="">
<label class="form-check-label" for="ignore-tls">
Ignore TLS/SSL error for HTTPS websites
{{ $t("ignoreTLSError") }}
</label>
</div>
<div class="my-3 form-check">
<input id="upside-down" v-model="monitor.upsideDown" class="form-check-input" type="checkbox">
<label class="form-check-label" for="upside-down">
Upside Down Mode
{{ $t("Upside Down Mode") }}
</label>
<div class="form-text">
Flip the status upside down. If the service is reachable, it is DOWN.
{{ $t("upsideDownModeDescription") }}
</div>
</div>
<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">
<div class="form-text">
Maximum number of redirects to follow. Set to 0 to disable redirects.
{{ $t("maxRedirectDescription") }}
</div>
</div>
<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
id="acceptedStatusCodes"
@ -150,21 +150,21 @@
></VueMultiselect>
<div class="form-text">
Select status codes which are considered as a successful response.
{{ $t("acceptedStatusCodesDescription") }}
</div>
</div>
<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 class="col-md-6">
<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">
Not available, please setup.
{{ $t("Not available, please setup.") }}
</p>
<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">
{{ notification.name }}
<a href="#" @click="$refs.notificationDialog.show(notification.id)">Edit</a>
<a href="#" @click="$refs.notificationDialog.show(notification.id)">{{ $t("Edit") }}</a>
</label>
</div>
<button class="btn btn-primary me-2" type="button" @click="$refs.notificationDialog.show()">
Setup Notification
{{ $t("Setup Notification") }}
</button>
</div>
</div>
@ -217,7 +217,7 @@ export default {
computed: {
pageName() {
return (this.isAdd) ? "Add New Monitor" : "Edit"
return this.$t((this.isAdd) ? "Add New Monitor" : "Edit");
},
isAdd() {
return this.$route.path === "/add";

View File

@ -2,53 +2,63 @@
<transition name="slide-fade" appear>
<div>
<h1 v-show="show" class="mb-3">
Settings
{{ $t("Settings") }}
</h1>
<div class="shadow-box">
<div class="row">
<div class="col-md-6">
<h2 class="mb-2">General</h2>
<h2 class="mb-2">{{ $t("Appearance") }}</h2>
<form class="mb-3" @submit.prevent="saveGeneral">
<div class="mb-3">
<label for="timezone" class="form-label">Theme</label>
<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">Light</label>
<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">Dark</label>
<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">Auto</label>
<label class="btn btn-outline-primary" for="btncheck3">{{ $t("Auto") }}</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Theme - Heartbeat Bar</label>
<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">Normal</label>
<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">Bottom</label>
<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">None</label>
<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">
<div class="mb-3">
<label for="timezone" class="form-label">Timezone</label>
<label for="timezone" class="form-label">{{ $t("Timezone") }}</label>
<select id="timezone" v-model="$root.userTimezone" class="form-select">
<option value="auto">
Auto: {{ guessTimezone }}
{{ $t("Auto") }}: {{ guessTimezone }}
</option>
<option v-for="(timezone, index) in timezoneList" :key="index" :value="timezone.value">
{{ timezone.name }}
@ -57,65 +67,65 @@
</div>
<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">
<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">
Allow indexing
{{ $t("Allow indexing") }}
</label>
</div>
<div class="form-check">
<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">
Discourage search engines from indexing site
{{ $t("Discourage search engines from indexing site") }}
</label>
</div>
</div>
<div>
<button class="btn btn-primary" type="submit">
Save
{{ $t("Save") }}
</button>
</div>
</form>
<template v-if="loaded">
<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">
<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>
</div>
<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>
</div>
<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>
<div class="invalid-feedback">
The repeat password does not match.
{{ $t("passwordNotMatchMsg") }}
</div>
</div>
<div>
<button class="btn btn-primary" type="submit">
Update Password
{{ $t("Update Password") }}
</button>
</div>
</form>
</template>
<h2 class="mt-5 mb-2">Advanced</h2>
<h2 class="mt-5 mb-2">{{ $t("Advanced") }}</h2>
<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-primary me-1" @click="confirmDisableAuth">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-outline-primary me-1" @click="enableAuth">{{ $t("Enable 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">{{ $t("Logout") }}</button>
</div>
</template>
</div>
@ -123,23 +133,23 @@
<div class="notification-list col-md-6">
<div v-if="$root.isMobile" class="mt-3" />
<h2>Notifications</h2>
<h2>{{ $t("Notifications") }}</h2>
<p v-if="$root.notificationList.length === 0">
Not available, please setup.
{{ $t("Not available, please setup.") }}
</p>
<p v-else>
Please assign a notification to monitor(s) to get it to work.
{{ $t("notificationDescription") }}
</p>
<ul class="list-group mb-3" style="border-radius: 1rem;">
<li v-for="(notification, index) in $root.notificationList" :key="index" class="list-group-item">
{{ 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>
</ul>
<button class="btn btn-primary me-2" type="button" @click="$refs.notificationDialog.show()">
Setup Notification
{{ $t("Setup Notification") }}
</button>
</div>
</div>
@ -147,10 +157,18 @@
<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">
<template v-if="$i18n.locale === 'en' ">
<p>Are you sure want to <strong>disable auth</strong>?</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>
</div>
</transition>
@ -195,6 +213,10 @@ export default {
"password.repeatNewPassword"() {
this.invalidPassword = false;
},
"$i18n.locale"() {
localStorage.locale = this.$i18n.locale;
},
},
mounted() {

View File

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

View File

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