Merge pull request #278 from chakflying/tags

Monitor: Tags with metadata
pull/402/head
Louis Lam 3 years ago committed by GitHub
commit 069c811af8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

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

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

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

@ -518,6 +518,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)
@ -612,6 +628,160 @@ 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, value, callback) => {
try {
checkLogin(socket)
await R.exec("DELETE FROM monitor_tag WHERE tag_id = ? AND monitor_id = ? AND value = ?", [
tagID,
monitorID,
value,
])
// 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)

@ -1,44 +1,69 @@
<template>
<div class="shadow-box list mb-3" :class="{ scrollbar: scrollbar }">
<div v-if="Object.keys($root.monitorList).length === 0" class="text-center mt-3">
{{ $t("No Monitors, please") }} <router-link to="/add">{{ $t("add one") }}</router-link>
<div class="shadow-box mb-3">
<div class="list-header">
<div class="placeholder"></div>
<div class="search-wrapper">
<a v-if="searchText == ''" class="search-icon">
<font-awesome-icon icon="search" />
</a>
<a v-if="searchText != ''" class="search-icon" @click="clearSearchText">
<font-awesome-icon icon="times" />
</a>
<input v-model="searchText" class="form-control search-input" :placeholder="$t('Search...')" />
</div>
</div>
<div class="list" :class="{ scrollbar: scrollbar }">
<div v-if="Object.keys($root.monitorList).length === 0" class="text-center mt-3">
{{ $t("No Monitors, please") }} <router-link to="/add">{{ $t("add one") }}</router-link>
</div>
<router-link v-for="(item, index) in sortedMonitorList" :key="index" :to="monitorURL(item.id)" class="item" :class="{ 'disabled': ! item.active }">
<div class="row">
<div class="col-6 col-md-8 small-padding" :class="{ 'monitorItem': $root.userHeartbeatBar == 'bottom' || $root.userHeartbeatBar == 'none' }">
<div class="info">
<Uptime :monitor="item" type="24" :pill="true" />
{{ item.name }}
<router-link v-for="(item, index) in sortedMonitorList" :key="index" :to="monitorURL(item.id)" class="item" :class="{ 'disabled': ! item.active }">
<div class="row">
<div class="col-6 col-md-8 small-padding" :class="{ 'monitorItem': $root.userHeartbeatBar == 'bottom' || $root.userHeartbeatBar == 'none' }">
<div class="info">
<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" />
</div>
</div>
<div v-show="$root.userHeartbeatBar == 'normal'" :key="$root.userHeartbeatBar" class="col-6 col-md-4">
<HeartbeatBar size="small" :monitor-id="item.id" />
</div>
</div>
<div v-if="$root.userHeartbeatBar == 'bottom'" class="row">
<div class="col-12">
<HeartbeatBar size="small" :monitor-id="item.id" />
<div v-if="$root.userHeartbeatBar == 'bottom'" class="row">
<div class="col-12">
<HeartbeatBar size="small" :monitor-id="item.id" />
</div>
</div>
</div>
</router-link>
</router-link>
</div>
</div>
</template>
<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: {
type: Boolean,
},
},
data() {
return {
searchText: "",
}
},
computed: {
sortedMonitorList() {
let result = Object.values(this.$root.monitorList);
@ -68,6 +93,17 @@ export default {
return m1.name.localeCompare(m2.name);
})
// Simple filter by search text
// finds monitor name, tag name or tag value
if (this.searchText != "") {
const loweredSearchText = this.searchText.toLowerCase();
result = result.filter(monitor => {
return monitor.name.toLowerCase().includes(loweredSearchText)
|| monitor.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText)
|| tag.value?.toLowerCase().includes(loweredSearchText))
})
}
return result;
},
},
@ -75,6 +111,9 @@ export default {
monitorURL(id) {
return "/dashboard/" + id;
},
clearSearchText() {
this.searchText = "";
}
},
}
</script>
@ -87,6 +126,43 @@ export default {
padding-right: 5px !important;
}
.list-header {
border-bottom: 1px solid #dee2e6;
border-radius: 10px 10px 0 0;
margin: -10px;
margin-bottom: 10px;
padding: 10px;
display: flex;
justify-content: space-between;
.dark & {
background-color: #161b22;
border-bottom: 0;
}
}
@media (max-width: 770px) {
.list-header {
margin: -20px;
margin-bottom: 10px;
padding: 5px;
}
}
.search-wrapper {
display: flex;
align-items: center;
}
.search-icon {
padding: 10px;
color: #c0c0c0;
}
.search-input {
max-width: 15em;
}
.list {
&.scrollbar {
min-height: calc(100vh - 240px);
@ -140,4 +216,11 @@ export default {
.monitorItem {
width: 100%;
}
.tags {
padding-left: 62px;
display: flex;
flex-wrap: wrap;
gap: 0;
}
</style>

@ -0,0 +1,73 @@
<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 lang="scss" scoped>
.tag-wrapper {
color: white;
opacity: 0.85;
.dark & {
opacity: 1;
}
}
.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>

@ -0,0 +1,405 @@
<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 class="p-1">
<button
type="button"
class="btn btn-outline-secondary btn-add"
:disabled="processing"
@click.stop="showAddDialog"
>
<font-awesome-icon class="me-1" icon="plus" /> {{ $t("Add") }}
</button>
</div>
<div ref="modal" class="modal fade" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-body">
<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?.name == null" class="d-flex mb-2">
<div class="w-50 pe-2">
<input v-model="newDraftTag.name" class="form-control"
:class="{'is-invalid': validateDraftTag.nameInvalid}"
:placeholder="$t('name')"
@keydown.enter.prevent="onEnter"
/>
<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>
<div class="mb-2">
<input v-model="newDraftTag.value" class="form-control"
:class="{'is-invalid': validateDraftTag.valueInvalid}"
:placeholder="$t('value (optional)')"
@keydown.enter.prevent="onEnter"
/>
<div class="invalid-feedback">
{{ $t("Tag with this value already exist.") }}
</div>
</div>
<div class="mb-2">
<button
type="button"
class="btn btn-secondary float-end"
:disabled="processing || validateDraftTag.invalid"
@click.stop="addDraftTag"
>
{{ $t("Add") }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Modal } from "bootstrap";
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 {
modal: null,
existingTags: [],
processing: false,
newTags: [],
deleteTags: [],
newDraftTag: {
name: null,
select: null,
color: null,
value: "",
invalid: true,
nameInvalid: false,
},
};
},
computed: {
tagOptions() {
const tagOptions = this.existingTags;
for (const tag of this.newTags) {
if (!tagOptions.find(t => t.name == tag.name && t.color == tag.color)) {
tagOptions.push(tag);
}
}
return tagOptions;
},
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" },
]
},
validateDraftTag() {
let nameInvalid = false;
let valueInvalid = false;
let invalid = true;
if (this.deleteTags.find(tag => tag.name == this.newDraftTag.select?.name && tag.value == this.newDraftTag.value)) {
// Undo removing a Tag
nameInvalid = false;
valueInvalid = false;
invalid = false;
} else if (this.existingTags.filter(tag => tag.name === this.newDraftTag.name).length > 0) {
// Try to create new tag with existing name
nameInvalid = true;
invalid = true;
} else if (this.newTags.concat(this.preSelectedTags).filter(tag => (
tag.name == this.newDraftTag.select?.name && tag.value == this.newDraftTag.value
) || (
tag.name == this.newDraftTag.name && tag.value == this.newDraftTag.value
)).length > 0) {
// Try to add a tag with existing name and value
valueInvalid = true;
invalid = true;
} else if (this.newDraftTag.select != null) {
// Select an existing tag, no need to validate
invalid = false;
valueInvalid = false;
} else if (this.newDraftTag.color == null || this.newDraftTag.name === "") {
// Missing form inputs
nameInvalid = false;
invalid = true;
} else {
// Looks valid
invalid = false;
nameInvalid = false;
valueInvalid = false;
}
return {
invalid,
nameInvalid,
valueInvalid,
}
},
},
mounted() {
this.modal = new Modal(this.$refs.modal);
this.getExistingTags();
},
methods: {
showAddDialog() {
this.modal.show();
},
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);
}
},
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) {
if (this.deleteTags.find(tag => tag.name == this.newDraftTag.select.name && tag.value == this.newDraftTag.value)) {
// Undo removing a tag
this.deleteTags = this.deleteTags.filter(tag => !(tag.name == this.newDraftTag.select.name && tag.value == this.newDraftTag.value));
} else {
// 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.trim(),
value: this.newDraftTag.value,
new: true,
})
}
this.clearDraftTag();
},
clearDraftTag() {
this.newDraftTag = {
name: null,
select: null,
color: null,
value: "",
invalid: true,
nameInvalid: false,
};
this.modal.hide();
},
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, value) {
return new Promise((resolve) => {
this.$root.getSocket().emit("deleteMonitorTag", tagId, monitorId, value, resolve);
});
},
onEnter() {
if (!this.validateDraftTag.invalid) {
this.addDraftTag();
}
},
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) {
// Create a New Tag
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;
// Assign the new ID to the tags of the same name & color
this.newTags.map(tag => {
if (tag.name == newTag.name && tag.color == newTag.color) {
tag.id = newTagResult.id;
}
})
} else {
tagId = newTag.id;
}
let newMonitorTagResult;
// Assign tag to monitor
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, deleteTag.value).then((res) => {
if (!res.ok) {
toast.error(res.msg);
deleteMonitorTagResult = false;
}
deleteMonitorTagResult = true;
});
if (!deleteMonitorTagResult) {
// abort
this.processing = false;
return;
}
}
this.getExistingTags();
this.newTags = [];
this.deleteTags = [];
this.processing = false;
}
},
};
</script>
<style scoped>
.btn-add {
width: 100%;
}
.modal-body {
padding: 1.5rem;
}
</style>

@ -1,10 +1,39 @@
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,
faSearch,
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,
faSearch,
faTachometerAlt,
faTimes,
faTrash,
);
export { FontAwesomeIcon };
export { FontAwesomeIcon }

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

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

@ -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,25 +323,32 @@ 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)
this.$root.toastRes(res);
this.init();
})
}
},
@ -357,6 +370,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 +388,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;

Loading…
Cancel
Save