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 @@ +<template> + <router-view /> +</template> + +<script> +export default { + +} +</script> + +<style lang="scss"> + +</style> 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 @@ +<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> 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 @@ +<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> 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 @@ +<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> 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 @@ +<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> 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 @@ +<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> 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 @@ +<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> 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 @@ +<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> 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 @@ +<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>