diff --git a/db/patch10.sql b/db/patch10.sql new file mode 100644 index 000000000..488db1169 --- /dev/null +++ b/db/patch10.sql @@ -0,0 +1,19 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +CREATE TABLE tag ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) NOT NULL, + color VARCHAR(255) NOT NULL, + created_date DATETIME DEFAULT (DATETIME('now')) NOT NULL +); + +CREATE TABLE monitor_tag ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + monitor_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + value TEXT, + CONSTRAINT FK_tag FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT FK_monitor FOREIGN KEY (monitor_id) REFERENCES monitor(id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX monitor_tag_monitor_id_index ON monitor_tag (monitor_id); +CREATE INDEX monitor_tag_tag_id_index ON monitor_tag (tag_id); diff --git a/server/database.js b/server/database.js index 4b3ad443e..7af75423a 100644 --- a/server/database.js +++ b/server/database.js @@ -37,7 +37,7 @@ class Database { * The finally version should be 10 after merged tag feature * @deprecated Use patchList for any new feature */ - static latestVersion = 9; + static latestVersion = 10; static noReject = true; diff --git a/server/model/monitor.js b/server/model/monitor.js index 89208a3fd..6c4e17eeb 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -32,6 +32,8 @@ class Monitor extends BeanModel { notificationIDList[bean.notification_id] = true; } + const tags = await R.getAll("SELECT mt.*, tag.name, tag.color FROM monitor_tag mt JOIN tag ON mt.tag_id = tag.id WHERE mt.monitor_id = ?", [this.id]); + return { id: this.id, name: this.name, @@ -52,6 +54,7 @@ class Monitor extends BeanModel { dns_resolve_server: this.dns_resolve_server, dns_last_result: this.dns_last_result, notificationIDList, + tags: tags, }; } diff --git a/server/model/tag.js b/server/model/tag.js new file mode 100644 index 000000000..748280a70 --- /dev/null +++ b/server/model/tag.js @@ -0,0 +1,13 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); + +class Tag extends BeanModel { + toJSON() { + return { + id: this._id, + name: this._name, + color: this._color, + }; + } +} + +module.exports = Tag; diff --git a/server/server.js b/server/server.js index a0b9a2fbb..003d25ae6 100644 --- a/server/server.js +++ b/server/server.js @@ -514,6 +514,22 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString(); } }); + socket.on("getMonitorList", async (callback) => { + try { + checkLogin(socket) + await sendMonitorList(socket); + callback({ + ok: true, + }); + } catch (e) { + console.error(e) + callback({ + ok: false, + msg: e.message, + }); + } + }); + socket.on("getMonitor", async (monitorID, callback) => { try { checkLogin(socket) @@ -608,6 +624,159 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString(); } }); + socket.on("getTags", async (callback) => { + try { + checkLogin(socket) + + const list = await R.findAll("tag") + + callback({ + ok: true, + tags: list.map(bean => bean.toJSON()), + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("addTag", async (tag, callback) => { + try { + checkLogin(socket) + + let bean = R.dispense("tag") + bean.name = tag.name + bean.color = tag.color + await R.store(bean) + + callback({ + ok: true, + tag: await bean.toJSON(), + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("editTag", async (tag, callback) => { + try { + checkLogin(socket) + + let bean = await R.findOne("monitor", " id = ? ", [ tag.id ]) + bean.name = tag.name + bean.color = tag.color + await R.store(bean) + + callback({ + ok: true, + tag: await bean.toJSON(), + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("deleteTag", async (tagID, callback) => { + try { + checkLogin(socket) + + await R.exec("DELETE FROM tag WHERE id = ? ", [ tagID ]) + + callback({ + ok: true, + msg: "Deleted Successfully.", + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("addMonitorTag", async (tagID, monitorID, value, callback) => { + try { + checkLogin(socket) + + await R.exec("INSERT INTO monitor_tag (tag_id, monitor_id, value) VALUES (?, ?, ?)", [ + tagID, + monitorID, + value, + ]) + + callback({ + ok: true, + msg: "Added Successfully.", + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("editMonitorTag", async (tagID, monitorID, value, callback) => { + try { + checkLogin(socket) + + await R.exec("UPDATE monitor_tag SET value = ? WHERE tag_id = ? AND monitor_id = ?", [ + value, + tagID, + monitorID, + ]) + + callback({ + ok: true, + msg: "Edited Successfully.", + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("deleteMonitorTag", async (tagID, monitorID, callback) => { + try { + checkLogin(socket) + + await R.exec("DELETE FROM monitor_tag WHERE tag_id = ? AND monitor_id = ?", [ + tagID, + monitorID, + ]) + + // Cleanup unused Tags + await R.exec("delete from tag where ( select count(*) from monitor_tag mt where tag.id = mt.tag_id ) = 0"); + + callback({ + ok: true, + msg: "Deleted Successfully.", + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + socket.on("changePassword", async (password, callback) => { try { checkLogin(socket) diff --git a/src/components/MonitorList.vue b/src/components/MonitorList.vue index e72155891..77097dc12 100644 --- a/src/components/MonitorList.vue +++ b/src/components/MonitorList.vue @@ -11,6 +11,9 @@ <Uptime :monitor="item" type="24" :pill="true" /> {{ item.name }} </div> + <div class="tags"> + <Tag v-for="tag in item.tags" :key="tag" :item="tag" :size="'sm'" /> + </div> </div> <div v-show="$root.userHeartbeatBar == 'normal'" :key="$root.userHeartbeatBar" class="col-6 col-md-4"> <HeartbeatBar size="small" :monitor-id="item.id" /> @@ -29,10 +32,13 @@ <script> import HeartbeatBar from "../components/HeartbeatBar.vue"; import Uptime from "../components/Uptime.vue"; +import Tag from "../components/Tag.vue"; + export default { components: { Uptime, HeartbeatBar, + Tag, }, props: { scrollbar: { @@ -140,4 +146,11 @@ export default { .monitorItem { width: 100%; } + +.tags { + padding-left: 62px; + display: flex; + flex-wrap: wrap; + gap: 0; +} </style> diff --git a/src/components/Tag.vue b/src/components/Tag.vue new file mode 100644 index 000000000..22ae8c3b0 --- /dev/null +++ b/src/components/Tag.vue @@ -0,0 +1,68 @@ +<template> + <div class="tag-wrapper rounded d-inline-flex" + :class="{ 'px-3': size == 'normal', + 'py-1': size == 'normal', + 'm-2': size == 'normal', + 'px-2': size == 'sm', + 'py-0': size == 'sm', + 'm-1': size == 'sm', + }" + :style="{ backgroundColor: item.color, fontSize: size == 'sm' ? '0.7em' : '1em' }" + > + <span class="tag-text">{{ displayText }}</span> + <span v-if="remove != null" class="ps-1 btn-remove" @click="remove(item)"> + <font-awesome-icon icon="times" /> + </span> + </div> +</template> + +<script> +export default { + props: { + item: { + type: Object, + required: true, + }, + remove: { + type: Function, + default: null, + }, + size: { + type: String, + default: "normal", + } + }, + computed: { + displayText() { + if (this.item.value == "") { + return this.item.name; + } else { + return `${this.item.name}: ${this.item.value}`; + } + } + } +} +</script> + +<style scoped> +.tag-wrapper { + color: white; +} + +.tag-text { + padding-bottom: 1px !important; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.btn-remove { + font-size: 0.9em; + line-height: 24px; + opacity: 0.3; +} + +.btn-remove:hover { + opacity: 1; +} +</style> diff --git a/src/components/TagsManager.vue b/src/components/TagsManager.vue new file mode 100644 index 000000000..4913eb091 --- /dev/null +++ b/src/components/TagsManager.vue @@ -0,0 +1,313 @@ +<template> + <div> + <h4 class="mb-3">{{ $t("Tags") }}</h4> + <div class="mb-3 p-1"> + <tag + v-for="item in selectedTags" + :key="item.id" + :item="item" + :remove="deleteTag" + /> + </div> + <div> + <vue-multiselect + v-model="newDraftTag.select" + class="mb-2" + :options="tagOptions" + :multiple="false" + :searchable="true" + :placeholder="$t('Add New below or Select...')" + track-by="id" + label="name" + > + <template #option="{ option }"> + <div class="mx-2 py-1 px-3 rounded d-inline-flex" + style="margin-top: -5px; margin-bottom: -5px; height: 24px;" + :style="{ color: textColor(option), backgroundColor: option.color + ' !important' }" + > + <span> + {{ option.name }}</span> + </div> + </template> + <template #singleLabel="{ option }"> + <div class="py-1 px-3 rounded d-inline-flex" + style="height: 24px;" + :style="{ color: textColor(option), backgroundColor: option.color + ' !important' }" + > + <span>{{ option.name }}</span> + </div> + </template> + </vue-multiselect> + <div v-if="newDraftTag.select?.id == null" class="d-flex mb-2"> + <div class="w-50 pe-2"> + <input v-model="newDraftTag.name" class="form-control" :class="{'is-invalid': newDraftTag.nameInvalid}" placeholder="name" /> + <div class="invalid-feedback"> + {{ $t("Tag with this name already exist.") }} + </div> + </div> + <div class="w-50 ps-2"> + <vue-multiselect + v-model="newDraftTag.color" + :options="colorOptions" + :multiple="false" + :searchable="true" + :placeholder="$t('color')" + track-by="color" + label="name" + select-label="" + deselect-label="" + > + <template #option="{ option }"> + <div class="mx-2 py-1 px-3 rounded d-inline-flex" + style="height: 24px; color: white;" + :style="{ backgroundColor: option.color + ' !important' }" + > + <span>{{ option.name }}</span> + </div> + </template> + <template #singleLabel="{ option }"> + <div class="py-1 px-3 rounded d-inline-flex" + style="height: 24px; color: white;" + :style="{ backgroundColor: option.color + ' !important' }" + > + <span>{{ option.name }}</span> + </div> + </template> + </vue-multiselect> + </div> + </div> + <input v-model="newDraftTag.value" class="form-control mb-2" :placeholder="$t('value (optional)')" /> + <div class="mb-2"> + <button + type="button" + class="btn btn-secondary float-end" + :disabled="processing || newDraftTag.invalid" + @click.stop="addDraftTag" + > + {{ $t("Add") }} + </button> + </div> + </div> + </div> +</template> + +<script> +import VueMultiselect from "vue-multiselect"; +import Tag from "../components/Tag.vue"; +import { useToast } from "vue-toastification" +const toast = useToast() + +export default { + components: { + Tag, + VueMultiselect, + }, + props: { + preSelectedTags: { + type: Array, + default: () => [], + }, + }, + data() { + return { + existingTags: [], + processing: false, + newTags: [], + deleteTags: [], + newDraftTag: { + name: null, + select: null, + color: null, + value: "", + invalid: true, + nameInvalid: false, + }, + }; + }, + computed: { + tagOptions() { + return this.existingTags; + }, + selectedTags() { + return this.preSelectedTags.concat(this.newTags).filter(tag => !this.deleteTags.find(monitorTag => monitorTag.id == tag.id)); + }, + colorOptions() { + return [ + { name: this.$t("Gray"), + color: "#4B5563" }, + { name: this.$t("Red"), + color: "#DC2626" }, + { name: this.$t("Orange"), + color: "#D97706" }, + { name: this.$t("Green"), + color: "#059669" }, + { name: this.$t("Blue"), + color: "#2563EB" }, + { name: this.$t("Indigo"), + color: "#4F46E5" }, + { name: this.$t("Purple"), + color: "#7C3AED" }, + { name: this.$t("Pink"), + color: "#DB2777" }, + ] + } + }, + watch: { + "newDraftTag.select": function (newSelected) { + this.newDraftTag.select = newSelected; + this.validateDraftTag(); + }, + "newDraftTag.name": function (newName) { + this.newDraftTag.name = newName.trim(); + this.validateDraftTag(); + }, + "newDraftTag.color": function (newColor) { + this.newDraftTag.color = newColor; + this.validateDraftTag(); + }, + }, + mounted() { + this.getExistingTags(); + }, + methods: { + getExistingTags() { + this.$root.getSocket().emit("getTags", (res) => { + if (res.ok) { + this.existingTags = res.tags; + } else { + toast.error(res.msg) + } + }); + }, + deleteTag(item) { + if (item.new) { + // Undo Adding a new Tag + this.newTags = this.newTags.filter(tag => tag.name != item.name && tag.value != item.value); + } else { + // Remove an Existing Tag + this.deleteTags.push(item); + } + }, + validateDraftTag() { + if (this.newDraftTag.select != null) { + // Select an existing tag, no need to validate + this.newDraftTag.invalid = false; + } else if (this.existingTags.filter(tag => tag.name === this.newDraftTag.name).length > 0) { + // Try to create new tag with existing name + this.newDraftTag.nameInvalid = true; + this.newDraftTag.invalid = true; + } else if (this.newDraftTag.color == null || this.newDraftTag.name === "") { + // Missing form inputs + this.newDraftTag.nameInvalid = false; + this.newDraftTag.invalid = true; + } else { + // Looks valid + this.newDraftTag.invalid = false; + this.newDraftTag.nameInvalid = false; + } + }, + textColor(option) { + if (option.color) { + return "white"; + } else { + return this.$root.theme === "light" ? "var(--bs-body-color)" : "inherit"; + } + }, + addDraftTag() { + console.log("Adding Draft Tag: ", this.newDraftTag); + if (this.newDraftTag.select != null) { + // Add an existing Tag + this.newTags.push({ + id: this.newDraftTag.select.id, + color: this.newDraftTag.select.color, + name: this.newDraftTag.select.name, + value: this.newDraftTag.value, + new: true, + }) + } else { + // Add new Tag + this.newTags.push({ + color: this.newDraftTag.color.color, + name: this.newDraftTag.name, + value: this.newDraftTag.value, + new: true, + }) + } + }, + addTagAsync(newTag) { + return new Promise((resolve) => { + this.$root.getSocket().emit("addTag", newTag, resolve); + }); + }, + addMonitorTagAsync(tagId, monitorId, value) { + return new Promise((resolve) => { + this.$root.getSocket().emit("addMonitorTag", tagId, monitorId, value, resolve); + }); + }, + deleteMonitorTagAsync(tagId, monitorId) { + return new Promise((resolve) => { + this.$root.getSocket().emit("deleteMonitorTag", tagId, monitorId, resolve); + }); + }, + async submit(monitorId) { + console.log(`Submitting tag changes for monitor ${monitorId}...`); + this.processing = true; + + for (const newTag of this.newTags) { + let tagId; + if (newTag.id == null) { + let newTagResult; + await this.addTagAsync(newTag).then((res) => { + if (!res.ok) { + toast.error(res.msg); + newTagResult = false; + } + newTagResult = res.tag; + }); + if (!newTagResult) { + // abort + this.processing = false; + return; + } + tagId = newTagResult.id; + } else { + tagId = newTag.id; + } + + let newMonitorTagResult; + await this.addMonitorTagAsync(tagId, monitorId, newTag.value).then((res) => { + if (!res.ok) { + toast.error(res.msg); + newMonitorTagResult = false; + } + newMonitorTagResult = true; + }); + if (!newMonitorTagResult) { + // abort + this.processing = false; + return; + } + } + + for (const deleteTag of this.deleteTags) { + let deleteMonitorTagResult; + await this.deleteMonitorTagAsync(deleteTag.tag_id, deleteTag.monitor_id).then((res) => { + if (!res.ok) { + toast.error(res.msg); + deleteMonitorTagResult = false; + } + deleteMonitorTagResult = true; + }); + if (!deleteMonitorTagResult) { + // abort + this.processing = false; + return; + } + } + + this.getExistingTags(); + this.processing = false; + } + }, +}; +</script> diff --git a/src/icon.js b/src/icon.js index 58583f0f8..56674f741 100644 --- a/src/icon.js +++ b/src/icon.js @@ -1,10 +1,37 @@ -import { library } from "@fortawesome/fontawesome-svg-core" -import { faCog, faEdit, faPlus, faPause, faPlay, faTachometerAlt, faTrash, faList, faArrowAltCircleUp, faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons" +import { library } from "@fortawesome/fontawesome-svg-core"; +import { + faArrowAltCircleUp, + faCog, + faEdit, + faEye, + faEyeSlash, + faList, + faPause, + faPlay, + faPlus, + faTachometerAlt, + faTimes, + faTrash +} from "@fortawesome/free-solid-svg-icons"; //import { fa } from '@fortawesome/free-regular-svg-icons' -import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome" +import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; // Add Free Font Awesome Icons here // https://fontawesome.com/v5.15/icons?d=gallery&p=2&s=solid&m=free -library.add(faCog, faEdit, faPlus, faPause, faPlay, faTachometerAlt, faTrash, faList, faArrowAltCircleUp, faEye, faEyeSlash); +library.add( + faArrowAltCircleUp, + faCog, + faEdit, + faEye, + faEyeSlash, + faList, + faPause, + faPlay, + faPlus, + faTachometerAlt, + faTimes, + faTrash, +); + +export { FontAwesomeIcon }; -export { FontAwesomeIcon } diff --git a/src/mixins/socket.js b/src/mixins/socket.js index ea55f5a88..da6361486 100644 --- a/src/mixins/socket.js +++ b/src/mixins/socket.js @@ -266,6 +266,10 @@ export default { socket.emit("twoFAStatus", callback) }, + getMonitorList(callback) { + socket.emit("getMonitorList", callback) + }, + add(monitor, callback) { socket.emit("add", monitor, callback) }, diff --git a/src/pages/Details.vue b/src/pages/Details.vue index 9092b1792..c992d240b 100644 --- a/src/pages/Details.vue +++ b/src/pages/Details.vue @@ -2,6 +2,9 @@ <transition name="slide-fade" appear> <div v-if="monitor"> <h1> {{ monitor.name }}</h1> + <div class="tags"> + <Tag v-for="tag in monitor.tags" :key="tag.id" :item="tag" :size="'sm'" /> + </div> <p class="url"> <a v-if="monitor.type === 'http' || monitor.type === 'keyword' " :href="monitor.url" target="_blank">{{ monitor.url }}</a> <span v-if="monitor.type === 'port'">TCP Ping {{ monitor.hostname }}:{{ monitor.port }}</span> @@ -213,6 +216,7 @@ import CountUp from "../components/CountUp.vue"; import Uptime from "../components/Uptime.vue"; import Pagination from "v-pagination-3"; const PingChart = defineAsyncComponent(() => import("../components/PingChart.vue")); +import Tag from "../components/Tag.vue"; export default { components: { @@ -224,6 +228,7 @@ export default { Status, Pagination, PingChart, + Tag, }, data() { return { @@ -503,4 +508,12 @@ table { } } +.tags { + margin-bottom: 0.5rem; +} + +.tags > div:first-child { + margin-left: 0 !important; +} + </style> diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index f98bb7560..d87bb4dd4 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -158,6 +158,10 @@ </div> </template> + <div class="my-3"> + <tags-manager ref="tagsManager" :pre-selected-tags="monitor.tags"></tags-manager> + </div> + <div class="mt-5 mb-1"> <button class="btn btn-primary" type="submit" :disabled="processing">{{ $t("Save") }}</button> </div> @@ -197,6 +201,7 @@ <script> import NotificationDialog from "../components/NotificationDialog.vue"; +import TagsManager from "../components/TagsManager.vue"; import { useToast } from "vue-toastification" import VueMultiselect from "vue-multiselect" import { isDev } from "../util.ts"; @@ -205,6 +210,7 @@ const toast = useToast() export default { components: { NotificationDialog, + TagsManager, VueMultiselect, }, @@ -317,22 +323,28 @@ export default { }, - submit() { + async submit() { this.processing = true; if (this.isAdd) { - this.$root.add(this.monitor, (res) => { - this.processing = false; + this.$root.add(this.monitor, async (res) => { if (res.ok) { + await this.$refs.tagsManager.submit(res.monitorID); + toast.success(res.msg); + this.processing = false; + this.$root.getMonitorList(); this.$router.push("/dashboard/" + res.monitorID) } else { toast.error(res.msg); + this.processing = false; } }) } else { + await this.$refs.tagsManager.submit(this.monitor.id); + this.$root.getSocket().emit("editMonitor", this.monitor, (res) => { this.processing = false; this.$root.toastRes(res) @@ -357,6 +369,8 @@ export default { .multiselect__tags { border-radius: 1.5rem; border: 1px solid #ced4da; + min-height: 38px; + padding: 6px 40px 0 8px; } .multiselect--active .multiselect__tags { @@ -373,9 +387,25 @@ export default { .multiselect__tag { border-radius: 50rem; + margin-bottom: 0; + padding: 6px 26px 6px 10px; background: $primary !important; } + .multiselect__placeholder { + font-size: 1rem; + padding-left: 6px; + padding-top: 0; + padding-bottom: 0; + margin-bottom: 0; + opacity: 0.67; + } + + .multiselect__input, .multiselect__single { + line-height: 14px; + margin-bottom: 0; + } + .dark { .multiselect__tag { color: $dark-font-color2;