WIP: Add tags functionality

WIP: add color column, show tags

WIP: Improve TagsManager styling & workflow

WIP: Improve styling & validation, use translation

WIP: Complete TagsManager functionality

WIP: Add tags display in monitorList & Details

Fix: update tags list after edit

Fix: slightly improve tags styling

Fix: Improve mobile UI

Fix: Fix tags not showing on create monitor

Fix: bring existingTags inside tagsManager

Fix: remove unused tags prop

Fix: Fix formatting, bump db version
This commit is contained in:
Nelson Chan 2021-08-26 18:55:19 +08:00
parent 50175b733c
commit 6e3a904aaa
12 changed files with 681 additions and 9 deletions

19
db/patch10.sql Normal file
View File

@ -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);

View File

@ -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;

View File

@ -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,
};
}

13
server/model/tag.js Normal file
View File

@ -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;

View File

@ -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)

View File

@ -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>

68
src/components/Tag.vue Normal file
View File

@ -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>

View File

@ -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>

View File

@ -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 }

View File

@ -266,6 +266,10 @@ export default {
socket.emit("twoFAStatus", callback)
},
getMonitorList(callback) {
socket.emit("getMonitorList", callback)
},
add(monitor, callback) {
socket.emit("add", monitor, callback)
},

View File

@ -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>

View File

@ -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;