diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/server/model/monitor.js b/server/model/monitor.js new file mode 100644 index 00000000..1b25270f --- /dev/null +++ b/server/model/monitor.js @@ -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; diff --git a/server/server.js b/server/server.js new file mode 100644 index 00000000..a3d10ddd --- /dev/null +++ b/server/server.js @@ -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; + } +} + diff --git a/server/util.js b/server/util.js new file mode 100644 index 00000000..fe3ed4a0 --- /dev/null +++ b/server/util.js @@ -0,0 +1,3 @@ +exports.sleep = (ms) => { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 00000000..1f05560e --- /dev/null +++ b/src/App.vue @@ -0,0 +1,13 @@ + + + + + diff --git a/src/assets/app.scss b/src/assets/app.scss new file mode 100644 index 00000000..a17e1884 --- /dev/null +++ b/src/assets/app.scss @@ -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); + } + } +} diff --git a/src/assets/vars.scss b/src/assets/vars.scss new file mode 100644 index 00000000..dd1bcc0b --- /dev/null +++ b/src/assets/vars.scss @@ -0,0 +1,6 @@ +$primary: #5CDD8B; +$link-color: #111; +$border-radius: 50rem; + +$highlight: #7ce8a4; +$highlight-white: #e7faec; diff --git a/src/components/Confirm.vue b/src/components/Confirm.vue new file mode 100644 index 00000000..063ece25 --- /dev/null +++ b/src/components/Confirm.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/src/components/Login.vue b/src/components/Login.vue new file mode 100644 index 00000000..5907f616 --- /dev/null +++ b/src/components/Login.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/src/layouts/Layout.vue b/src/layouts/Layout.vue new file mode 100644 index 00000000..9e3d7a65 --- /dev/null +++ b/src/layouts/Layout.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/src/main.js b/src/main.js new file mode 100644 index 00000000..085c25f2 --- /dev/null +++ b/src/main.js @@ -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') + diff --git a/src/mixins/socket.js b/src/mixins/socket.js new file mode 100644 index 00000000..a55a1d3e --- /dev/null +++ b/src/mixins/socket.js @@ -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: { + + } + +} + diff --git a/src/pages/Dashboard.vue b/src/pages/Dashboard.vue new file mode 100644 index 00000000..8da16248 --- /dev/null +++ b/src/pages/Dashboard.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/src/pages/DashboardHome.vue b/src/pages/DashboardHome.vue new file mode 100644 index 00000000..b9ecfefb --- /dev/null +++ b/src/pages/DashboardHome.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/src/pages/Details.vue b/src/pages/Details.vue new file mode 100644 index 00000000..d1e04d1c --- /dev/null +++ b/src/pages/Details.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue new file mode 100644 index 00000000..143806a7 --- /dev/null +++ b/src/pages/EditMonitor.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/src/pages/Settings.vue b/src/pages/Settings.vue new file mode 100644 index 00000000..77a4add1 --- /dev/null +++ b/src/pages/Settings.vue @@ -0,0 +1,111 @@ + + + + +