mirror of
https://github.com/louislam/uptime-kuma.git
synced 2024-11-27 16:54:04 +00:00
init
This commit is contained in:
parent
c22e3050fb
commit
0a4fb45a8c
17 changed files with 1533 additions and 0 deletions
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
33
server/model/monitor.js
Normal file
33
server/model/monitor.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
const dayjs = require("dayjs");
|
||||
const {BeanModel} = require("redbean-node/dist/bean-model");
|
||||
|
||||
class Monitor extends BeanModel {
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
url: this.url,
|
||||
upRate: this.upRate,
|
||||
active: this.active,
|
||||
type: this.type,
|
||||
interval: this.interval,
|
||||
};
|
||||
}
|
||||
|
||||
start(io) {
|
||||
const beat = () => {
|
||||
console.log(`Monitor ${this.id}: Heartbeat`)
|
||||
io.to(this.user_id).emit("heartbeat", dayjs().unix());
|
||||
}
|
||||
|
||||
beat();
|
||||
this.heartbeatInterval = setInterval(beat, this.interval * 1000);
|
||||
}
|
||||
|
||||
stop() {
|
||||
clearInterval(this.heartbeatInterval)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Monitor;
|
379
server/server.js
Normal file
379
server/server.js
Normal file
|
@ -0,0 +1,379 @@
|
|||
const express = require('express');
|
||||
const app = express();
|
||||
const http = require('http');
|
||||
const server = http.createServer(app);
|
||||
const { Server } = require("socket.io");
|
||||
const io = new Server(server);
|
||||
const axios = require('axios');
|
||||
const dayjs = require("dayjs");
|
||||
const {R} = require("redbean-node");
|
||||
const passwordHash = require('password-hash');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const Monitor = require("./model/monitor");
|
||||
const {sleep} = require("./util");
|
||||
|
||||
|
||||
let stop = false;
|
||||
let interval = 6000;
|
||||
let totalClient = 0;
|
||||
let jwtSecret = null;
|
||||
let loadFromDatabase = true;
|
||||
let monitorList = {};
|
||||
|
||||
(async () => {
|
||||
|
||||
R.setup('sqlite', {
|
||||
filename: '../data/kuma.db'
|
||||
});
|
||||
R.freeze(true)
|
||||
await R.autoloadModels("./model");
|
||||
|
||||
await initDatabase();
|
||||
|
||||
app.use('/', express.static("public"));
|
||||
|
||||
io.on('connection', async (socket) => {
|
||||
console.log('a user connected');
|
||||
totalClient++;
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('user disconnected');
|
||||
totalClient--;
|
||||
});
|
||||
|
||||
// Public API
|
||||
|
||||
socket.on("loginByToken", async (token, callback) => {
|
||||
|
||||
try {
|
||||
let decoded = jwt.verify(token, jwtSecret);
|
||||
|
||||
console.log("Username from JWT: " + decoded.username)
|
||||
|
||||
let user = await R.findOne("user", " username = ? AND active = 1 ", [
|
||||
decoded.username
|
||||
])
|
||||
|
||||
if (user) {
|
||||
await afterLogin(socket, user)
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
})
|
||||
} else {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: "The user is inactive or deleted."
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: "Invalid token."
|
||||
})
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
socket.on("login", async (data, callback) => {
|
||||
console.log("Login")
|
||||
|
||||
let user = await R.findOne("user", " username = ? AND active = 1 ", [
|
||||
data.username
|
||||
])
|
||||
|
||||
if (user && passwordHash.verify(data.password, user.password)) {
|
||||
|
||||
await afterLogin(socket, user)
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
token: jwt.sign({
|
||||
username: data.username
|
||||
}, jwtSecret)
|
||||
})
|
||||
} else {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: "Incorrect username or password."
|
||||
})
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
socket.on("logout", async (callback) => {
|
||||
socket.leave(socket.userID)
|
||||
socket.userID = null;
|
||||
callback();
|
||||
});
|
||||
|
||||
// Auth Only API
|
||||
|
||||
socket.on("add", async (monitor, callback) => {
|
||||
try {
|
||||
checkLogin(socket)
|
||||
|
||||
let bean = R.dispense("monitor")
|
||||
bean.import(monitor)
|
||||
bean.user_id = socket.userID
|
||||
await R.store(bean)
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Added Successfully.",
|
||||
monitorID: bean.id
|
||||
});
|
||||
|
||||
await sendMonitorList(socket);
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("getMonitor", async (monitorID, callback) => {
|
||||
try {
|
||||
checkLogin(socket)
|
||||
|
||||
console.log(`Get Monitor: ${monitorID} User ID: ${socket.userID}`)
|
||||
|
||||
let bean = await R.findOne("monitor", " id = ? AND user_id = ? ", [
|
||||
monitorID,
|
||||
socket.userID,
|
||||
])
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
monitor: bean.toJSON(),
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Start or Resume the monitor
|
||||
socket.on("resumeMonitor", async (monitorID, callback) => {
|
||||
try {
|
||||
checkLogin(socket)
|
||||
await startMonitor(socket.userID, monitorID);
|
||||
await sendMonitorList(socket);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Paused Successfully."
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("pauseMonitor", async (monitorID, callback) => {
|
||||
try {
|
||||
checkLogin(socket)
|
||||
await pauseMonitor(socket.userID, monitorID)
|
||||
await sendMonitorList(socket);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Paused Successfully."
|
||||
});
|
||||
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("deleteMonitor", async (monitorID, callback) => {
|
||||
try {
|
||||
checkLogin(socket)
|
||||
|
||||
console.log(`Delete Monitor: ${monitorID} User ID: ${socket.userID}`)
|
||||
|
||||
if (monitorID in monitorList) {
|
||||
monitorList[monitorID].stop();
|
||||
delete monitorList[monitorID]
|
||||
}
|
||||
|
||||
await R.exec("DELETE FROM monitor WHERE id = ? AND user_id = ? ", [
|
||||
monitorID,
|
||||
socket.userID
|
||||
]);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Deleted Successfully."
|
||||
});
|
||||
|
||||
await sendMonitorList(socket);
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("changePassword", async (password, callback) => {
|
||||
try {
|
||||
checkLogin(socket)
|
||||
|
||||
if (! password.currentPassword) {
|
||||
throw new Error("Invalid new password")
|
||||
}
|
||||
|
||||
let user = await R.findOne("user", " id = ? AND active = 1 ", [
|
||||
socket.userID
|
||||
])
|
||||
|
||||
if (user && passwordHash.verify(password.currentPassword, user.password)) {
|
||||
|
||||
await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
|
||||
passwordHash.generate(password.newPassword),
|
||||
socket.userID
|
||||
]);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Password has been updated successfully."
|
||||
})
|
||||
} else {
|
||||
throw new Error("Incorrect current password")
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(3001, () => {
|
||||
console.log('Listening on 3001');
|
||||
startMonitors();
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
async function checkOwner(userID, monitorID) {
|
||||
let row = await R.getRow("SELECT id FROM monitor WHERE id = ? AND user_id = ? ", [
|
||||
monitorID,
|
||||
userID,
|
||||
])
|
||||
|
||||
if (! row) {
|
||||
throw new Error("You do not own this monitor.");
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMonitorList(socket) {
|
||||
io.to(socket.userID).emit("monitorList", await getMonitorJSONList(socket.userID))
|
||||
}
|
||||
|
||||
async function afterLogin(socket, user) {
|
||||
socket.userID = user.id;
|
||||
socket.join(user.id)
|
||||
socket.emit("monitorList", await getMonitorJSONList(user.id))
|
||||
}
|
||||
|
||||
async function getMonitorJSONList(userID) {
|
||||
let result = [];
|
||||
|
||||
let monitorList = await R.find("monitor", " user_id = ? ORDER BY weight DESC ", [
|
||||
userID
|
||||
])
|
||||
|
||||
for (let monitor of monitorList) {
|
||||
result.push(monitor.toJSON())
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function checkLogin(socket) {
|
||||
if (! socket.userID) {
|
||||
throw new Error("You are not logged in.");
|
||||
}
|
||||
}
|
||||
|
||||
async function initDatabase() {
|
||||
let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [
|
||||
"jwtSecret"
|
||||
]);
|
||||
|
||||
if (! jwtSecretBean) {
|
||||
console.log("JWT secret is not found, generate one.")
|
||||
jwtSecretBean = R.dispense("setting")
|
||||
jwtSecretBean.key = "jwtSecret"
|
||||
|
||||
jwtSecretBean.value = passwordHash.generate(dayjs() + "")
|
||||
await R.store(jwtSecretBean)
|
||||
} else {
|
||||
console.log("Load JWT secret from database.")
|
||||
}
|
||||
|
||||
jwtSecret = jwtSecretBean.value;
|
||||
}
|
||||
|
||||
async function startMonitor(userID, monitorID) {
|
||||
await checkOwner(userID, monitorID)
|
||||
|
||||
console.log(`Resume Monitor: ${monitorID} User ID: ${userID}`)
|
||||
|
||||
await R.exec("UPDATE monitor SET active = 1 WHERE id = ? AND user_id = ? ", [
|
||||
monitorID,
|
||||
userID
|
||||
]);
|
||||
|
||||
let monitor = await R.findOne("monitor", " id = ? ", [
|
||||
monitorID
|
||||
])
|
||||
|
||||
monitorList[monitor.id] = monitor;
|
||||
monitor.start(io)
|
||||
}
|
||||
|
||||
async function pauseMonitor(userID, monitorID) {
|
||||
await checkOwner(userID, monitorID)
|
||||
|
||||
console.log(`Pause Monitor: ${monitorID} User ID: ${userID}`)
|
||||
|
||||
await R.exec("UPDATE monitor SET active = 0 WHERE id = ? AND user_id = ? ", [
|
||||
monitorID,
|
||||
userID
|
||||
]);
|
||||
|
||||
if (monitorID in monitorList) {
|
||||
monitorList[monitorID].stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume active monitors
|
||||
*/
|
||||
async function startMonitors() {
|
||||
let list = await R.find("monitor", " active = 1 ")
|
||||
|
||||
for (let monitor of list) {
|
||||
monitor.start(io)
|
||||
monitorList[monitor.id] = monitor;
|
||||
}
|
||||
}
|
||||
|
3
server/util.js
Normal file
3
server/util.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
exports.sleep = (ms) => {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
13
src/App.vue
Normal file
13
src/App.vue
Normal file
|
@ -0,0 +1,13 @@
|
|||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
</style>
|
57
src/assets/app.scss
Normal file
57
src/assets/app.scss
Normal file
|
@ -0,0 +1,57 @@
|
|||
@import "vars.scss";
|
||||
@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;
|
||||
}
|
||||
|
||||
.shadow-box {
|
||||
overflow: hidden;
|
||||
box-shadow: 0 15px 70px rgba(0, 0, 0, .1);
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
|
||||
&.big-padding {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: white;
|
||||
|
||||
&:hover, &:active, &:focus, &.active {
|
||||
color: white;
|
||||
background-color: $highlight;
|
||||
border-color: $highlight;
|
||||
}
|
||||
}
|
||||
|
||||
.hp-bar-big {
|
||||
white-space: nowrap;
|
||||
margin-top: 4px;
|
||||
text-align: center;
|
||||
direction: rtl;
|
||||
margin-bottom: 10px;
|
||||
transition: all ease-in-out 0.15s;
|
||||
position: relative;
|
||||
|
||||
div {
|
||||
display: inline-block;
|
||||
background-color: $primary;
|
||||
width: 1%;
|
||||
height: 30px;
|
||||
margin: 0.3%;
|
||||
border-radius: 50rem;
|
||||
transition: all ease-in-out 0.15s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.5);
|
||||
}
|
||||
}
|
||||
}
|
6
src/assets/vars.scss
Normal file
6
src/assets/vars.scss
Normal file
|
@ -0,0 +1,6 @@
|
|||
$primary: #5CDD8B;
|
||||
$link-color: #111;
|
||||
$border-radius: 50rem;
|
||||
|
||||
$highlight: #7ce8a4;
|
||||
$highlight-white: #e7faec;
|
50
src/components/Confirm.vue
Normal file
50
src/components/Confirm.vue
Normal file
|
@ -0,0 +1,50 @@
|
|||
<template>
|
||||
<div class="modal fade" tabindex="-1" ref="modal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="exampleModalLabel">Confirm</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" :class="btnStyle" @click="yes" data-bs-dismiss="modal">Yes</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">No</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Modal } from 'bootstrap'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
btnStyle: {
|
||||
type: String,
|
||||
default: "btn-primary"
|
||||
}
|
||||
},
|
||||
data: () => ({
|
||||
modal: null
|
||||
}),
|
||||
mounted() {
|
||||
this.modal = new Modal(this.$refs.modal)
|
||||
},
|
||||
methods: {
|
||||
show() {
|
||||
this.modal.show()
|
||||
},
|
||||
yes() {
|
||||
this.$emit('yes');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
77
src/components/Login.vue
Normal file
77
src/components/Login.vue
Normal file
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<div class="form-container">
|
||||
<div class="form">
|
||||
<form @submit.prevent="submit">
|
||||
|
||||
<h1 class="h3 mb-3 fw-normal"></h1>
|
||||
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" id="floatingInput" placeholder="Username" v-model="username">
|
||||
<label for="floatingInput">Username</label>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mt-3">
|
||||
<input type="password" class="form-control" id="floatingPassword" placeholder="Password" v-model="password">
|
||||
<label for="floatingPassword">Password</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3 mt-3">
|
||||
<label>
|
||||
<input type="checkbox" value="remember-me" class="form-check-input" id="remember" v-model="remember">
|
||||
|
||||
<label class="form-check-label" for="remember">
|
||||
Remember me
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
<button class="w-100 btn btn-primary" type="submit" :disabled="processing">Login</button>
|
||||
|
||||
<div class="alert alert-danger mt-3" role="alert" v-if="res && !res.ok">
|
||||
{{ res.msg }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
username: "",
|
||||
password: "",
|
||||
remember: true,
|
||||
res: null,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.$root.login(this.username, this.password, (res) => {
|
||||
this.processing = false;
|
||||
this.res = res;
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.form-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 40px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.form {
|
||||
|
||||
width: 100%;
|
||||
max-width: 330px;
|
||||
padding: 15px;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
69
src/layouts/Layout.vue
Normal file
69
src/layouts/Layout.vue
Normal file
|
@ -0,0 +1,69 @@
|
|||
<template>
|
||||
|
||||
<div class="lost-connection" v-if="! $root.socket.connected && ! $root.socket.firstConnect">
|
||||
<div class="container-fluid">
|
||||
Lost connection to the socket server. Reconnecting...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header class="d-flex flex-wrap justify-content-center py-3 mb-3 border-bottom">
|
||||
|
||||
<router-link to="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
|
||||
<svg class="bi me-2" width="40" height="32"><use xlink:href="#bootstrap"/></svg>
|
||||
<span class="fs-4 title">Uptime Kuma</span>
|
||||
</router-link>
|
||||
|
||||
<ul class="nav nav-pills">
|
||||
<li class="nav-item"><router-link to="/dashboard" class="nav-link">📊 Dashboard</router-link></li>
|
||||
<li class="nav-item"><router-link to="/settings" class="nav-link">⚙ Settings</router-link></li>
|
||||
</ul>
|
||||
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<router-view v-if="$root.loggedIn" />
|
||||
<Login v-if="! $root.loggedIn && $root.allowLoginDialog" />
|
||||
</main>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Login from "../components/Login.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Login
|
||||
},
|
||||
mounted() {
|
||||
this.init();
|
||||
},
|
||||
watch: {
|
||||
$route (to, from) {
|
||||
this.init();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
if (this.$route.name === "root") {
|
||||
this.$router.push("/dashboard")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.nav {
|
||||
margin-right: 25px;
|
||||
}
|
||||
|
||||
.lost-connection {
|
||||
padding: 5px;
|
||||
background-color: crimson;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
78
src/main.js
Normal file
78
src/main.js
Normal file
|
@ -0,0 +1,78 @@
|
|||
import {createApp, h} from "vue";
|
||||
import {createRouter, createWebHistory} from 'vue-router'
|
||||
|
||||
import App from './App.vue'
|
||||
import Layout from './layouts/Layout.vue'
|
||||
import Settings from "./pages/Settings.vue";
|
||||
import Dashboard from "./pages/Dashboard.vue";
|
||||
import DashboardHome from "./pages/DashboardHome.vue";
|
||||
import Details from "./pages/Details.vue";
|
||||
import socket from "./mixins/socket"
|
||||
import "./assets/app.scss"
|
||||
import EditMonitor from "./pages/EditMonitor.vue";
|
||||
import Toast from "vue-toastification";
|
||||
import "vue-toastification/dist/index.css";
|
||||
import "bootstrap"
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
name: "root",
|
||||
path: '',
|
||||
component: Dashboard,
|
||||
children: [
|
||||
{
|
||||
name: "DashboardHome",
|
||||
path: '/dashboard',
|
||||
component: DashboardHome,
|
||||
children: [
|
||||
{
|
||||
path: ':id',
|
||||
component: Details,
|
||||
},
|
||||
{
|
||||
path: '/add',
|
||||
component: EditMonitor,
|
||||
},
|
||||
{
|
||||
path: '/edit/:id',
|
||||
component: EditMonitor,
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: Settings,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
linkActiveClass: 'active',
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
const app = createApp({
|
||||
mixins: [
|
||||
socket,
|
||||
],
|
||||
render: ()=>h(App)
|
||||
})
|
||||
|
||||
app.use(router)
|
||||
|
||||
const options = {
|
||||
position: "bottom-right"
|
||||
};
|
||||
|
||||
app.use(Toast, options);
|
||||
|
||||
app.mount('#app')
|
||||
|
121
src/mixins/socket.js
Normal file
121
src/mixins/socket.js
Normal file
|
@ -0,0 +1,121 @@
|
|||
import {io} from "socket.io-client";
|
||||
import { useToast } from 'vue-toastification'
|
||||
const toast = useToast()
|
||||
|
||||
let storage = localStorage;
|
||||
let socket;
|
||||
|
||||
export default {
|
||||
|
||||
data() {
|
||||
return {
|
||||
socket: {
|
||||
token: null,
|
||||
firstConnect: true,
|
||||
connected: false,
|
||||
},
|
||||
allowLoginDialog: false, // Allowed to show login dialog, but "loggedIn" have to be true too. This exists because prevent the login dialog show 0.1s in first before the socket server auth-ed.
|
||||
loggedIn: false,
|
||||
monitorList: [
|
||||
|
||||
],
|
||||
importantHeartbeatList: [
|
||||
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
socket = io("http://localhost:3001", {
|
||||
transports: ['websocket']
|
||||
});
|
||||
|
||||
socket.on('monitorList', (data) => {
|
||||
this.monitorList = data;
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
this.socket.connected = false;
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
this.socket.connected = true;
|
||||
this.socket.firstConnect = false;
|
||||
|
||||
if (storage.token) {
|
||||
this.loginByToken(storage.token)
|
||||
} else {
|
||||
this.allowLoginDialog = true;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
getSocket() {
|
||||
return socket;
|
||||
},
|
||||
toastRes(res) {
|
||||
if (res.ok) {
|
||||
toast.success(res.msg);
|
||||
} else {
|
||||
toast.error(res.msg);
|
||||
}
|
||||
},
|
||||
login(username, password, callback) {
|
||||
socket.emit("login", {
|
||||
username,
|
||||
password,
|
||||
}, (res) => {
|
||||
|
||||
if (res.ok) {
|
||||
storage.token = res.token;
|
||||
this.socket.token = res.token;
|
||||
this.loggedIn = true;
|
||||
|
||||
// Trigger Chrome Save Password
|
||||
history.pushState({}, '')
|
||||
}
|
||||
|
||||
callback(res)
|
||||
})
|
||||
},
|
||||
loginByToken(token) {
|
||||
socket.emit("loginByToken", token, (res) => {
|
||||
this.allowLoginDialog = true;
|
||||
|
||||
if (! res.ok) {
|
||||
this.logout()
|
||||
console.log(res.msg)
|
||||
} else {
|
||||
this.loggedIn = true;
|
||||
}
|
||||
})
|
||||
},
|
||||
logout() {
|
||||
storage.removeItem("token");
|
||||
this.socket.token = null;
|
||||
this.loggedIn = false;
|
||||
|
||||
socket.emit("logout", () => {
|
||||
toast.success("Logout Successfully")
|
||||
})
|
||||
},
|
||||
add(monitor, callback) {
|
||||
socket.emit("add", monitor, callback)
|
||||
},
|
||||
deleteMonitor(monitorID, callback) {
|
||||
socket.emit("deleteMonitor", monitorID, callback)
|
||||
},
|
||||
loadMonitor(monitorID) {
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
128
src/pages/Dashboard.vue
Normal file
128
src/pages/Dashboard.vue
Normal file
|
@ -0,0 +1,128 @@
|
|||
<template>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div>
|
||||
<router-link to="/add" class="btn btn-primary">Add New Monitor</router-link>
|
||||
</div>
|
||||
|
||||
<div class="shadow-box list">
|
||||
|
||||
<span v-if="$root.monitorList.length === 0">No Monitors, please <router-link to="/add">add one</router-link>.</span>
|
||||
|
||||
<router-link :to="monitorURL(item.id)" class="item" :class="{ 'disabled': ! item.active }" v-for="item in $root.monitorList">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
|
||||
<div class="info">
|
||||
<span class="badge rounded-pill bg-primary">{{ item.upRate }}%</span>
|
||||
{{ item.name }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="hp-bar">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</router-link>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-8">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
components: {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
monitorURL(id) {
|
||||
return "/dashboard/" + id;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../assets/vars.scss";
|
||||
|
||||
.container-fluid {
|
||||
width: 98%
|
||||
}
|
||||
|
||||
.list {
|
||||
margin-top: 25px;
|
||||
|
||||
.item {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
padding: 15px 15px 12px 15px;
|
||||
border-radius: 10px;
|
||||
transition: all ease-in-out 0.15s;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.info {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $highlight-white;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #cdf8f4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hp-bar {
|
||||
white-space: nowrap;
|
||||
margin-top: 4px;
|
||||
text-align: right;
|
||||
|
||||
div {
|
||||
display: inline-block;
|
||||
background-color: $primary;
|
||||
width: 0.35rem;
|
||||
height: 1rem;
|
||||
margin: 0.15rem;
|
||||
border-radius: 50rem;
|
||||
transition: all ease-in-out 0.15s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
123
src/pages/DashboardHome.vue
Normal file
123
src/pages/DashboardHome.vue
Normal file
|
@ -0,0 +1,123 @@
|
|||
<template>
|
||||
|
||||
<div v-if="$route.name === 'DashboardHome'">
|
||||
<h1 class="mb-3">Quick Stats</h1>
|
||||
|
||||
<div class="shadow-box big-padding text-center">
|
||||
<div class="row">
|
||||
|
||||
<div class="col-12">
|
||||
<div class="hp-bar-big">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<h3>Up</h3>
|
||||
<span class="num">2</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h3>Down</h3>
|
||||
<span class="num text-danger">0</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h3>Pause</h3>
|
||||
<span class="num">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-8">
|
||||
<h4>Latest Incident</h4>
|
||||
|
||||
<div class="shadow-box bg-danger text-light">
|
||||
MySQL was down.
|
||||
</div>
|
||||
|
||||
<div class="shadow-box bg-primary text-light">
|
||||
No issues was found.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-4">
|
||||
|
||||
<h4>Overall Uptime</h4>
|
||||
|
||||
<div class="shadow-box">
|
||||
<div>100.00% (24 hours)</div>
|
||||
<div>100.00% (7 days)</div>
|
||||
<div>100.00% (30 days)</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<router-view ref="child" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
computed: {
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../assets/vars";
|
||||
|
||||
.num {
|
||||
font-size: 30px;
|
||||
color: $primary;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
162
src/pages/Details.vue
Normal file
162
src/pages/Details.vue
Normal file
|
@ -0,0 +1,162 @@
|
|||
<template>
|
||||
<h1>{{ monitor.name }}</h1>
|
||||
<h2>{{ monitor.url }}</h2>
|
||||
|
||||
<div class="functions">
|
||||
<button class="btn btn-light" @click="pauseDialog" v-if="monitor.active">Pause</button>
|
||||
<button class="btn btn-primary" @click="resumeMonitor" v-if="! monitor.active">Resume</button>
|
||||
<router-link :to=" '/edit/' + monitor.id " class="btn btn-light">Edit</router-link>
|
||||
<button class="btn btn-danger" @click="deleteDialog">Delete</button>
|
||||
</div>
|
||||
|
||||
<div class="shadow-box">
|
||||
|
||||
<div class="hp-bar-big">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Confirm ref="confirmPause" @yes="pauseMonitor">
|
||||
Are you sure want to pause?
|
||||
</Confirm>
|
||||
|
||||
<Confirm ref="confirmDelete" btnStyle="btn-danger" @yes="deleteMonitor">
|
||||
Are you sure want to delete this monitor?
|
||||
</Confirm>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useToast } from 'vue-toastification'
|
||||
const toast = useToast()
|
||||
import Confirm from "../components/Confirm.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Confirm
|
||||
},
|
||||
mounted() {
|
||||
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
monitor() {
|
||||
let id = parseInt(this.$route.params.id)
|
||||
|
||||
for (let monitor of this.$root.monitorList) {
|
||||
if (monitor.id === id) {
|
||||
return monitor;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
pauseDialog() {
|
||||
this.$refs.confirmPause.show();
|
||||
},
|
||||
resumeMonitor() {
|
||||
this.$root.getSocket().emit("resumeMonitor", this.monitor.id, (res) => {
|
||||
this.$root.toastRes(res)
|
||||
})
|
||||
},
|
||||
pauseMonitor() {
|
||||
this.$root.getSocket().emit("pauseMonitor", this.monitor.id, (res) => {
|
||||
this.$root.toastRes(res)
|
||||
})
|
||||
},
|
||||
deleteDialog() {
|
||||
this.$refs.confirmDelete.show();
|
||||
},
|
||||
deleteMonitor() {
|
||||
this.$root.deleteMonitor(this.monitor.id, (res) => {
|
||||
if (res.ok) {
|
||||
toast.success(res.msg);
|
||||
this.$router.push("/dashboard")
|
||||
} else {
|
||||
toast.error(res.msg);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../assets/vars.scss";
|
||||
|
||||
h2 {
|
||||
color: $primary;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.functions {
|
||||
button, a {
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.shadow-box {
|
||||
padding: 20px;
|
||||
margin-top: 25px;
|
||||
}
|
||||
</style>
|
123
src/pages/EditMonitor.vue
Normal file
123
src/pages/EditMonitor.vue
Normal file
|
@ -0,0 +1,123 @@
|
|||
<template>
|
||||
<h1 class="mb-3">{{ pageName }}</h1>
|
||||
<form @submit.prevent="submit">
|
||||
|
||||
<div class="shadow-box">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h2>General</h2>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="type" class="form-label">Monitor Type</label>
|
||||
<select class="form-select" aria-label="Default select example" id="type" v-model="monitor.type">
|
||||
<option value="http">HTTP(s)</option>
|
||||
<option value="port">TCP Port</option>
|
||||
<option value="ping">Ping</option>
|
||||
<option value="keyword">HTTP(s) - Keyword</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Friendly Name</label>
|
||||
<input type="text" class="form-control" id="name" v-model="monitor.name" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="url" class="form-label">URL</label>
|
||||
<input type="url" class="form-control" id="url" v-model="monitor.url" pattern="https?://.+" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="interval" class="form-label">Heartbeat Interval (Every {{ monitor.interval }} seconds)</label>
|
||||
<input type="number" class="form-control" id="interval" v-model="monitor.interval" required min="20" step="20">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-primary" type="submit" :disabled="processing">Save</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<h2>Notifications</h2>
|
||||
<p>Not available, please setup in Settings page.</p>
|
||||
<a class="btn btn-primary me-2" href="/settings" target="_blank">Go to Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useToast } from 'vue-toastification'
|
||||
const toast = useToast()
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
},
|
||||
mounted() {
|
||||
|
||||
if (this.isAdd) {
|
||||
this.monitor = {
|
||||
type: "http",
|
||||
name: "",
|
||||
url: "https://",
|
||||
interval: 60,
|
||||
}
|
||||
} else {
|
||||
this.$root.getSocket().emit("getMonitor", this.$route.params.id, (res) => {
|
||||
if (res.ok) {
|
||||
this.monitor = res.monitor;
|
||||
} else {
|
||||
toast.error(res.msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
monitor: { }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
pageName() {
|
||||
return (this.isAdd) ? "Add New Monitor" : "Edit"
|
||||
},
|
||||
isAdd() {
|
||||
return this.$route.path === "/add";
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
this.processing = true;
|
||||
|
||||
if (this.isAdd) {
|
||||
this.$root.add(this.monitor, (res) => {
|
||||
this.processing = false;
|
||||
|
||||
if (res.ok) {
|
||||
toast.success(res.msg);
|
||||
this.$router.push("/dashboard/" + res.monitorID)
|
||||
} else {
|
||||
toast.error(res.msg);
|
||||
}
|
||||
|
||||
})
|
||||
} else {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shadow-box {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
111
src/pages/Settings.vue
Normal file
111
src/pages/Settings.vue
Normal file
|
@ -0,0 +1,111 @@
|
|||
<template>
|
||||
<h1 class="mb-3">Settings</h1>
|
||||
|
||||
<div class="shadow-box">
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md-6">
|
||||
<h2>General</h2>
|
||||
<form class="mb-3">
|
||||
<div class="mb-3">
|
||||
<label for="timezone" class="form-label">Timezone</label>
|
||||
<select class="form-select" aria-label="Default select example" id="timezone">
|
||||
<option value="1">One</option>
|
||||
<option value="2">Two</option>
|
||||
<option value="3">Three</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h2>Change Password</h2>
|
||||
<form class="mb-3" @submit.prevent="savePassword">
|
||||
<div class="mb-3">
|
||||
<label for="current-password" class="form-label">Current Password</label>
|
||||
<input type="password" class="form-control" id="current-password" required v-model="password.currentPassword">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="new-password" class="form-label">New Password</label>
|
||||
<input type="password" class="form-control" id="new-password" required v-model="password.newPassword">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="repeat-new-password" class="form-label">Repeat New Password</label>
|
||||
<input type="password" class="form-control" :class="{ 'is-invalid' : invalidPassword }" id="repeat-new-password" required v-model="password.repeatNewPassword">
|
||||
<div class="invalid-feedback">
|
||||
The repeat password is not match.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-primary" type="submit">Update Password</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-danger" @click="$root.logout">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<h2>Notifications</h2>
|
||||
<p>Empty</p>
|
||||
<button class="btn btn-primary" type="submit">Add Notification</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
invalidPassword: false,
|
||||
password: {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
repeatNewPassword: "",
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
savePassword() {
|
||||
if (this.password.newPassword !== this.password.repeatNewPassword) {
|
||||
this.invalidPassword = true;
|
||||
} else {
|
||||
this.$root.getSocket().emit("changePassword", this.password, (res) => {
|
||||
this.$root.toastRes(res)
|
||||
if (res.ok) {
|
||||
this.password.currentPassword = ""
|
||||
this.password.newPassword = ""
|
||||
this.password.repeatNewPassword = ""
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"password.repeatNewPassword"() {
|
||||
this.invalidPassword = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shadow-box {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
Loading…
Reference in a new issue