summaryrefslogtreecommitdiffstats
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/APIKeyDialog.vue233
-rw-r--r--src/components/ActionInput.vue93
-rw-r--r--src/components/ActionSelect.vue100
-rw-r--r--src/components/BadgeGeneratorDialog.vue306
-rw-r--r--src/components/CertificateInfo.vue54
-rw-r--r--src/components/CertificateInfoRow.vue129
-rw-r--r--src/components/Confirm.vue84
-rw-r--r--src/components/CopyableInput.vue142
-rw-r--r--src/components/CountUp.vue79
-rw-r--r--src/components/CreateGroupDialog.vue63
-rw-r--r--src/components/Datetime.vue30
-rw-r--r--src/components/DockerHostDialog.vue198
-rw-r--r--src/components/EditMonitorCondition.vue152
-rw-r--r--src/components/EditMonitorConditionGroup.vue189
-rw-r--r--src/components/EditMonitorConditions.vue149
-rw-r--r--src/components/HeartbeatBar.vue348
-rw-r--r--src/components/HiddenInput.vue93
-rw-r--r--src/components/Login.vue113
-rw-r--r--src/components/MaintenanceTime.vue60
-rw-r--r--src/components/MonitorList.vue485
-rw-r--r--src/components/MonitorListFilter.vue293
-rw-r--r--src/components/MonitorListFilterDropdown.vue137
-rw-r--r--src/components/MonitorListItem.vue256
-rw-r--r--src/components/MonitorSettingDialog.vue125
-rw-r--r--src/components/NotificationDialog.vue352
-rw-r--r--src/components/PingChart.vue610
-rw-r--r--src/components/ProxyDialog.vue224
-rw-r--r--src/components/PublicGroupList.vue273
-rw-r--r--src/components/RemoteBrowserDialog.vue185
-rw-r--r--src/components/ScreenshotDialog.vue52
-rw-r--r--src/components/Status.vue63
-rw-r--r--src/components/Tag.vue87
-rw-r--r--src/components/TagEditDialog.vue485
-rw-r--r--src/components/TagsManager.vue475
-rw-r--r--src/components/ToggleSection.vue69
-rw-r--r--src/components/TwoFADialog.vue225
-rw-r--r--src/components/Uptime.vue103
-rw-r--r--src/components/notifications/46elks.vue48
-rw-r--r--src/components/notifications/AlertNow.vue13
-rw-r--r--src/components/notifications/Alerta.vue14
-rw-r--r--src/components/notifications/AliyunSms.vue25
-rw-r--r--src/components/notifications/Apprise.vue38
-rw-r--r--src/components/notifications/Bark.vue60
-rw-r--r--src/components/notifications/Bitrix24.vue24
-rw-r--r--src/components/notifications/CallMeBot.vue13
-rw-r--r--src/components/notifications/Cellsynt.vue54
-rw-r--r--src/components/notifications/ClickSendSMS.vue37
-rw-r--r--src/components/notifications/DingDing.vue37
-rw-r--r--src/components/notifications/Discord.vue65
-rw-r--r--src/components/notifications/Feishu.vue15
-rw-r--r--src/components/notifications/FlashDuty.vue29
-rw-r--r--src/components/notifications/FreeMobile.vue12
-rw-r--r--src/components/notifications/GoAlert.vue28
-rw-r--r--src/components/notifications/GoogleChat.vue13
-rw-r--r--src/components/notifications/Gorush.vue47
-rw-r--r--src/components/notifications/Gotify.vue30
-rw-r--r--src/components/notifications/GrafanaOncall.vue7
-rw-r--r--src/components/notifications/GtxMessaging.vue49
-rw-r--r--src/components/notifications/HeiiOnCall.vue34
-rw-r--r--src/components/notifications/HomeAssistant.vue40
-rw-r--r--src/components/notifications/Keep.vue42
-rw-r--r--src/components/notifications/Kook.vue33
-rw-r--r--src/components/notifications/Line.vue29
-rw-r--r--src/components/notifications/LineNotify.vue9
-rw-r--r--src/components/notifications/LunaSea.vue33
-rw-r--r--src/components/notifications/Matrix.vue34
-rw-r--r--src/components/notifications/Mattermost.vue32
-rw-r--r--src/components/notifications/Nostr.vue26
-rw-r--r--src/components/notifications/Ntfy.vue82
-rw-r--r--src/components/notifications/Octopush.vue50
-rw-r--r--src/components/notifications/OneBot.vue34
-rw-r--r--src/components/notifications/Onesender.vue81
-rw-r--r--src/components/notifications/Opsgenie.vue36
-rw-r--r--src/components/notifications/PagerDuty.vue45
-rw-r--r--src/components/notifications/PagerTree.vue31
-rw-r--r--src/components/notifications/PromoSMS.vue43
-rw-r--r--src/components/notifications/PushDeer.vue24
-rw-r--r--src/components/notifications/Pushbullet.vue20
-rw-r--r--src/components/notifications/Pushover.vue70
-rw-r--r--src/components/notifications/Pushy.vue24
-rw-r--r--src/components/notifications/RocketChat.vue27
-rw-r--r--src/components/notifications/SIGNL4.vue16
-rw-r--r--src/components/notifications/SMSC.vue43
-rw-r--r--src/components/notifications/SMSEagle.vue40
-rw-r--r--src/components/notifications/SMSManager.vue31
-rw-r--r--src/components/notifications/SMSPartner.vue39
-rw-r--r--src/components/notifications/SMTP.vue149
-rw-r--r--src/components/notifications/SendGrid.vue47
-rw-r--r--src/components/notifications/ServerChan.vue16
-rw-r--r--src/components/notifications/SerwerSMS.vue28
-rw-r--r--src/components/notifications/SevenIO.vue31
-rw-r--r--src/components/notifications/Signal.vue34
-rw-r--r--src/components/notifications/Slack.vue45
-rw-r--r--src/components/notifications/Splunk.vue32
-rw-r--r--src/components/notifications/Squadcast.vue6
-rw-r--r--src/components/notifications/Stackfield.vue13
-rw-r--r--src/components/notifications/Teams.vue18
-rw-r--r--src/components/notifications/TechulusPush.vue81
-rw-r--r--src/components/notifications/Telegram.vue117
-rw-r--r--src/components/notifications/Threema.vue87
-rw-r--r--src/components/notifications/Twilio.vue38
-rw-r--r--src/components/notifications/WPush.vue31
-rw-r--r--src/components/notifications/WeCom.vue12
-rw-r--r--src/components/notifications/Webhook.vue100
-rw-r--r--src/components/notifications/Whapi.vue33
-rw-r--r--src/components/notifications/ZohoCliq.vue18
-rw-r--r--src/components/notifications/index.js147
-rw-r--r--src/components/settings/APIKeys.vue273
-rw-r--r--src/components/settings/About.vue66
-rw-r--r--src/components/settings/Appearance.vue190
-rw-r--r--src/components/settings/Docker.vue48
-rw-r--r--src/components/settings/General.vue283
-rw-r--r--src/components/settings/MonitorHistory.vue155
-rw-r--r--src/components/settings/Notifications.vue218
-rw-r--r--src/components/settings/Proxies.vue48
-rw-r--r--src/components/settings/RemoteBrowsers.vue53
-rw-r--r--src/components/settings/ReverseProxy.vue211
-rw-r--r--src/components/settings/Security.vue228
-rw-r--r--src/components/settings/Tags.vue175
119 files changed, 11851 insertions, 0 deletions
diff --git a/src/components/APIKeyDialog.vue b/src/components/APIKeyDialog.vue
new file mode 100644
index 0000000..a103cf0
--- /dev/null
+++ b/src/components/APIKeyDialog.vue
@@ -0,0 +1,233 @@
+<template>
+ <form @submit.prevent="submit">
+ <div ref="keyaddmodal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">
+ {{ $t("Add API Key") }}
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
+ </div>
+ <div class="modal-body">
+ <!-- Name -->
+ <div class="mb-3">
+ <label for="name" class="form-label">{{ $t("Name") }}</label>
+ <input
+ id="name" v-model="key.name" type="text" class="form-control"
+ required
+ >
+ </div>
+
+ <!-- Expiry -->
+ <div class="my-3">
+ <label class="form-label">{{ $t("Expiry date") }}</label>
+ <div class="d-flex flex-row align-items-center">
+ <div class="col-6">
+ <Datepicker
+ v-model="key.expires"
+ :dark="$root.isDark"
+ :monthChangeOnScroll="false"
+ :minDate="minDate"
+ format="yyyy-MM-dd HH:mm"
+ modelType="yyyy-MM-dd HH:mm:ss"
+ :required="!noExpire"
+ :disabled="noExpire"
+ />
+ </div>
+ <div class="col-6 ms-3">
+ <div class="form-check mb-0">
+ <input
+ id="no-expire" v-model="noExpire" class="form-check-input"
+ type="checkbox"
+ >
+ <label class="form-check-label" for="no-expire">{{
+ $t("Don't expire")
+ }}</label>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button
+ id="monitor-submit-btn" class="btn btn-primary" type="submit"
+ :disabled="processing"
+ >
+ {{ $t("Generate") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div ref="keymodal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">
+ {{ $t("Key Added") }}
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
+ </div>
+
+ <div class="modal-body">
+ <div class="mb-3">
+ {{ $t("apiKeyAddedMsg") }}
+ </div>
+ <div class="mb-3">
+ <CopyableInput v-model="clearKey" disabled="disabled" />
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <button type="button" class="btn btn-primary" data-bs-dismiss="modal">
+ {{ $t('Continue') }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+</template>
+
+<script lang="ts">
+import { Modal } from "bootstrap";
+import dayjs from "dayjs";
+import Datepicker from "@vuepic/vue-datepicker";
+import CopyableInput from "./CopyableInput.vue";
+
+export default {
+ components: {
+ CopyableInput,
+ Datepicker
+ },
+ props: {},
+ // emits: [ "added" ],
+ data() {
+ return {
+ keyaddmodal: null,
+ keymodal: null,
+ processing: false,
+ key: {},
+ dark: (this.$root.theme === "dark"),
+ minDate: this.$root.date(dayjs()) + " 00:00",
+ clearKey: null,
+ noExpire: false,
+ };
+ },
+
+ mounted() {
+ this.keyaddmodal = new Modal(this.$refs.keyaddmodal);
+ this.keymodal = new Modal(this.$refs.keymodal);
+ },
+
+ methods: {
+ /**
+ * Show modal
+ * @returns {void}
+ */
+ show() {
+ this.id = null;
+ this.key = {
+ name: "",
+ expires: this.minDate,
+ active: 1,
+ };
+
+ this.keyaddmodal.show();
+ },
+
+ /**
+ * Submit data to server
+ * @returns {Promise<void>}
+ */
+ async submit() {
+ this.processing = true;
+
+ if (this.noExpire) {
+ this.key.expires = null;
+ }
+
+ this.$root.addAPIKey(this.key, async (res) => {
+ this.keyaddmodal.hide();
+ this.processing = false;
+ if (res.ok) {
+ this.clearKey = res.key;
+ this.keymodal.show();
+ this.clearForm();
+ } else {
+ this.$root.toastError(res.msg);
+ }
+ });
+ },
+
+ /**
+ * Clear Form inputs
+ * @returns {void}
+ */
+ clearForm() {
+ this.key = {
+ name: "",
+ expires: this.minDate,
+ active: 1,
+ };
+ this.noExpire = false;
+ },
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.dark {
+ .modal-dialog .form-text, .modal-dialog p {
+ color: $dark-font-color;
+ }
+}
+
+.shadow-box {
+ padding: 20px;
+}
+
+textarea {
+ min-height: 150px;
+}
+
+.dark-calendar::-webkit-calendar-picker-indicator {
+ filter: invert(1);
+}
+
+.weekday-picker {
+ display: flex;
+ gap: 10px;
+
+ & > div {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 40px;
+
+ .form-check-inline {
+ margin-right: 0;
+ }
+ }
+}
+
+.day-picker {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+
+ & > div {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 40px;
+
+ .form-check-inline {
+ margin-right: 0;
+ }
+ }
+}
+</style>
diff --git a/src/components/ActionInput.vue b/src/components/ActionInput.vue
new file mode 100644
index 0000000..a61e4f9
--- /dev/null
+++ b/src/components/ActionInput.vue
@@ -0,0 +1,93 @@
+<template>
+ <div class="input-group mb-3">
+ <input
+ ref="input"
+ v-model="model"
+ class="form-control"
+ :type="type"
+ :placeholder="placeholder"
+ :disabled="!enabled"
+ >
+ <button type="button" class="btn btn-outline-primary" :aria-label="actionAriaLabel" @click="action()">
+ <font-awesome-icon :icon="icon" />
+ </button>
+ </div>
+</template>
+
+<script>
+/**
+ * Generic input field with a customizable action on the right.
+ * Action is passed in as a function.
+ */
+export default {
+ props: {
+ /**
+ * The value of the input field.
+ */
+ modelValue: {
+ type: String,
+ default: ""
+ },
+ /**
+ * Whether the input field is enabled / disabled.
+ */
+ enabled: {
+ type: Boolean,
+ default: true
+ },
+ /**
+ * Placeholder text for the input field.
+ */
+ placeholder: {
+ type: String,
+ default: ""
+ },
+ /**
+ * The icon displayed in the right button of the input field.
+ * Accepts a Font Awesome icon string identifier.
+ * @example "plus"
+ */
+ icon: {
+ type: String,
+ required: true,
+ },
+ /**
+ * The input type of the input field.
+ * @example "email"
+ */
+ type: {
+ type: String,
+ default: "text",
+ },
+ /**
+ * The action to be performed when the button is clicked.
+ * Action is passed in as a function.
+ */
+ action: {
+ type: Function,
+ default: () => {},
+ },
+ /**
+ * The aria-label of the action button
+ */
+ actionAriaLabel: {
+ type: String,
+ required: true,
+ }
+ },
+ emits: [ "update:modelValue" ],
+ computed: {
+ /**
+ * Send value update to parent on change.
+ */
+ model: {
+ get() {
+ return this.modelValue;
+ },
+ set(value) {
+ this.$emit("update:modelValue", value);
+ }
+ }
+ },
+};
+</script>
diff --git a/src/components/ActionSelect.vue b/src/components/ActionSelect.vue
new file mode 100644
index 0000000..d47154f
--- /dev/null
+++ b/src/components/ActionSelect.vue
@@ -0,0 +1,100 @@
+<template>
+ <div class="input-group mb-3">
+ <select :id="id" ref="select" v-model="model" class="form-select" :disabled="disabled" :required="required">
+ <option v-for="option in options" :key="option" :value="option.value" :disabled="option.disabled">{{ option.label }}</option>
+ </select>
+ <button type="button" class="btn btn-outline-primary" :class="{ disabled: actionDisabled }" :aria-label="actionAriaLabel" @click="action()">
+ <font-awesome-icon :icon="icon" aria-hidden="true" />
+ </button>
+ </div>
+</template>
+
+<script>
+/**
+ * Generic select field with a customizable action on the right.
+ * Action is passed in as a function.
+ */
+export default {
+ props: {
+ options: {
+ type: Array,
+ default: () => [],
+ },
+ /**
+ * The id of the form which will be targeted by a <label for=..
+ */
+ id: {
+ type: String,
+ required: true,
+ },
+ /**
+ * The value of the select field.
+ */
+ modelValue: {
+ type: Number,
+ default: null,
+ },
+ /**
+ * Whether the select field is enabled / disabled.
+ */
+ disabled: {
+ type: Boolean,
+ default: false
+ },
+ /**
+ * The icon displayed in the right button of the select field.
+ * Accepts a Font Awesome icon string identifier.
+ * @example "plus"
+ */
+ icon: {
+ type: String,
+ required: true,
+ },
+ /**
+ * The action to be performed when the button is clicked.
+ * Action is passed in as a function.
+ */
+ action: {
+ type: Function,
+ default: () => {},
+ },
+ /**
+ * The aria-label of the action button
+ */
+ actionAriaLabel: {
+ type: String,
+ required: true,
+ },
+ /**
+ * Whether the action button is disabled.
+ * @example true
+ */
+ actionDisabled: {
+ type: Boolean,
+ default: false
+ },
+ /**
+ * Whether the select field is required.
+ * @example true
+ */
+ required: {
+ type: Boolean,
+ default: false,
+ }
+ },
+ emits: [ "update:modelValue" ],
+ computed: {
+ /**
+ * Send value update to parent on change.
+ */
+ model: {
+ get() {
+ return this.modelValue;
+ },
+ set(value) {
+ this.$emit("update:modelValue", value);
+ }
+ }
+ },
+};
+</script>
diff --git a/src/components/BadgeGeneratorDialog.vue b/src/components/BadgeGeneratorDialog.vue
new file mode 100644
index 0000000..7faa4c5
--- /dev/null
+++ b/src/components/BadgeGeneratorDialog.vue
@@ -0,0 +1,306 @@
+<template>
+ <div ref="BadgeGeneratorModal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">
+ {{ $t("Badge Generator", [monitor.name]) }}
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
+ </div>
+ <div class="modal-body">
+ <div class="mb-3">
+ <label for="type" class="form-label">{{ $t("Badge Type") }}</label>
+ <select id="type" v-model="badge.type" class="form-select">
+ <option value="status">status</option>
+ <option value="uptime">uptime</option>
+ <option value="ping">ping</option>
+ <option value="avg-response">avg-response</option>
+ <option value="cert-exp">cert-exp</option>
+ <option value="response">response</option>
+ </select>
+ </div>
+
+ <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('duration') " class="mb-3">
+ <label for="duration" class="form-label">{{ $t("Badge Duration (in hours)") }}</label>
+ <input id="duration" v-model="badge.duration" type="number" min="0" placeholder="24" class="form-control">
+ </div>
+
+ <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('label') " class="mb-3">
+ <label for="label" class="form-label">{{ $t("Badge Label") }}</label>
+ <input id="label" v-model="badge.label" type="text" class="form-control">
+ </div>
+
+ <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('prefix') " class="mb-3">
+ <label for="prefix" class="form-label">{{ $t("Badge Prefix") }}</label>
+ <input id="prefix" v-model="badge.prefix" type="text" class="form-control">
+ </div>
+
+ <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('suffix') " class="mb-3">
+ <label for="suffix" class="form-label">{{ $t("Badge Suffix") }}</label>
+ <input id="suffix" v-model="badge.suffix" type="text" placeholder="%" class="form-control">
+ </div>
+
+ <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('labelColor') " class="mb-3">
+ <label for="labelColor" class="form-label">{{ $t("Badge Label Color") }}</label>
+ <input id="labelColor" v-model="badge.labelColor" type="text" placeholder="#555" class="form-control">
+ </div>
+
+ <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('color') " class="mb-3">
+ <label for="color" class="form-label">{{ $t("Badge Color") }}</label>
+ <input id="color" v-model="badge.color" type="text" :placeholder="badgeConstants.defaultUpColor" class="form-control">
+ </div>
+
+ <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('labelPrefix') " class="mb-3">
+ <label for="labelPrefix" class="form-label">{{ $t("Badge Label Prefix") }}</label>
+ <input id="labelPrefix" v-model="badge.labelPrefix" type="text" class="form-control">
+ </div>
+
+ <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('labelSuffix') " class="mb-3">
+ <label for="labelSuffix" class="form-label">{{ $t("Badge Label Suffix") }}</label>
+ <input id="labelSuffix" v-model="badge.labelSuffix" type="text" placeholder="h" class="form-control">
+ </div>
+
+ <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('upColor') " class="mb-3">
+ <label for="upColor" class="form-label">{{ $t("Badge Up Color") }}</label>
+ <input id="upColor" v-model="badge.upColor" type="text" class="form-control" :placeholder="badgeConstants.defaultUpColor">
+ </div>
+
+ <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('downColor') " class="mb-3">
+ <label for="downColor" class="form-label">{{ $t("Badge Down Color") }}</label>
+ <input id="downColor" v-model="badge.downColor" type="text" class="form-control" :placeholder="badgeConstants.defaultDownColor">
+ </div>
+
+ <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('pendingColor') " class="mb-3">
+ <label for="pendingColor" class="form-label">{{ $t("Badge Pending Color") }}</label>
+ <input id="pendingColor" v-model="badge.pendingColor" type="text" class="form-control" :placeholder="badgeConstants.defaultPendingColor">
+ </div>
+
+ <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('maintenanceColor') " class="mb-3">
+ <label for="maintenanceColor" class="form-label">{{ $t("Badge Maintenance Color") }}</label>
+ <input id="maintenanceColor" v-model="badge.maintenanceColor" type="text" class="form-control" :placeholder="badgeConstants.defaultMaintenanceColor">
+ </div>
+
+ <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('warnColor') " class="mb-3">
+ <label for="warnColor" class="form-label">{{ $t("Badge Warn Color") }}</label>
+ <input id="warnColor" v-model="badge.warnColor" type="text" class="form-control" :placeholder="badgeConstants.defaultMaintenanceColor">
+ </div>
+
+ <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('warnDays') " class="mb-3">
+ <label for="warnDays" class="form-label">{{ $t("Badge Warn Days") }}</label>
+ <input id="warnDays" v-model="badge.warnDays" type="number" min="0" class="form-control" :placeholder="badgeConstants.defaultCertExpireWarnDays">
+ </div>
+
+ <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('downDays') " class="mb-3">
+ <label for="downDays" class="form-label">{{ $t("Badge Down Days") }}</label>
+ <input id="downDays" v-model="badge.downDays" type="number" min="0" class="form-control" :placeholder="badgeConstants.defaultCertExpireDownDays">
+ </div>
+
+ <div class="mb-3">
+ <label for="style" class="form-label">{{ $t("Badge Style") }}</label>
+ <select id="style" v-model="badge.style" class="form-select">
+ <option value="plastic">plastic</option>
+ <option value="flat">flat</option>
+ <option value="flat-square">flat-square</option>
+ <option value="for-the-badge">for-the-badge</option>
+ <option value="social">social</option>
+ </select>
+ </div>
+
+ <div class="mb-3">
+ <label for="value" class="form-label">{{ $t("Badge value (For Testing only.)") }}</label>
+ <input id="value" v-model="badge.value" type="text" class="form-control">
+ </div>
+
+ <div class="mb-3 pt-3 d-flex justify-content-center">
+ <img :src="badgeURL" :alt="$t('Badge Preview')">
+ </div>
+
+ <div class="my-3">
+ <label for="badge-url" class="form-label">{{ $t("Badge URL") }}</label>
+ <CopyableInput id="badge-url" v-model="badgeURL" type="url" disabled="disabled" />
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <button type="submit" class="btn btn-danger" data-bs-dismiss="modal">
+ {{ $t("Close") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script lang="ts">
+import { Modal } from "bootstrap";
+import CopyableInput from "./CopyableInput.vue";
+import { badgeConstants } from "../util.ts";
+
+export default {
+ components: {
+ CopyableInput
+ },
+ props: {},
+ emits: [],
+ data() {
+ return {
+ model: null,
+ processing: false,
+ monitor: {
+ id: null,
+ name: null,
+ },
+ badge: {
+ type: "status",
+ duration: null,
+ label: null,
+ prefix: null,
+ suffix: null,
+ labelColor: null,
+ color: null,
+ labelPrefix: null,
+ labelSuffix: null,
+ upColor: null,
+ downColor: null,
+ pendingColor: null,
+ maintenanceColor: null,
+ warnColor: null,
+ warnDays: null,
+ downDays: null,
+ style: "flat",
+ value: null,
+ },
+ parameters: {
+ status: [
+ "upLabel",
+ "downLabel",
+ "pendingLabel",
+ "maintenanceLabel",
+ "upColor",
+ "downColor",
+ "pendingColor",
+ "maintenanceColor",
+ ],
+ uptime: [
+ "duration",
+ "labelPrefix",
+ "labelSuffix",
+ "prefix",
+ "suffix",
+ "color",
+ "labelColor",
+ ],
+ ping: [
+ "duration",
+ "labelPrefix",
+ "labelSuffix",
+ "prefix",
+ "suffix",
+ "color",
+ "labelColor",
+ ],
+ "avg-response": [
+ "duration",
+ "labelPrefix",
+ "labelSuffix",
+ "prefix",
+ "suffix",
+ "color",
+ "labelColor",
+ ],
+ "cert-exp": [
+ "labelPrefix",
+ "labelSuffix",
+ "prefix",
+ "suffix",
+ "upColor",
+ "warnColor",
+ "downColor",
+ "warnDays",
+ "downDays",
+ "labelColor",
+ ],
+ response: [
+ "labelPrefix",
+ "labelSuffix",
+ "prefix",
+ "suffix",
+ "color",
+ "labelColor",
+ ],
+ },
+ badgeConstants,
+ };
+ },
+
+ computed: {
+ badgeURL() {
+ if (!this.monitor.id || !this.badge.type) {
+ return;
+ }
+ let badgeURL = this.$root.baseURL + "/api/badge/" + this.monitor.id + "/" + this.badge.type;
+
+ let parameterList = {};
+
+ for (let parameter of this.parameters[this.badge.type] || []) {
+ if (parameter === "duration" && this.badge.duration) {
+ badgeURL += "/" + this.badge.duration;
+ continue;
+ }
+
+ if (this.badge[parameter]) {
+ parameterList[parameter] = this.badge[parameter];
+ }
+ }
+
+ for (let parameter of [ "label", "style", "value" ]) {
+ if (parameter === "style" && this.badge.style === "flat") {
+ continue;
+ }
+
+ if (this.badge[parameter]) {
+ parameterList[parameter] = this.badge[parameter];
+ }
+ }
+
+ if (Object.keys(parameterList).length > 0) {
+ return badgeURL + "?" + new URLSearchParams(parameterList);
+ }
+
+ return badgeURL;
+ },
+ },
+
+ mounted() {
+ this.BadgeGeneratorModal = new Modal(this.$refs.BadgeGeneratorModal);
+ },
+
+ methods: {
+ /**
+ * Setting monitor
+ * @param {number} monitorId ID of monitor
+ * @param {string} monitorName Name of monitor
+ * @returns {void}
+ */
+ show(monitorId, monitorName) {
+ this.monitor = {
+ id: monitorId,
+ name: monitorName,
+ };
+
+ this.BadgeGeneratorModal.show();
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.dark {
+ .modal-dialog .form-text, .modal-dialog p {
+ color: $dark-font-color;
+ }
+}
+</style>
diff --git a/src/components/CertificateInfo.vue b/src/components/CertificateInfo.vue
new file mode 100644
index 0000000..cb1a829
--- /dev/null
+++ b/src/components/CertificateInfo.vue
@@ -0,0 +1,54 @@
+<template>
+ <div>
+ <h4>{{ $t("Certificate Info") }}</h4>
+ {{ $t("Certificate Chain") }}:
+ <div
+ v-if="valid"
+ class="rounded d-inline-flex ms-2 text-white tag-valid"
+ >
+ {{ $t("Valid") }}
+ </div>
+ <div
+ v-if="!valid"
+ class="rounded d-inline-flex ms-2 text-white tag-invalid"
+ >
+ {{ $t("Invalid") }}
+ </div>
+ <certificate-info-row :cert="certInfo" />
+ </div>
+</template>
+
+<script>
+import CertificateInfoRow from "./CertificateInfoRow.vue";
+export default {
+ components: {
+ CertificateInfoRow,
+ },
+ props: {
+ /** Object representing certificate */
+ certInfo: {
+ type: Object,
+ required: true,
+ },
+ /** Is the TLS certificate valid? */
+ valid: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.tag-valid {
+ padding: 2px 25px;
+ background-color: $primary;
+}
+
+.tag-invalid {
+ padding: 2px 25px;
+ background-color: $danger;
+}
+</style>
diff --git a/src/components/CertificateInfoRow.vue b/src/components/CertificateInfoRow.vue
new file mode 100644
index 0000000..8a4a875
--- /dev/null
+++ b/src/components/CertificateInfoRow.vue
@@ -0,0 +1,129 @@
+<template>
+ <div>
+ <div class="d-flex flex-row align-items-center p-1 overflow-hidden">
+ <div class="m-3 ps-3">
+ <div class="cert-icon">
+ <font-awesome-icon icon="file" />
+ <font-awesome-icon class="award-icon" icon="award" />
+ </div>
+ </div>
+ <div class="m-3">
+ <table class="text-start">
+ <tbody>
+ <tr class="my-3">
+ <td class="px-3">{{ $t("Subject:") }}</td>
+ <td>{{ formatSubject(cert.subject) }}</td>
+ </tr>
+ <tr class="my-3">
+ <td class="px-3">{{ $t("Valid To:") }}</td>
+ <td><Datetime :value="cert.validTo" /></td>
+ </tr>
+ <tr class="my-3">
+ <td class="px-3">{{ $t("Days Remaining:") }}</td>
+ <td>{{ cert.daysRemaining }}</td>
+ </tr>
+ <tr class="my-3">
+ <td class="px-3">{{ $t("Issuer:") }}</td>
+ <td>{{ formatSubject(cert.issuer) }}</td>
+ </tr>
+ <tr class="my-3">
+ <td class="px-3">{{ $t("Fingerprint:") }}</td>
+ <td>{{ cert.fingerprint }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ <div class="d-flex">
+ <font-awesome-icon
+ v-if="cert.issuerCertificate"
+ class="m-2 ps-6 link-icon"
+ icon="link"
+ />
+ </div>
+ <certificate-info-row
+ v-if="cert.issuerCertificate"
+ :cert="cert.issuerCertificate"
+ />
+ </div>
+</template>
+
+<script>
+import Datetime from "../components/Datetime.vue";
+export default {
+ name: "CertificateInfoRow",
+ components: {
+ Datetime,
+ },
+ props: {
+ /** Object representing certificate */
+ cert: {
+ type: Object,
+ required: true,
+ },
+ },
+ methods: {
+ /**
+ * Format the subject of the certificate
+ * @param {object} subject Object representing the certificates
+ * subject
+ * @returns {string} Certificate subject
+ */
+ formatSubject(subject) {
+ if (subject.O && subject.CN && subject.C) {
+ return `${subject.CN} - ${subject.O} (${subject.C})`;
+ } else if (subject.O && subject.CN) {
+ return `${subject.CN} - ${subject.O}`;
+ } else if (subject.CN) {
+ return subject.CN;
+ } else {
+ return "no info";
+ }
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+table {
+ overflow: hidden;
+}
+
+.cert-icon {
+ position: relative;
+ font-size: 70px;
+ color: $link-color;
+ opacity: 0.5;
+
+ .dark & {
+ color: $dark-font-color;
+ opacity: 0.3;
+ }
+}
+
+.award-icon {
+ position: absolute;
+ font-size: 0.5em;
+ bottom: 20%;
+ left: 12%;
+ color: white;
+
+ .dark & {
+ color: $dark-bg;
+ }
+}
+
+.link-icon {
+ font-size: 20px;
+ margin-left: 50px !important;
+ color: $link-color;
+ opacity: 0.5;
+
+ .dark & {
+ color: $dark-font-color;
+ opacity: 0.3;
+ }
+}
+</style>
diff --git a/src/components/Confirm.vue b/src/components/Confirm.vue
new file mode 100644
index 0000000..e855b67
--- /dev/null
+++ b/src/components/Confirm.vue
@@ -0,0 +1,84 @@
+<template>
+ <div ref="modal" class="modal fade" tabindex="-1">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 id="exampleModalLabel" class="modal-title">
+ {{ title || $t("Confirm") }}
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
+ </div>
+ <div class="modal-body">
+ <slot />
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn" :class="btnStyle" data-bs-dismiss="modal" @click="yes">
+ {{ yesText }}
+ </button>
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @click="no">
+ {{ noText }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import { Modal } from "bootstrap";
+
+export default {
+ props: {
+ /** Style of button */
+ btnStyle: {
+ type: String,
+ default: "btn-primary",
+ },
+ /** Text to use as yes */
+ yesText: {
+ type: String,
+ default: "Yes", // TODO: No idea what to translate this
+ },
+ /** Text to use as no */
+ noText: {
+ type: String,
+ default: "No",
+ },
+ /** Title to show on modal. Defaults to translated version of "Config" */
+ title: {
+ type: String,
+ default: null,
+ }
+ },
+ emits: [ "yes", "no" ],
+ data: () => ({
+ modal: null,
+ }),
+ mounted() {
+ this.modal = new Modal(this.$refs.modal);
+ },
+ methods: {
+ /**
+ * Show the confirm dialog
+ * @returns {void}
+ */
+ show() {
+ this.modal.show();
+ },
+ /**
+ * @fires string "yes" Notify the parent when Yes is pressed
+ * @returns {void}
+ */
+ yes() {
+ this.$emit("yes");
+ },
+ /**
+ * @fires string "no" Notify the parent when No is pressed
+ * @returns {void}
+ */
+ no() {
+ this.$emit("no");
+ }
+ },
+};
+</script>
diff --git a/src/components/CopyableInput.vue b/src/components/CopyableInput.vue
new file mode 100644
index 0000000..951fded
--- /dev/null
+++ b/src/components/CopyableInput.vue
@@ -0,0 +1,142 @@
+<template>
+ <div class="input-group">
+ <input
+ :id="id"
+ ref="input"
+ v-model="model"
+ :type="type"
+ class="form-control"
+ :placeholder="placeholder"
+ :autocomplete="autocomplete"
+ :required="required"
+ :readonly="readonly"
+ :disabled="disabled"
+ >
+
+ <!-- A hidden textarea for copying text on non-https -->
+ <textarea ref="hiddenTextarea" style="position: fixed; left: -999999px; top: -999999px;"></textarea>
+
+ <a class="btn btn-outline-primary" @click="copyToClipboard(model)">
+ <font-awesome-icon :icon="icon" />
+ </a>
+ </div>
+</template>
+
+<script>
+
+let timeout;
+
+export default {
+ props: {
+ /** ID of this input */
+ id: {
+ type: String,
+ default: ""
+ },
+ /** Type of input */
+ type: {
+ type: String,
+ default: "text"
+ },
+ /** The value of the input */
+ modelValue: {
+ type: String,
+ default: ""
+ },
+ /** A placeholder to use */
+ placeholder: {
+ type: String,
+ default: ""
+ },
+ /** Should the field auto complete */
+ autocomplete: {
+ type: String,
+ default: undefined,
+ },
+ /** Is the input required? */
+ required: {
+ type: Boolean
+ },
+ /** Should the input be read only? */
+ readonly: {
+ type: String,
+ default: undefined,
+ },
+ /** Is the input disabled? */
+ disabled: {
+ type: String,
+ default: undefined,
+ },
+ },
+ emits: [ "update:modelValue" ],
+ data() {
+ return {
+ visibility: "password",
+ icon: "copy",
+ };
+ },
+ computed: {
+ model: {
+ get() {
+ return this.modelValue;
+ },
+ set(value) {
+ this.$emit("update:modelValue", value);
+ }
+ }
+ },
+ created() {
+
+ },
+ methods: {
+
+ /**
+ * Show the input
+ * @returns {void}
+ */
+ showInput() {
+ this.visibility = "text";
+ },
+
+ /**
+ * Hide the input
+ * @returns {void}
+ */
+ hideInput() {
+ this.visibility = "password";
+ },
+
+ /**
+ * Copy the provided text to the users clipboard
+ * @param {string} textToCopy Text to copy to clipboard
+ * @returns {Promise<void>}
+ */
+ copyToClipboard(textToCopy) {
+ this.icon = "check";
+
+ clearTimeout(timeout);
+ timeout = setTimeout(() => {
+ this.icon = "copy";
+ }, 3000);
+
+ // navigator clipboard api needs a secure context (https)
+ // For http, use the text area method (else part)
+ if (navigator.clipboard && window.isSecureContext) {
+ // navigator clipboard api method'
+ return navigator.clipboard.writeText(textToCopy);
+ } else {
+ // text area method
+ let textArea = this.$refs.hiddenTextarea;
+ textArea.value = textToCopy;
+ textArea.focus();
+ textArea.select();
+ return new Promise((res, rej) => {
+ // here the magic happens
+ document.execCommand("copy") ? res() : rej();
+ });
+ }
+ }
+
+ }
+};
+</script>
diff --git a/src/components/CountUp.vue b/src/components/CountUp.vue
new file mode 100644
index 0000000..3bd0107
--- /dev/null
+++ b/src/components/CountUp.vue
@@ -0,0 +1,79 @@
+<template>
+ <span v-if="isNum" ref="output">{{ outputFixed }}</span> <span v-if="isNum">{{ unit }}</span>
+ <span v-else>{{ value }}</span>
+</template>
+
+<script lang="ts">
+
+import { sleep } from "../util.ts";
+
+export default {
+
+ props: {
+ /** Value to count */
+ value: {
+ type: [ String, Number ],
+ default: 0,
+ },
+ time: {
+ type: Number,
+ default: 0.3,
+ },
+ /** Unit of the value */
+ unit: {
+ type: String,
+ default: "ms",
+ },
+ },
+
+ data() {
+ return {
+ output: "",
+ frameDuration: 30,
+ };
+ },
+
+ computed: {
+ isNum() {
+ return typeof this.value === "number";
+ },
+ outputFixed() {
+ if (typeof this.output === "number") {
+ if (this.output < 1) {
+ return "<1";
+ } else if (Number.isInteger(this.output)) {
+ return this.output;
+ } else {
+ return this.output.toFixed(2);
+ }
+ } else {
+ return this.output;
+ }
+ }
+ },
+
+ watch: {
+ async value(from, to) {
+ let diff = to - from;
+ let frames = 12;
+ let step = Math.floor(diff / frames);
+
+ if (! (isNaN(step) || ! this.isNum || (diff > 0 && step < 1) || (diff < 0 && step > 1) || diff === 0)) {
+ for (let i = 1; i < frames; i++) {
+ this.output += step;
+ await sleep(15);
+ }
+ }
+
+ this.output = this.value;
+ },
+ },
+
+ mounted() {
+ this.output = this.value;
+ },
+
+ methods: {},
+
+};
+</script>
diff --git a/src/components/CreateGroupDialog.vue b/src/components/CreateGroupDialog.vue
new file mode 100644
index 0000000..ba7fe6e
--- /dev/null
+++ b/src/components/CreateGroupDialog.vue
@@ -0,0 +1,63 @@
+<template>
+ <div ref="modal" class="modal fade" tabindex="-1">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">
+ {{ $t("New Group") }}
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
+ </div>
+ <div class="modal-body">
+ <form @submit.prevent="confirm">
+ <div>
+ <label for="draftGroupName" class="form-label">{{ $t("Group Name") }}</label>
+ <input id="draftGroupName" v-model="groupName" type="text" class="form-control">
+ </div>
+ </form>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
+ {{ $t("Cancel") }}
+ </button>
+ <button type="button" class="btn btn-primary" data-bs-dismiss="modal" :disabled="groupName == '' || groupName == null" @click="confirm">
+ {{ $t("Confirm") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import { Modal } from "bootstrap";
+
+export default {
+ props: {},
+ emits: [ "added" ],
+ data: () => ({
+ modal: null,
+ groupName: null,
+ }),
+ mounted() {
+ this.modal = new Modal(this.$refs.modal);
+ },
+ methods: {
+ /**
+ * Show the confirm dialog
+ * @returns {void}
+ */
+ show() {
+ this.modal.show();
+ },
+ /**
+ * Dialog confirmed
+ * @returns {void}
+ */
+ confirm() {
+ this.$emit("added", this.groupName);
+ this.modal.hide();
+ },
+ },
+};
+</script>
diff --git a/src/components/Datetime.vue b/src/components/Datetime.vue
new file mode 100644
index 0000000..0d4e182
--- /dev/null
+++ b/src/components/Datetime.vue
@@ -0,0 +1,30 @@
+<template>
+ <span>{{ displayText }}</span>
+</template>
+
+<script>
+export default {
+ props: {
+ /** Value of date time */
+ value: {
+ type: String,
+ default: null,
+ },
+ /** Should only the date be displayed? */
+ dateOnly: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ computed: {
+ displayText() {
+ if (this.dateOnly) {
+ return this.$root.date(this.value);
+ } else {
+ return this.$root.datetime(this.value);
+ }
+ },
+ },
+};
+</script>
diff --git a/src/components/DockerHostDialog.vue b/src/components/DockerHostDialog.vue
new file mode 100644
index 0000000..d2cde81
--- /dev/null
+++ b/src/components/DockerHostDialog.vue
@@ -0,0 +1,198 @@
+<template>
+ <form @submit.prevent="submit">
+ <div ref="modal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 id="exampleModalLabel" class="modal-title">
+ {{ $t("Setup Docker Host") }}
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
+ </div>
+ <div class="modal-body">
+ <div class="mb-3">
+ <label for="docker-name" class="form-label">{{ $t("Friendly Name") }}</label>
+ <input id="docker-name" v-model="dockerHost.name" type="text" class="form-control" required>
+ </div>
+
+ <div class="mb-3">
+ <label for="docker-type" class="form-label">{{ $t("Connection Type") }}</label>
+ <select id="docker-type" v-model="dockerHost.dockerType" class="form-select">
+ <option v-for="type in connectionTypes" :key="type" :value="type">{{ $t(type) }}</option>
+ </select>
+ </div>
+
+ <div class="mb-3">
+ <label for="docker-daemon" class="form-label">{{ $t("Docker Daemon") }}</label>
+ <input id="docker-daemon" v-model="dockerHost.dockerDaemon" type="text" class="form-control" required>
+
+ <div class="form-text">
+ {{ $t("Examples") }}:
+ <ul>
+ <li>/var/run/docker.sock</li>
+ <li>http://localhost:2375</li>
+ <li>https://localhost:2376 (TLS)</li>
+ </ul>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
+ {{ $t("Delete") }}
+ </button>
+ <button type="button" class="btn btn-warning" :disabled="processing" @click="test">
+ {{ $t("Test") }}
+ </button>
+ <button type="submit" class="btn btn-primary" :disabled="processing">
+ <div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
+ {{ $t("Save") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+
+ <Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteDockerHost">
+ {{ $t("deleteDockerHostMsg") }}
+ </Confirm>
+</template>
+
+<script lang="ts">
+import { Modal } from "bootstrap";
+import Confirm from "./Confirm.vue";
+
+export default {
+ components: {
+ Confirm,
+ },
+ props: {},
+ emits: [ "added", "deleted" ],
+ data() {
+ return {
+ modal: null,
+ processing: false,
+ id: null,
+ connectionTypes: [ "socket", "tcp" ],
+ dockerHost: {
+ name: "",
+ dockerDaemon: "",
+ dockerType: "",
+ // Do not set default value here, please scroll to show()
+ }
+ };
+ },
+
+ mounted() {
+ this.modal = new Modal(this.$refs.modal);
+ },
+ methods: {
+
+ /**
+ * Confirm deletion of docker host
+ * @returns {void}
+ */
+ deleteConfirm() {
+ this.modal.hide();
+ this.$refs.confirmDelete.show();
+ },
+
+ /**
+ * Show specified docker host
+ * @param {number} dockerHostID ID of host to show
+ * @returns {void}
+ */
+ show(dockerHostID) {
+ if (dockerHostID) {
+ let found = false;
+
+ this.id = dockerHostID;
+
+ for (let n of this.$root.dockerHostList) {
+ if (n.id === dockerHostID) {
+ this.dockerHost = n;
+ found = true;
+ break;
+ }
+ }
+
+ if (!found) {
+ this.$root.toastError("Docker Host not found!");
+ }
+
+ } else {
+ this.id = null;
+ this.dockerHost = {
+ name: "",
+ dockerType: "socket",
+ dockerDaemon: "/var/run/docker.sock",
+ };
+ }
+
+ this.modal.show();
+ },
+
+ /**
+ * Add docker host
+ * @returns {void}
+ */
+ submit() {
+ this.processing = true;
+ this.$root.getSocket().emit("addDockerHost", this.dockerHost, this.id, (res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+
+ if (res.ok) {
+ this.modal.hide();
+
+ // Emit added event, doesn't emit edit.
+ if (! this.id) {
+ this.$emit("added", res.id);
+ }
+
+ }
+ });
+ },
+
+ /**
+ * Test the docker host
+ * @returns {void}
+ */
+ test() {
+ this.processing = true;
+ this.$root.getSocket().emit("testDockerHost", this.dockerHost, (res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+ });
+ },
+
+ /**
+ * Delete this docker host
+ * @returns {void}
+ */
+ deleteDockerHost() {
+ this.processing = true;
+ this.$root.getSocket().emit("deleteDockerHost", this.id, (res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+
+ if (res.ok) {
+ this.$emit("deleted", this.id);
+ this.modal.hide();
+ }
+ });
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.dark {
+ .modal-dialog .form-text, .modal-dialog p {
+ color: $dark-font-color;
+ }
+}
+</style>
diff --git a/src/components/EditMonitorCondition.vue b/src/components/EditMonitorCondition.vue
new file mode 100644
index 0000000..ac1b02d
--- /dev/null
+++ b/src/components/EditMonitorCondition.vue
@@ -0,0 +1,152 @@
+<template>
+ <div class="monitor-condition mb-3" data-testid="condition">
+ <button
+ v-if="!isInGroup || !isFirst || !isLast"
+ class="btn btn-outline-danger remove-button"
+ type="button"
+ :aria-label="$t('conditionDelete')"
+ data-testid="remove-condition"
+ @click="remove"
+ >
+ <font-awesome-icon icon="trash" />
+ </button>
+
+ <select v-if="!isFirst" v-model="model.andOr" class="form-select and-or-select" data-testid="condition-and-or">
+ <option value="and">{{ $t("and") }}</option>
+ <option value="or">{{ $t("or") }}</option>
+ </select>
+
+ <select v-model="model.variable" class="form-select" data-testid="condition-variable">
+ <option
+ v-for="variable in conditionVariables"
+ :key="variable.id"
+ :value="variable.id"
+ >
+ {{ $t(variable.id) }}
+ </option>
+ </select>
+
+ <select v-model="model.operator" class="form-select" data-testid="condition-operator">
+ <option
+ v-for="operator in getVariableOperators(model.variable)"
+ :key="operator.id"
+ :value="operator.id"
+ >
+ {{ $t(operator.caption) }}
+ </option>
+ </select>
+
+ <input
+ v-model="model.value"
+ type="text"
+ class="form-control"
+ :aria-label="$t('conditionValuePlaceholder')"
+ data-testid="condition-value"
+ required
+ />
+ </div>
+</template>
+
+<script>
+export default {
+ name: "EditMonitorCondition",
+
+ props: {
+ /**
+ * The monitor condition
+ */
+ modelValue: {
+ type: Object,
+ required: true,
+ },
+
+ /**
+ * Whether this is the first condition
+ */
+ isFirst: {
+ type: Boolean,
+ required: true,
+ },
+
+ /**
+ * Whether this is the last condition
+ */
+ isLast: {
+ type: Boolean,
+ required: true,
+ },
+
+ /**
+ * Whether this condition is in a group
+ */
+ isInGroup: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ /**
+ * Variable choices
+ */
+ conditionVariables: {
+ type: Array,
+ required: true,
+ },
+ },
+
+ emits: [ "update:modelValue", "remove" ],
+
+ computed: {
+ model: {
+ get() {
+ return this.modelValue;
+ },
+ set(value) {
+ this.$emit("update:modelValue", value);
+ }
+ }
+ },
+
+ methods: {
+ remove() {
+ this.$emit("remove", this.model);
+ },
+
+ getVariableOperators(variableId) {
+ return this.conditionVariables.find(v => v.id === variableId)?.operators ?? [];
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.monitor-condition {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.remove-button {
+ justify-self: flex-end;
+ margin-bottom: 12px;
+ margin-left: auto;
+}
+
+@container (min-width: 500px) {
+ .monitor-condition {
+ display: flex;
+ flex-wrap: nowrap;
+ }
+
+ .remove-button {
+ margin-bottom: 0;
+ margin-left: 10px;
+ order: 100;
+ }
+
+ .and-or-select {
+ width: auto;
+ }
+}
+</style>
diff --git a/src/components/EditMonitorConditionGroup.vue b/src/components/EditMonitorConditionGroup.vue
new file mode 100644
index 0000000..910b415
--- /dev/null
+++ b/src/components/EditMonitorConditionGroup.vue
@@ -0,0 +1,189 @@
+<template>
+ <div class="condition-group mb-3" data-testid="condition-group">
+ <div class="d-flex">
+ <select v-if="!isFirst" v-model="model.andOr" class="form-select" style="width: auto;" data-testid="condition-group-and-or">
+ <option value="and">{{ $t("and") }}</option>
+ <option value="or">{{ $t("or") }}</option>
+ </select>
+ </div>
+
+ <div class="condition-group-inner mt-2 pa-2">
+ <div class="condition-group-conditions">
+ <template v-for="(child, childIndex) in model.children" :key="childIndex">
+ <EditMonitorConditionGroup
+ v-if="child.type === 'group'"
+ v-model="model.children[childIndex]"
+ :is-first="childIndex === 0"
+ :get-new-group="getNewGroup"
+ :get-new-condition="getNewCondition"
+ :condition-variables="conditionVariables"
+ @remove="removeChild"
+ />
+ <EditMonitorCondition
+ v-else
+ v-model="model.children[childIndex]"
+ :is-first="childIndex === 0"
+ :is-last="childIndex === model.children.length - 1"
+ :is-in-group="true"
+ :condition-variables="conditionVariables"
+ @remove="removeChild"
+ />
+ </template>
+ </div>
+
+ <div class="condition-group-actions mt-3">
+ <button class="btn btn-outline-secondary me-2" type="button" data-testid="add-condition-button" @click="addCondition">
+ {{ $t("conditionAdd") }}
+ </button>
+ <button class="btn btn-outline-secondary me-2" type="button" data-testid="add-group-button" @click="addGroup">
+ {{ $t("conditionAddGroup") }}
+ </button>
+ <button
+ class="btn btn-outline-danger"
+ type="button"
+ :aria-label="$t('conditionDeleteGroup')"
+ data-testid="remove-condition-group"
+ @click="remove"
+ >
+ <font-awesome-icon icon="trash" />
+ </button>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import EditMonitorCondition from "./EditMonitorCondition.vue";
+
+export default {
+ name: "EditMonitorConditionGroup",
+
+ components: {
+ EditMonitorCondition,
+ },
+
+ props: {
+ /**
+ * The condition group
+ */
+ modelValue: {
+ type: Object,
+ required: true,
+ },
+
+ /**
+ * Whether this is the first condition
+ */
+ isFirst: {
+ type: Boolean,
+ required: true,
+ },
+
+ /**
+ * Function to generate a new group model
+ */
+ getNewGroup: {
+ type: Function,
+ required: true,
+ },
+
+ /**
+ * Function to generate a new condition model
+ */
+ getNewCondition: {
+ type: Function,
+ required: true,
+ },
+
+ /**
+ * Variable choices
+ */
+ conditionVariables: {
+ type: Array,
+ required: true,
+ },
+ },
+
+ emits: [ "update:modelValue", "remove" ],
+
+ computed: {
+ model: {
+ get() {
+ return this.modelValue;
+ },
+ set(value) {
+ this.$emit("update:modelValue", value);
+ }
+ }
+ },
+
+ methods: {
+ addGroup() {
+ const conditions = [ ...this.model.children ];
+ conditions.push(this.getNewGroup());
+ this.model.children = conditions;
+ },
+
+ addCondition() {
+ const conditions = [ ...this.model.children ];
+ conditions.push(this.getNewCondition());
+ this.model.children = conditions;
+ },
+
+ remove() {
+ this.$emit("remove", this.model);
+ },
+
+ removeChild(child) {
+ const idx = this.model.children.indexOf(child);
+ if (idx !== -1) {
+ this.model.children.splice(idx, 1);
+ }
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.condition-group-inner {
+ background: rgba(0, 0, 0, 0.05);
+ padding: 20px;
+}
+
+.dark .condition-group-inner {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.condition-group-conditions {
+ container-type: inline-size;
+}
+
+.condition-group-actions {
+ display: grid;
+ gap: 10px;
+}
+
+// Delete button
+.condition-group-actions > :last-child {
+ margin-left: auto;
+ margin-top: 14px;
+}
+
+@container (min-width: 400px) {
+ .condition-group-actions {
+ display: flex;
+ }
+
+ // Delete button
+ .condition-group-actions > :last-child {
+ margin-left: auto;
+ margin-top: 0;
+ }
+
+ .btn-delete-group {
+ margin-left: auto;
+ }
+}
+</style>
diff --git a/src/components/EditMonitorConditions.vue b/src/components/EditMonitorConditions.vue
new file mode 100644
index 0000000..60f7c65
--- /dev/null
+++ b/src/components/EditMonitorConditions.vue
@@ -0,0 +1,149 @@
+<template>
+ <div class="monitor-conditions">
+ <label class="form-label">{{ $t("Conditions") }}</label>
+ <div class="monitor-conditions-conditions">
+ <template v-for="(condition, conditionIndex) in model" :key="conditionIndex">
+ <EditMonitorConditionGroup
+ v-if="condition.type === 'group'"
+ v-model="model[conditionIndex]"
+ :is-first="conditionIndex === 0"
+ :get-new-group="getNewGroup"
+ :get-new-condition="getNewCondition"
+ :condition-variables="conditionVariables"
+ @remove="removeCondition"
+ />
+ <EditMonitorCondition
+ v-else
+ v-model="model[conditionIndex]"
+ :is-first="conditionIndex === 0"
+ :is-last="conditionIndex === model.length - 1"
+ :condition-variables="conditionVariables"
+ @remove="removeCondition"
+ />
+ </template>
+ </div>
+ <div class="monitor-conditions-buttons">
+ <button class="btn btn-outline-secondary me-2" type="button" data-testid="add-condition-button" @click="addCondition">
+ {{ $t("conditionAdd") }}
+ </button>
+ <button class="btn btn-outline-secondary me-2" type="button" data-testid="add-group-button" @click="addGroup">
+ {{ $t("conditionAddGroup") }}
+ </button>
+ </div>
+ </div>
+</template>
+
+<script>
+import EditMonitorConditionGroup from "./EditMonitorConditionGroup.vue";
+import EditMonitorCondition from "./EditMonitorCondition.vue";
+
+export default {
+ name: "EditMonitorConditions",
+
+ components: {
+ EditMonitorConditionGroup,
+ EditMonitorCondition,
+ },
+
+ props: {
+ /**
+ * The monitor conditions
+ */
+ modelValue: {
+ type: Array,
+ required: true,
+ },
+
+ conditionVariables: {
+ type: Array,
+ required: true,
+ },
+ },
+
+ emits: [ "update:modelValue" ],
+
+ computed: {
+ model: {
+ get() {
+ return this.modelValue;
+ },
+ set(value) {
+ this.$emit("update:modelValue", value);
+ }
+ }
+ },
+
+ created() {
+ if (this.model.length === 0) {
+ this.addCondition();
+ }
+ },
+
+ methods: {
+ getNewGroup() {
+ return {
+ type: "group",
+ children: [ this.getNewCondition() ],
+ andOr: "and",
+ };
+ },
+
+ getNewCondition() {
+ const firstVariable = this.conditionVariables[0]?.id || null;
+ const firstOperator = this.getVariableOperators(firstVariable)[0] || null;
+ return {
+ type: "expression",
+ variable: firstVariable,
+ operator: firstOperator?.id || null,
+ value: "",
+ andOr: "and",
+ };
+ },
+
+ addGroup() {
+ const conditions = [ ...this.model ];
+ conditions.push(this.getNewGroup());
+ this.$emit("update:modelValue", conditions);
+ },
+
+ addCondition() {
+ const conditions = [ ...this.model ];
+ conditions.push(this.getNewCondition());
+ this.$emit("update:modelValue", conditions);
+ },
+
+ removeCondition(condition) {
+ const conditions = [ ...this.model ];
+ const idx = conditions.indexOf(condition);
+ if (idx !== -1) {
+ conditions.splice(idx, 1);
+ this.$emit("update:modelValue", conditions);
+ }
+ },
+
+ getVariableOperators(variableId) {
+ return this.conditionVariables.find(v => v.id === variableId)?.operators ?? [];
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.monitor-conditions,
+.monitor-conditions-conditions {
+ container-type: inline-size;
+}
+
+.monitor-conditions-buttons {
+ display: grid;
+ gap: 10px;
+}
+
+@container (min-width: 400px) {
+ .monitor-conditions-buttons {
+ display: flex;
+ }
+}
+</style>
diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue
new file mode 100644
index 0000000..429ca9f
--- /dev/null
+++ b/src/components/HeartbeatBar.vue
@@ -0,0 +1,348 @@
+<template>
+ <div ref="wrap" class="wrap" :style="wrapStyle">
+ <div class="hp-bar-big" :style="barStyle">
+ <div
+ v-for="(beat, index) in shortBeatList"
+ :key="index"
+ class="beat-hover-area"
+ :class="{ 'empty': (beat === 0) }"
+ :style="beatHoverAreaStyle"
+ :title="getBeatTitle(beat)"
+ >
+ <div
+ class="beat"
+ :class="{ 'empty': (beat === 0), 'down': (beat.status === 0), 'pending': (beat.status === 2), 'maintenance': (beat.status === 3) }"
+ :style="beatStyle"
+ />
+ </div>
+ </div>
+ <div
+ v-if="!$root.isMobile && size !== 'small' && beatList.length > 4 && $root.styleElapsedTime !== 'none'"
+ class="d-flex justify-content-between align-items-center word" :style="timeStyle"
+ >
+ <div>{{ timeSinceFirstBeat }}</div>
+ <div v-if="$root.styleElapsedTime === 'with-line'" class="connecting-line"></div>
+ <div>{{ timeSinceLastBeat }}</div>
+ </div>
+ </div>
+</template>
+
+<script>
+import dayjs from "dayjs";
+
+export default {
+ props: {
+ /** Size of the heartbeat bar */
+ size: {
+ type: String,
+ default: "big",
+ },
+ /** ID of the monitor */
+ monitorId: {
+ type: Number,
+ required: true,
+ },
+ /** Array of the monitors heartbeats */
+ heartbeatList: {
+ type: Array,
+ default: null,
+ }
+ },
+ data() {
+ return {
+ beatWidth: 10,
+ beatHeight: 30,
+ hoverScale: 1.5,
+ beatHoverAreaPadding: 4,
+ move: false,
+ maxBeat: -1,
+ };
+ },
+ computed: {
+
+ /**
+ * If heartbeatList is null, get it from $root.heartbeatList
+ * @returns {object} Heartbeat list
+ */
+ beatList() {
+ if (this.heartbeatList === null) {
+ return this.$root.heartbeatList[this.monitorId];
+ } else {
+ return this.heartbeatList;
+ }
+ },
+
+ /**
+ * Calculates the amount of beats of padding needed to fill the length of shortBeatList.
+ * @returns {number} The amount of beats of padding needed to fill the length of shortBeatList.
+ */
+ numPadding() {
+ if (!this.beatList) {
+ return 0;
+ }
+ let num = this.beatList.length - this.maxBeat;
+
+ if (this.move) {
+ num = num - 1;
+ }
+
+ if (num > 0) {
+ return 0;
+ }
+
+ return -1 * num;
+ },
+
+ shortBeatList() {
+ if (!this.beatList) {
+ return [];
+ }
+
+ let placeholders = [];
+
+ let start = this.beatList.length - this.maxBeat;
+
+ if (this.move) {
+ start = start - 1;
+ }
+
+ if (start < 0) {
+ // Add empty placeholder
+ for (let i = start; i < 0; i++) {
+ placeholders.push(0);
+ }
+ start = 0;
+ }
+
+ return placeholders.concat(this.beatList.slice(start));
+ },
+
+ wrapStyle() {
+ let topBottom = (((this.beatHeight * this.hoverScale) - this.beatHeight) / 2);
+ let leftRight = (((this.beatWidth * this.hoverScale) - this.beatWidth) / 2);
+
+ return {
+ padding: `${topBottom}px ${leftRight}px`,
+ width: "100%",
+ };
+ },
+
+ barStyle() {
+ if (this.move && this.shortBeatList.length > this.maxBeat) {
+ let width = -(this.beatWidth + this.beatHoverAreaPadding * 2);
+
+ return {
+ transition: "all ease-in-out 0.25s",
+ transform: `translateX(${width}px)`,
+ };
+
+ }
+ return {
+ transform: "translateX(0)",
+ };
+
+ },
+
+ beatHoverAreaStyle() {
+ return {
+ padding: this.beatHoverAreaPadding + "px",
+ "--hover-scale": this.hoverScale,
+ };
+ },
+
+ beatStyle() {
+ return {
+ width: this.beatWidth + "px",
+ height: this.beatHeight + "px",
+ };
+ },
+
+ /**
+ * Returns the style object for positioning the time element.
+ * @returns {object} The style object containing the CSS properties for positioning the time element.
+ */
+ timeStyle() {
+ return {
+ "margin-left": this.numPadding * (this.beatWidth + this.beatHoverAreaPadding * 2) + "px",
+ };
+ },
+
+ /**
+ * Calculates the time elapsed since the first valid beat.
+ * @returns {string} The time elapsed in minutes or hours.
+ */
+ timeSinceFirstBeat() {
+ const firstValidBeat = this.shortBeatList.at(this.numPadding);
+ const minutes = dayjs().diff(dayjs.utc(firstValidBeat?.time), "minutes");
+ if (minutes > 60) {
+ return (minutes / 60).toFixed(0) + "h";
+ } else {
+ return minutes + "m";
+ }
+ },
+
+ /**
+ * Calculates the elapsed time since the last valid beat was registered.
+ * @returns {string} The elapsed time in a minutes, hours or "now".
+ */
+ timeSinceLastBeat() {
+ const lastValidBeat = this.shortBeatList.at(-1);
+ const seconds = dayjs().diff(dayjs.utc(lastValidBeat?.time), "seconds");
+
+ let tolerance = 60 * 2; // default for when monitorList not available
+ if (this.$root.monitorList[this.monitorId] != null) {
+ tolerance = this.$root.monitorList[this.monitorId].interval * 2;
+ }
+
+ if (seconds < tolerance) {
+ return this.$t("now");
+ } else if (seconds < 60 * 60) {
+ return this.$t("time ago", [ (seconds / 60).toFixed(0) + "m" ]);
+ } else {
+ return this.$t("time ago", [ (seconds / 60 / 60).toFixed(0) + "h" ]);
+ }
+ }
+ },
+ watch: {
+ beatList: {
+ handler(val, oldVal) {
+ this.move = true;
+
+ setTimeout(() => {
+ this.move = false;
+ }, 300);
+ },
+ deep: true,
+ },
+ },
+ unmounted() {
+ window.removeEventListener("resize", this.resize);
+ },
+ beforeMount() {
+ if (this.heartbeatList === null) {
+ if (!(this.monitorId in this.$root.heartbeatList)) {
+ this.$root.heartbeatList[this.monitorId] = [];
+ }
+ }
+ },
+
+ mounted() {
+ if (this.size !== "big") {
+ this.beatWidth = 5;
+ this.beatHeight = 16;
+ this.beatHoverAreaPadding = 2;
+ }
+
+ // Suddenly, have an idea how to handle it universally.
+ // If the pixel * ratio != Integer, then it causes render issue, round it to solve it!!
+ const actualWidth = this.beatWidth * window.devicePixelRatio;
+ const actualHoverAreaPadding = this.beatHoverAreaPadding * window.devicePixelRatio;
+
+ if (!Number.isInteger(actualWidth)) {
+ this.beatWidth = Math.round(actualWidth) / window.devicePixelRatio;
+ }
+
+ if (!Number.isInteger(actualHoverAreaPadding)) {
+ this.beatHoverAreaPadding = Math.round(actualHoverAreaPadding) / window.devicePixelRatio;
+ }
+
+ window.addEventListener("resize", this.resize);
+ this.resize();
+ },
+ methods: {
+ /**
+ * Resize the heartbeat bar
+ * @returns {void}
+ */
+ resize() {
+ if (this.$refs.wrap) {
+ this.maxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatHoverAreaPadding * 2));
+ }
+ },
+
+ /**
+ * Get the title of the beat.
+ * Used as the hover tooltip on the heartbeat bar.
+ * @param {object} beat Beat to get title from
+ * @returns {string} Beat title
+ */
+ getBeatTitle(beat) {
+ return `${this.$root.datetime(beat.time)}` + ((beat.msg) ? ` - ${beat.msg}` : "");
+ },
+
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.wrap {
+ overflow: hidden;
+ width: 100%;
+ white-space: nowrap;
+}
+
+.hp-bar-big {
+ .beat-hover-area {
+ display: inline-block;
+
+ &:not(.empty):hover {
+ transition: all ease-in-out 0.15s;
+ opacity: 0.8;
+ transform: scale(var(--hover-scale));
+ }
+
+ .beat {
+ background-color: $primary;
+ border-radius: $border-radius;
+
+ /*
+ pointer-events needs to be changed because
+ tooltip momentarily disappears when crossing between .beat-hover-area and .beat
+ */
+ pointer-events: none;
+
+ &.empty {
+ background-color: aliceblue;
+ }
+
+ &.down {
+ background-color: $danger;
+ }
+
+ &.pending {
+ background-color: $warning;
+ }
+
+ &.maintenance {
+ background-color: $maintenance;
+ }
+ }
+ }
+}
+
+.dark {
+ .hp-bar-big .beat.empty {
+ background-color: #848484;
+ }
+}
+
+.word {
+ color: $secondary-text;
+ font-size: 12px;
+}
+
+.connecting-line {
+ flex-grow: 1;
+ height: 1px;
+ background-color: #ededed;
+ margin-left: 10px;
+ margin-right: 10px;
+ margin-top: 2px;
+
+ .dark & {
+ background-color: #333;
+ }
+}
+</style>
diff --git a/src/components/HiddenInput.vue b/src/components/HiddenInput.vue
new file mode 100644
index 0000000..85ff883
--- /dev/null
+++ b/src/components/HiddenInput.vue
@@ -0,0 +1,93 @@
+<template>
+ <div class="input-group mb-3">
+ <input
+ ref="input"
+ v-model="model"
+ :type="visibility"
+ class="form-control"
+ :placeholder="placeholder"
+ :maxlength="maxlength"
+ :autocomplete="autocomplete"
+ :required="required"
+ :readonly="readonly"
+ >
+
+ <a v-if="visibility == 'password'" class="btn btn-outline-primary" @click="showInput()">
+ <font-awesome-icon icon="eye" />
+ </a>
+ <a v-if="visibility == 'text'" class="btn btn-outline-primary" @click="hideInput()">
+ <font-awesome-icon icon="eye-slash" />
+ </a>
+ </div>
+</template>
+
+<script>
+export default {
+ props: {
+ /** The value of the input */
+ modelValue: {
+ type: String,
+ default: ""
+ },
+ /** A placeholder to use */
+ placeholder: {
+ type: String,
+ default: ""
+ },
+ /** Maximum length of the input */
+ maxlength: {
+ type: Number,
+ default: 255
+ },
+ /** Should the field auto complete */
+ autocomplete: {
+ type: String,
+ default: "new-password",
+ },
+ /** Is the input required? */
+ required: {
+ type: Boolean
+ },
+ /** Should the input be read only? */
+ readonly: {
+ type: String,
+ default: undefined,
+ },
+ },
+ emits: [ "update:modelValue" ],
+ data() {
+ return {
+ visibility: "password",
+ };
+ },
+ computed: {
+ model: {
+ get() {
+ return this.modelValue;
+ },
+ set(value) {
+ this.$emit("update:modelValue", value);
+ }
+ }
+ },
+ created() {
+
+ },
+ methods: {
+ /**
+ * Show users input in plain text
+ * @returns {void}
+ */
+ showInput() {
+ this.visibility = "text";
+ },
+ /**
+ * Censor users input
+ * @returns {void}
+ */
+ hideInput() {
+ this.visibility = "password";
+ },
+ }
+};
+</script>
diff --git a/src/components/Login.vue b/src/components/Login.vue
new file mode 100644
index 0000000..1f8946f
--- /dev/null
+++ b/src/components/Login.vue
@@ -0,0 +1,113 @@
+<template>
+ <div class="form-container">
+ <div class="form">
+ <form @submit.prevent="submit">
+ <h1 class="h3 mb-3 fw-normal" />
+
+ <div v-if="!tokenRequired" class="form-floating">
+ <input id="floatingInput" v-model="username" type="text" class="form-control" placeholder="Username" autocomplete="username" required>
+ <label for="floatingInput">{{ $t("Username") }}</label>
+ </div>
+
+ <div v-if="!tokenRequired" class="form-floating mt-3">
+ <input id="floatingPassword" v-model="password" type="password" class="form-control" placeholder="Password" autocomplete="current-password" required>
+ <label for="floatingPassword">{{ $t("Password") }}</label>
+ </div>
+
+ <div v-if="tokenRequired">
+ <div class="form-floating mt-3">
+ <input id="otp" v-model="token" type="text" maxlength="6" class="form-control" placeholder="123456" autocomplete="one-time-code" required>
+ <label for="otp">{{ $t("Token") }}</label>
+ </div>
+ </div>
+
+ <div class="form-check mb-3 mt-3 d-flex justify-content-center pe-4">
+ <div class="form-check">
+ <input id="remember" v-model="$root.remember" type="checkbox" value="remember-me" class="form-check-input">
+
+ <label class="form-check-label" for="remember">
+ {{ $t("Remember me") }}
+ </label>
+ </div>
+ </div>
+ <button class="w-100 btn btn-primary" type="submit" :disabled="processing">
+ {{ $t("Login") }}
+ </button>
+
+ <div v-if="res && !res.ok" class="alert alert-danger mt-3" role="alert">
+ {{ $t(res.msg) }}
+ </div>
+ </form>
+ </div>
+ </div>
+</template>
+
+<script>
+export default {
+ data() {
+ return {
+ processing: false,
+ username: "",
+ password: "",
+ token: "",
+ res: null,
+ tokenRequired: false,
+ };
+ },
+
+ mounted() {
+ document.title += " - Login";
+ },
+
+ unmounted() {
+ document.title = document.title.replace(" - Login", "");
+ },
+
+ methods: {
+ /**
+ * Submit the user details and attempt to log in
+ * @returns {void}
+ */
+ submit() {
+ this.processing = true;
+
+ this.$root.login(this.username, this.password, this.token, (res) => {
+ this.processing = false;
+
+ if (res.tokenRequired) {
+ this.tokenRequired = true;
+ } else {
+ this.res = res;
+ }
+ });
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+.form-container {
+ display: flex;
+ align-items: center;
+ padding-top: 40px;
+ padding-bottom: 40px;
+}
+
+.form-floating {
+ > label {
+ padding-left: 1.3rem;
+ }
+
+ > .form-control {
+ padding-left: 1.3rem;
+ }
+}
+
+.form {
+ width: 100%;
+ max-width: 330px;
+ padding: 15px;
+ margin: auto;
+ text-align: center;
+}
+</style>
diff --git a/src/components/MaintenanceTime.vue b/src/components/MaintenanceTime.vue
new file mode 100644
index 0000000..14d7684
--- /dev/null
+++ b/src/components/MaintenanceTime.vue
@@ -0,0 +1,60 @@
+<template>
+ <div>
+ <div v-if="maintenance.strategy === 'manual'" class="timeslot">
+ {{ $t("Manual") }}
+ </div>
+ <div v-else-if="maintenance.timeslotList.length > 0">
+ <div class="timeslot">
+ {{ startDateTime }}
+ <span class="to">-</span>
+ {{ endDateTime }}
+ </div>
+ <div class="timeslot">
+ UTC{{ maintenance.timezoneOffset }} <span v-if="maintenance.timezone !== 'UTC'">{{ maintenance.timezone }}</span>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import dayjs from "dayjs";
+import { SQL_DATETIME_FORMAT_WITHOUT_SECOND } from "../util.ts";
+
+export default {
+ props: {
+ maintenance: {
+ type: Object,
+ required: true
+ },
+ },
+ computed: {
+ startDateTime() {
+ return dayjs(this.maintenance.timeslotList[0].startDate).tz(this.maintenance.timezone, true).format(SQL_DATETIME_FORMAT_WITHOUT_SECOND);
+ },
+ endDateTime() {
+ return dayjs(this.maintenance.timeslotList[0].endDate).tz(this.maintenance.timezone, true).format(SQL_DATETIME_FORMAT_WITHOUT_SECOND);
+ }
+ },
+};
+</script>
+
+<style lang="scss">
+.timeslot {
+ margin-top: 5px;
+ display: inline-block;
+ font-size: 14px;
+ background-color: rgba(255, 255, 255, 0.5);
+ border-radius: 20px;
+ padding: 0 10px;
+ margin-right: 5px;
+
+ .to {
+ margin: 0 6px;
+ }
+
+ .dark & {
+ color: white;
+ background-color: rgba(255, 255, 255, 0.1);
+ }
+}
+</style>
diff --git a/src/components/MonitorList.vue b/src/components/MonitorList.vue
new file mode 100644
index 0000000..a579316
--- /dev/null
+++ b/src/components/MonitorList.vue
@@ -0,0 +1,485 @@
+<template>
+ <div class="shadow-box mb-3" :style="boxStyle">
+ <div class="list-header">
+ <div class="header-top">
+ <button class="btn btn-outline-normal ms-2" :class="{ 'active': selectMode }" type="button" @click="selectMode = !selectMode">
+ {{ $t("Select") }}
+ </button>
+
+ <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>
+ <form>
+ <input
+ v-model="searchText"
+ class="form-control search-input"
+ :placeholder="$t('Search...')"
+ :aria-label="$t('Search monitored sites')"
+ autocomplete="off"
+ />
+ </form>
+ </div>
+ </div>
+ <div class="header-filter">
+ <MonitorListFilter :filterState="filterState" @update-filter="updateFilter" />
+ </div>
+
+ <!-- Selection Controls -->
+ <div v-if="selectMode" class="selection-controls px-2 pt-2">
+ <input
+ v-model="selectAll"
+ class="form-check-input select-input"
+ type="checkbox"
+ />
+
+ <button class="btn-outline-normal" @click="pauseDialog"><font-awesome-icon icon="pause" size="sm" /> {{ $t("Pause") }}</button>
+ <button class="btn-outline-normal" @click="resumeSelected"><font-awesome-icon icon="play" size="sm" /> {{ $t("Resume") }}</button>
+
+ <span v-if="selectedMonitorCount > 0">
+ {{ $t("selectedMonitorCount", [ selectedMonitorCount ]) }}
+ </span>
+ </div>
+ </div>
+ <div ref="monitorList" class="monitor-list" :class="{ scrollbar: scrollbar }" :style="monitorListStyle" data-testid="monitor-list">
+ <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>
+
+ <MonitorListItem
+ v-for="(item, index) in sortedMonitorList"
+ :key="index"
+ :monitor="item"
+ :isSelectMode="selectMode"
+ :isSelected="isSelected"
+ :select="select"
+ :deselect="deselect"
+ :filter-func="filterFunc"
+ :sort-func="sortFunc"
+ />
+ </div>
+ </div>
+
+ <Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseSelected">
+ {{ $t("pauseMonitorMsg") }}
+ </Confirm>
+</template>
+
+<script>
+import Confirm from "../components/Confirm.vue";
+import MonitorListItem from "../components/MonitorListItem.vue";
+import MonitorListFilter from "./MonitorListFilter.vue";
+import { getMonitorRelativeURL } from "../util.ts";
+
+export default {
+ components: {
+ Confirm,
+ MonitorListItem,
+ MonitorListFilter,
+ },
+ props: {
+ /** Should the scrollbar be shown */
+ scrollbar: {
+ type: Boolean,
+ },
+ },
+ data() {
+ return {
+ searchText: "",
+ selectMode: false,
+ selectAll: false,
+ disableSelectAllWatcher: false,
+ selectedMonitors: {},
+ windowTop: 0,
+ filterState: {
+ status: null,
+ active: null,
+ tags: null,
+ }
+ };
+ },
+ computed: {
+ /**
+ * Improve the sticky appearance of the list by increasing its
+ * height as user scrolls down.
+ * Not used on mobile.
+ * @returns {object} Style for monitor list
+ */
+ boxStyle() {
+ if (window.innerWidth > 550) {
+ return {
+ height: `calc(100vh - 160px + ${this.windowTop}px)`,
+ };
+ } else {
+ return {
+ height: "calc(100vh - 160px)",
+ };
+ }
+
+ },
+
+ /**
+ * Returns a sorted list of monitors based on the applied filters and search text.
+ * @returns {Array} The sorted list of monitors.
+ */
+ sortedMonitorList() {
+ let result = Object.values(this.$root.monitorList);
+
+ result = result.filter(monitor => {
+ // The root list does not show children
+ if (monitor.parent !== null) {
+ return false;
+ }
+ return true;
+ });
+
+ result = result.filter(this.filterFunc);
+
+ result.sort(this.sortFunc);
+
+ return result;
+ },
+
+ isDarkTheme() {
+ return document.body.classList.contains("dark");
+ },
+
+ monitorListStyle() {
+ let listHeaderHeight = 107;
+
+ if (this.selectMode) {
+ listHeaderHeight += 42;
+ }
+
+ return {
+ "height": `calc(100% - ${listHeaderHeight}px)`
+ };
+ },
+
+ selectedMonitorCount() {
+ return Object.keys(this.selectedMonitors).length;
+ },
+
+ /**
+ * Determines if any filters are active.
+ * @returns {boolean} True if any filter is active, false otherwise.
+ */
+ filtersActive() {
+ return this.filterState.status != null || this.filterState.active != null || this.filterState.tags != null || this.searchText !== "";
+ }
+ },
+ watch: {
+ searchText() {
+ for (let monitor of this.sortedMonitorList) {
+ if (!this.selectedMonitors[monitor.id]) {
+ if (this.selectAll) {
+ this.disableSelectAllWatcher = true;
+ this.selectAll = false;
+ }
+ break;
+ }
+ }
+ },
+ selectAll() {
+ if (!this.disableSelectAllWatcher) {
+ this.selectedMonitors = {};
+
+ if (this.selectAll) {
+ this.sortedMonitorList.forEach((item) => {
+ this.selectedMonitors[item.id] = true;
+ });
+ }
+ } else {
+ this.disableSelectAllWatcher = false;
+ }
+ },
+ selectMode() {
+ if (!this.selectMode) {
+ this.selectAll = false;
+ this.selectedMonitors = {};
+ }
+ },
+ },
+ mounted() {
+ window.addEventListener("scroll", this.onScroll);
+ },
+ beforeUnmount() {
+ window.removeEventListener("scroll", this.onScroll);
+ },
+ methods: {
+ /**
+ * Handle user scroll
+ * @returns {void}
+ */
+ onScroll() {
+ if (window.top.scrollY <= 133) {
+ this.windowTop = window.top.scrollY;
+ } else {
+ this.windowTop = 133;
+ }
+ },
+ /**
+ * Get URL of monitor
+ * @param {number} id ID of monitor
+ * @returns {string} Relative URL of monitor
+ */
+ monitorURL(id) {
+ return getMonitorRelativeURL(id);
+ },
+ /**
+ * Clear the search bar
+ * @returns {void}
+ */
+ clearSearchText() {
+ this.searchText = "";
+ },
+ /**
+ * Update the MonitorList Filter
+ * @param {object} newFilter Object with new filter
+ * @returns {void}
+ */
+ updateFilter(newFilter) {
+ this.filterState = newFilter;
+ },
+ /**
+ * Deselect a monitor
+ * @param {number} id ID of monitor
+ * @returns {void}
+ */
+ deselect(id) {
+ delete this.selectedMonitors[id];
+ },
+ /**
+ * Select a monitor
+ * @param {number} id ID of monitor
+ * @returns {void}
+ */
+ select(id) {
+ this.selectedMonitors[id] = true;
+ },
+ /**
+ * Determine if monitor is selected
+ * @param {number} id ID of monitor
+ * @returns {bool} Is the monitor selected?
+ */
+ isSelected(id) {
+ return id in this.selectedMonitors;
+ },
+ /**
+ * Disable select mode and reset selection
+ * @returns {void}
+ */
+ cancelSelectMode() {
+ this.selectMode = false;
+ this.selectedMonitors = {};
+ },
+ /**
+ * Show dialog to confirm pause
+ * @returns {void}
+ */
+ pauseDialog() {
+ this.$refs.confirmPause.show();
+ },
+ /**
+ * Pause each selected monitor
+ * @returns {void}
+ */
+ pauseSelected() {
+ Object.keys(this.selectedMonitors)
+ .filter(id => this.$root.monitorList[id].active)
+ .forEach(id => this.$root.getSocket().emit("pauseMonitor", id, () => {}));
+
+ this.cancelSelectMode();
+ },
+ /**
+ * Resume each selected monitor
+ * @returns {void}
+ */
+ resumeSelected() {
+ Object.keys(this.selectedMonitors)
+ .filter(id => !this.$root.monitorList[id].active)
+ .forEach(id => this.$root.getSocket().emit("resumeMonitor", id, () => {}));
+
+ this.cancelSelectMode();
+ },
+ /**
+ * Whether a monitor should be displayed based on the filters
+ * @param {object} monitor Monitor to check
+ * @returns {boolean} Should the monitor be displayed
+ */
+ filterFunc(monitor) {
+ // Group monitors bypass filter if at least 1 of children matched
+ if (monitor.type === "group") {
+ const children = Object.values(this.$root.monitorList).filter(m => m.parent === monitor.id);
+ if (children.some((child, index, children) => this.filterFunc(child))) {
+ return true;
+ }
+ }
+
+ // filter by search text
+ // finds monitor name, tag name or tag value
+ let searchTextMatch = true;
+ if (this.searchText !== "") {
+ const loweredSearchText = this.searchText.toLowerCase();
+ searchTextMatch =
+ monitor.name.toLowerCase().includes(loweredSearchText)
+ || monitor.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText)
+ || tag.value?.toLowerCase().includes(loweredSearchText));
+ }
+
+ // filter by status
+ let statusMatch = true;
+ if (this.filterState.status != null && this.filterState.status.length > 0) {
+ if (monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[monitor.id]) {
+ monitor.status = this.$root.lastHeartbeatList[monitor.id].status;
+ }
+ statusMatch = this.filterState.status.includes(monitor.status);
+ }
+
+ // filter by active
+ let activeMatch = true;
+ if (this.filterState.active != null && this.filterState.active.length > 0) {
+ activeMatch = this.filterState.active.includes(monitor.active);
+ }
+
+ // filter by tags
+ let tagsMatch = true;
+ if (this.filterState.tags != null && this.filterState.tags.length > 0) {
+ tagsMatch = monitor.tags.map(tag => tag.tag_id) // convert to array of tag IDs
+ .filter(monitorTagId => this.filterState.tags.includes(monitorTagId)) // perform Array Intersaction between filter and monitor's tags
+ .length > 0;
+ }
+
+ return searchTextMatch && statusMatch && activeMatch && tagsMatch;
+ },
+ /**
+ * Function used in Array.sort to order monitors in a list.
+ * @param {*} m1 monitor 1
+ * @param {*} m2 monitor 2
+ * @returns {number} -1, 0 or 1
+ */
+ sortFunc(m1, m2) {
+ if (m1.active !== m2.active) {
+ if (m1.active === false) {
+ return 1;
+ }
+
+ if (m2.active === false) {
+ return -1;
+ }
+ }
+
+ if (m1.weight !== m2.weight) {
+ if (m1.weight > m2.weight) {
+ return -1;
+ }
+
+ if (m1.weight < m2.weight) {
+ return 1;
+ }
+ }
+
+ return m1.name.localeCompare(m2.name);
+ }
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.shadow-box {
+ height: calc(100vh - 150px);
+ position: sticky;
+ top: 10px;
+}
+
+.small-padding {
+ padding-left: 5px !important;
+ padding-right: 5px !important;
+}
+
+.list-header {
+ border-bottom: 1px solid #dee2e6;
+ border-radius: 10px 10px 0 0;
+ margin: -10px;
+ margin-bottom: 10px;
+ padding: 10px;
+
+ .dark & {
+ background-color: $dark-header-bg;
+ border-bottom: 0;
+ }
+}
+
+.header-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.header-filter {
+ display: flex;
+ align-items: center;
+}
+
+@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;
+
+ // Clear filter button (X)
+ svg[data-icon="times"] {
+ cursor: pointer;
+ transition: all ease-in-out 0.1s;
+
+ &:hover {
+ opacity: 0.5;
+ }
+ }
+}
+
+.search-input {
+ max-width: 15em;
+}
+
+.monitor-item {
+ width: 100%;
+}
+
+.tags {
+ margin-top: 4px;
+ padding-left: 67px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0;
+}
+
+.bottom-style {
+ padding-left: 67px;
+ margin-top: 5px;
+}
+
+.selection-controls {
+ margin-top: 5px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+</style>
diff --git a/src/components/MonitorListFilter.vue b/src/components/MonitorListFilter.vue
new file mode 100644
index 0000000..330efc9
--- /dev/null
+++ b/src/components/MonitorListFilter.vue
@@ -0,0 +1,293 @@
+<template>
+ <div class="px-2 pt-2 d-flex">
+ <button
+ type="button"
+ :title="$t('Clear current filters')"
+ class="clear-filters-btn btn"
+ :class="{ 'active': numFiltersActive > 0}"
+ tabindex="0"
+ :disabled="numFiltersActive === 0"
+ @click="clearFilters"
+ >
+ <font-awesome-icon icon="stream" />
+ <span v-if="numFiltersActive > 0" class="px-1 fw-bold">{{ numFiltersActive }}</span>
+ <font-awesome-icon v-if="numFiltersActive > 0" icon="times" />
+ </button>
+ <MonitorListFilterDropdown
+ :filterActive="filterState.status?.length > 0"
+ >
+ <template #status>
+ <Status v-if="filterState.status?.length === 1" :status="filterState.status[0]" />
+ <span v-else>
+ {{ $t('Status') }}
+ </span>
+ </template>
+ <template #dropdown>
+ <li>
+ <div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(1)">
+ <div class="d-flex align-items-center justify-content-between">
+ <Status :status="1" />
+ <span class="ps-3">
+ {{ $root.stats.up }}
+ <span v-if="filterState.status?.includes(1)" class="px-1 filter-active">
+ <font-awesome-icon icon="check" />
+ </span>
+ </span>
+ </div>
+ </div>
+ </li>
+ <li>
+ <div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(0)">
+ <div class="d-flex align-items-center justify-content-between">
+ <Status :status="0" />
+ <span class="ps-3">
+ {{ $root.stats.down }}
+ <span v-if="filterState.status?.includes(0)" class="px-1 filter-active">
+ <font-awesome-icon icon="check" />
+ </span>
+ </span>
+ </div>
+ </div>
+ </li>
+ <li>
+ <div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(2)">
+ <div class="d-flex align-items-center justify-content-between">
+ <Status :status="2" />
+ <span class="ps-3">
+ {{ $root.stats.pending }}
+ <span v-if="filterState.status?.includes(2)" class="px-1 filter-active">
+ <font-awesome-icon icon="check" />
+ </span>
+ </span>
+ </div>
+ </div>
+ </li>
+ <li>
+ <div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(3)">
+ <div class="d-flex align-items-center justify-content-between">
+ <Status :status="3" />
+ <span class="ps-3">
+ {{ $root.stats.maintenance }}
+ <span v-if="filterState.status?.includes(3)" class="px-1 filter-active">
+ <font-awesome-icon icon="check" />
+ </span>
+ </span>
+ </div>
+ </div>
+ </li>
+ </template>
+ </MonitorListFilterDropdown>
+ <MonitorListFilterDropdown :filterActive="filterState.active?.length > 0">
+ <template #status>
+ <span v-if="filterState.active?.length === 1">
+ <span v-if="filterState.active[0]">{{ $t("Running") }}</span>
+ <span v-else>{{ $t("filterActivePaused") }}</span>
+ </span>
+ <span v-else>
+ {{ $t("filterActive") }}
+ </span>
+ </template>
+ <template #dropdown>
+ <li>
+ <div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(true)">
+ <div class="d-flex align-items-center justify-content-between">
+ <span>{{ $t("Running") }}</span>
+ <span class="ps-3">
+ {{ $root.stats.active }}
+ <span v-if="filterState.active?.includes(true)" class="px-1 filter-active">
+ <font-awesome-icon icon="check" />
+ </span>
+ </span>
+ </div>
+ </div>
+ </li>
+ <li>
+ <div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(false)">
+ <div class="d-flex align-items-center justify-content-between">
+ <span>{{ $t("filterActivePaused") }}</span>
+ <span class="ps-3">
+ {{ $root.stats.pause }}
+ <span v-if="filterState.active?.includes(false)" class="px-1 filter-active">
+ <font-awesome-icon icon="check" />
+ </span>
+ </span>
+ </div>
+ </div>
+ </li>
+ </template>
+ </MonitorListFilterDropdown>
+ <MonitorListFilterDropdown :filterActive="filterState.tags?.length > 0">
+ <template #status>
+ <Tag
+ v-if="filterState.tags?.length === 1"
+ :item="tagsList.find(tag => tag.id === filterState.tags[0])"
+ :size="'sm'"
+ />
+ <span v-else>
+ {{ $t('Tags') }}
+ </span>
+ </template>
+ <template #dropdown>
+ <li v-for="tag in tagsList" :key="tag.id">
+ <div class="dropdown-item" tabindex="0" @click.stop="toggleTagFilter(tag)">
+ <div class="d-flex align-items-center justify-content-between">
+ <span><Tag :item="tag" :size="'sm'" /></span>
+ <span class="ps-3">
+ {{ getTaggedMonitorCount(tag) }}
+ <span v-if="filterState.tags?.includes(tag.id)" class="px-1 filter-active">
+ <font-awesome-icon icon="check" />
+ </span>
+ </span>
+ </div>
+ </div>
+ </li>
+ <li v-if="tagsList.length === 0">
+ <div class="dropdown-item disabled px-3">
+ {{ $t('No tags found.') }}
+ </div>
+ </li>
+ </template>
+ </MonitorListFilterDropdown>
+ </div>
+</template>
+
+<script>
+import MonitorListFilterDropdown from "./MonitorListFilterDropdown.vue";
+import Status from "./Status.vue";
+import Tag from "./Tag.vue";
+
+export default {
+ components: {
+ MonitorListFilterDropdown,
+ Status,
+ Tag,
+ },
+ props: {
+ filterState: {
+ type: Object,
+ required: true,
+ }
+ },
+ emits: [ "updateFilter" ],
+ data() {
+ return {
+ tagsList: [],
+ };
+ },
+ computed: {
+ numFiltersActive() {
+ let num = 0;
+
+ Object.values(this.filterState).forEach(item => {
+ if (item != null && item.length > 0) {
+ num += 1;
+ }
+ });
+
+ return num;
+ }
+ },
+ mounted() {
+ this.getExistingTags();
+ },
+ methods: {
+ toggleStatusFilter(status) {
+ let newFilter = {
+ ...this.filterState
+ };
+
+ if (newFilter.status == null) {
+ newFilter.status = [ status ];
+ } else {
+ if (newFilter.status.includes(status)) {
+ newFilter.status = newFilter.status.filter(item => item !== status);
+ } else {
+ newFilter.status.push(status);
+ }
+ }
+ this.$emit("updateFilter", newFilter);
+ },
+ toggleActiveFilter(active) {
+ let newFilter = {
+ ...this.filterState
+ };
+
+ if (newFilter.active == null) {
+ newFilter.active = [ active ];
+ } else {
+ if (newFilter.active.includes(active)) {
+ newFilter.active = newFilter.active.filter(item => item !== active);
+ } else {
+ newFilter.active.push(active);
+ }
+ }
+ this.$emit("updateFilter", newFilter);
+ },
+ toggleTagFilter(tag) {
+ let newFilter = {
+ ...this.filterState
+ };
+
+ if (newFilter.tags == null) {
+ newFilter.tags = [ tag.id ];
+ } else {
+ if (newFilter.tags.includes(tag.id)) {
+ newFilter.tags = newFilter.tags.filter(item => item !== tag.id);
+ } else {
+ newFilter.tags.push(tag.id);
+ }
+ }
+ this.$emit("updateFilter", newFilter);
+ },
+ clearFilters() {
+ this.$emit("updateFilter", {
+ status: null,
+ });
+ },
+ getExistingTags() {
+ this.$root.getSocket().emit("getTags", (res) => {
+ if (res.ok) {
+ this.tagsList = res.tags;
+ }
+ });
+ },
+ getTaggedMonitorCount(tag) {
+ return Object.values(this.$root.monitorList).filter(monitor => {
+ return monitor.tags.find(monitorTag => monitorTag.tag_id === tag.id);
+ }).length;
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.dropdown-item {
+ cursor: pointer;
+}
+
+.clear-filters-btn {
+ font-size: 0.8em;
+ margin-right: 5px;
+ display: flex;
+ align-items: center;
+ padding: 2px 10px;
+ border-radius: 16px;
+ background-color: transparent;
+
+ .dark & {
+ color: $dark-font-color;
+ border: 1px solid $dark-font-color2;
+ }
+
+ &.active {
+ border: 1px solid $highlight;
+ background-color: $highlight-white;
+
+ .dark & {
+ background-color: $dark-font-color2;
+ }
+ }
+}
+</style>
diff --git a/src/components/MonitorListFilterDropdown.vue b/src/components/MonitorListFilterDropdown.vue
new file mode 100644
index 0000000..79d664a
--- /dev/null
+++ b/src/components/MonitorListFilterDropdown.vue
@@ -0,0 +1,137 @@
+<template>
+ <div tabindex="-1" class="dropdown" @focusin="open = true" @focusout="handleFocusOut">
+ <button type="button" class="filter-dropdown-status" :class="{ 'active': filterActive }" tabindex="0">
+ <div class="px-1 d-flex align-items-center">
+ <slot name="status"></slot>
+ </div>
+ <span class="px-1">
+ <font-awesome-icon icon="angle-down" />
+ </span>
+ </button>
+ <ul class="filter-dropdown-menu" :class="{ 'open': open }">
+ <slot name="dropdown"></slot>
+ </ul>
+ </div>
+</template>
+
+<script>
+
+export default {
+ components: {
+
+ },
+ props: {
+ filterActive: {
+ type: Boolean,
+ required: true,
+ }
+ },
+ data() {
+ return {
+ open: false
+ };
+ },
+ methods: {
+ handleFocusOut(e) {
+ if (e.relatedTarget != null && this.$el.contains(e.relatedTarget)) {
+ return;
+ }
+ this.open = false;
+ }
+ }
+};
+</script>
+
+<style lang="scss">
+@import "../assets/vars.scss";
+@import "../assets/app.scss";
+
+.filter-dropdown-menu {
+ z-index: 100;
+ transition: all 0.2s;
+ padding: 5px 0 !important;
+ border-radius: 16px;
+ overflow: hidden;
+
+ position: absolute;
+ inset: 0 auto auto 0;
+ margin: 0;
+ transform: translate(0, 36px);
+ box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1);
+ visibility: hidden;
+ list-style: none;
+ height: 0;
+ opacity: 0;
+ background: white;
+
+ &.open {
+ height: unset;
+ visibility: inherit;
+ opacity: 1;
+ }
+
+ .dropdown-item {
+ padding: 5px 15px;
+ }
+
+ .dropdown-item:focus {
+ background: $highlight-white;
+
+ .dark & {
+ background: $dark-bg2;
+ }
+ }
+
+ .dark & {
+ background-color: $dark-bg;
+ color: $dark-font-color;
+ border-color: $dark-border-color;
+
+ .dropdown-item {
+ color: $dark-font-color;
+
+ &.active {
+ color: $dark-font-color2;
+ background-color: $highlight !important;
+ }
+
+ &:hover {
+ background-color: $dark-bg2;
+ }
+ }
+ }
+}
+
+.filter-dropdown-status {
+ @extend .btn-outline-normal;
+ display: flex;
+ align-items: center;
+ margin-left: 5px;
+ color: $link-color;
+
+ .dark & {
+ color: $dark-font-color;
+ }
+
+ &:focus {
+ background-color: $highlight-white;
+
+ .dark & {
+ background-color: $dark-font-color2;
+ }
+ }
+
+ &.active {
+ border: 1px solid $highlight;
+ background-color: $highlight-white;
+
+ .dark & {
+ background-color: $dark-font-color2;
+ }
+ }
+}
+
+.filter-active {
+ color: $highlight;
+}
+</style>
diff --git a/src/components/MonitorListItem.vue b/src/components/MonitorListItem.vue
new file mode 100644
index 0000000..74ba483
--- /dev/null
+++ b/src/components/MonitorListItem.vue
@@ -0,0 +1,256 @@
+<template>
+ <div>
+ <div :style="depthMargin">
+ <!-- Checkbox -->
+ <div v-if="isSelectMode" class="select-input-wrapper">
+ <input
+ class="form-check-input select-input"
+ type="checkbox"
+ :aria-label="$t('Check/Uncheck')"
+ :checked="isSelected(monitor.id)"
+ @click.stop="toggleSelection"
+ />
+ </div>
+
+ <router-link :to="monitorURL(monitor.id)" class="item" :class="{ 'disabled': ! monitor.active }">
+ <div class="row">
+ <div class="col-9 col-md-8 small-padding" :class="{ 'monitor-item': $root.userHeartbeatBar == 'bottom' || $root.userHeartbeatBar == 'none' }">
+ <div class="info">
+ <Uptime :monitor="monitor" type="24" :pill="true" />
+ <span v-if="hasChildren" class="collapse-padding" @click.prevent="changeCollapsed">
+ <font-awesome-icon icon="chevron-down" class="animated" :class="{ collapsed: isCollapsed}" />
+ </span>
+ {{ monitor.name }}
+ </div>
+ <div v-if="monitor.tags.length > 0" class="tags">
+ <Tag v-for="tag in monitor.tags" :key="tag" :item="tag" :size="'sm'" />
+ </div>
+ </div>
+ <div v-show="$root.userHeartbeatBar == 'normal'" :key="$root.userHeartbeatBar" class="col-3 col-md-4">
+ <HeartbeatBar ref="heartbeatBar" size="small" :monitor-id="monitor.id" />
+ </div>
+ </div>
+
+ <div v-if="$root.userHeartbeatBar == 'bottom'" class="row">
+ <div class="col-12 bottom-style">
+ <HeartbeatBar ref="heartbeatBar" size="small" :monitor-id="monitor.id" />
+ </div>
+ </div>
+ </router-link>
+ </div>
+
+ <transition name="slide-fade-up">
+ <div v-if="!isCollapsed" class="childs">
+ <MonitorListItem
+ v-for="(item, index) in sortedChildMonitorList"
+ :key="index"
+ :monitor="item"
+ :isSelectMode="isSelectMode"
+ :isSelected="isSelected"
+ :select="select"
+ :deselect="deselect"
+ :depth="depth + 1"
+ :filter-func="filterFunc"
+ :sort-func="sortFunc"
+ />
+ </div>
+ </transition>
+ </div>
+</template>
+
+<script>
+import HeartbeatBar from "../components/HeartbeatBar.vue";
+import Tag from "../components/Tag.vue";
+import Uptime from "../components/Uptime.vue";
+import { getMonitorRelativeURL } from "../util.ts";
+
+export default {
+ name: "MonitorListItem",
+ components: {
+ Uptime,
+ HeartbeatBar,
+ Tag,
+ },
+ props: {
+ /** Monitor this represents */
+ monitor: {
+ type: Object,
+ default: null,
+ },
+ /** If the user is in select mode */
+ isSelectMode: {
+ type: Boolean,
+ default: false,
+ },
+ /** How many ancestors are above this monitor */
+ depth: {
+ type: Number,
+ default: 0,
+ },
+ /** Callback to determine if monitor is selected */
+ isSelected: {
+ type: Function,
+ default: () => {}
+ },
+ /** Callback fired when monitor is selected */
+ select: {
+ type: Function,
+ default: () => {}
+ },
+ /** Callback fired when monitor is deselected */
+ deselect: {
+ type: Function,
+ default: () => {}
+ },
+ /** Function to filter child monitors */
+ filterFunc: {
+ type: Function,
+ default: () => {}
+ },
+ /** Function to sort child monitors */
+ sortFunc: {
+ type: Function,
+ default: () => {},
+ }
+ },
+ data() {
+ return {
+ isCollapsed: true,
+ };
+ },
+ computed: {
+ sortedChildMonitorList() {
+ let result = Object.values(this.$root.monitorList);
+
+ // Get children
+ result = result.filter(childMonitor => childMonitor.parent === this.monitor.id);
+
+ // Run filter on children
+ result = result.filter(this.filterFunc);
+
+ result.sort(this.sortFunc);
+
+ return result;
+ },
+ hasChildren() {
+ return this.sortedChildMonitorList.length > 0;
+ },
+ depthMargin() {
+ return {
+ marginLeft: `${31 * this.depth}px`,
+ };
+ },
+ },
+ watch: {
+ isSelectMode() {
+ // TODO: Resize the heartbeat bar, but too slow
+ // this.$refs.heartbeatBar.resize();
+ }
+ },
+ beforeMount() {
+
+ // Always unfold if monitor is accessed directly
+ if (this.monitor.childrenIDs.includes(parseInt(this.$route.params.id))) {
+ this.isCollapsed = false;
+ return;
+ }
+
+ // Set collapsed value based on local storage
+ let storage = window.localStorage.getItem("monitorCollapsed");
+ if (storage === null) {
+ return;
+ }
+
+ let storageObject = JSON.parse(storage);
+ if (storageObject[`monitor_${this.monitor.id}`] == null) {
+ return;
+ }
+
+ this.isCollapsed = storageObject[`monitor_${this.monitor.id}`];
+ },
+ methods: {
+ /**
+ * Changes the collapsed value of the current monitor and saves
+ * it to local storage
+ * @returns {void}
+ */
+ changeCollapsed() {
+ this.isCollapsed = !this.isCollapsed;
+
+ // Save collapsed value into local storage
+ let storage = window.localStorage.getItem("monitorCollapsed");
+ let storageObject = {};
+ if (storage !== null) {
+ storageObject = JSON.parse(storage);
+ }
+ storageObject[`monitor_${this.monitor.id}`] = this.isCollapsed;
+
+ window.localStorage.setItem("monitorCollapsed", JSON.stringify(storageObject));
+ },
+ /**
+ * Get URL of monitor
+ * @param {number} id ID of monitor
+ * @returns {string} Relative URL of monitor
+ */
+ monitorURL(id) {
+ return getMonitorRelativeURL(id);
+ },
+ /**
+ * Toggle selection of monitor
+ * @returns {void}
+ */
+ toggleSelection() {
+ if (this.isSelected(this.monitor.id)) {
+ this.deselect(this.monitor.id);
+ } else {
+ this.select(this.monitor.id);
+ }
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.small-padding {
+ padding-left: 5px !important;
+ padding-right: 5px !important;
+}
+
+.collapse-padding {
+ padding-left: 8px !important;
+ padding-right: 2px !important;
+}
+
+// .monitor-item {
+// width: 100%;
+// }
+
+.tags {
+ margin-top: 4px;
+ padding-left: 67px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0;
+}
+
+.collapsed {
+ transform: rotate(-90deg);
+}
+
+.animated {
+ transition: all 0.2s $easing-in;
+}
+
+.select-input-wrapper {
+ float: left;
+ margin-top: 15px;
+ margin-left: 3px;
+ margin-right: 10px;
+ padding-left: 4px;
+ position: relative;
+ z-index: 15;
+}
+
+</style>
diff --git a/src/components/MonitorSettingDialog.vue b/src/components/MonitorSettingDialog.vue
new file mode 100644
index 0000000..e6b2cd1
--- /dev/null
+++ b/src/components/MonitorSettingDialog.vue
@@ -0,0 +1,125 @@
+<template>
+ <div ref="MonitorSettingDialog" class="modal fade" tabindex="-1">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">
+ {{ $t("Monitor Setting", [monitor.name]) }}
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
+ </div>
+ <div class="modal-body">
+ <div class="my-3 form-check">
+ <input id="show-clickable-link" v-model="monitor.isClickAble" class="form-check-input" type="checkbox" @click="toggleLink(monitor.group_index, monitor.monitor_index)" />
+ <label class="form-check-label" for="show-clickable-link">
+ {{ $t("Show Clickable Link") }}
+ </label>
+ <div class="form-text">
+ {{ $t("Show Clickable Link Description") }}
+ </div>
+ </div>
+
+ <button
+ class="btn btn-primary btn-add-group me-2"
+ @click="$refs.badgeGeneratorDialog.show(monitor.id, monitor.name)"
+ >
+ <font-awesome-icon icon="certificate" />
+ {{ $t("Open Badge Generator") }}
+ </button>
+ </div>
+
+ <div class="modal-footer">
+ <button type="submit" class="btn btn-danger" data-bs-dismiss="modal">
+ {{ $t("Close") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <BadgeGeneratorDialog ref="badgeGeneratorDialog" />
+</template>
+
+<script lang="ts">
+import { Modal } from "bootstrap";
+import BadgeGeneratorDialog from "./BadgeGeneratorDialog.vue";
+
+export default {
+ components: {
+ BadgeGeneratorDialog
+ },
+ props: {},
+ emits: [],
+ data() {
+ return {
+ monitor: {
+ id: null,
+ name: null,
+ },
+ };
+ },
+
+ computed: {},
+
+ mounted() {
+ this.MonitorSettingDialog = new Modal(this.$refs.MonitorSettingDialog);
+ },
+
+ methods: {
+ /**
+ * Setting monitor
+ * @param {object} group Data of monitor
+ * @param {object} monitor Data of monitor
+ * @returns {void}
+ */
+ show(group, monitor) {
+ this.monitor = {
+ id: monitor.element.id,
+ name: monitor.element.name,
+ monitor_index: monitor.index,
+ group_index: group.index,
+ isClickAble: this.showLink(monitor),
+ };
+
+ this.MonitorSettingDialog.show();
+ },
+
+ /**
+ * Toggle the value of sendUrl
+ * @param {number} groupIndex Index of group monitor is member of
+ * @param {number} index Index of monitor within group
+ * @returns {void}
+ */
+ toggleLink(groupIndex, index) {
+ this.$root.publicGroupList[groupIndex].monitorList[index].sendUrl = !this.$root.publicGroupList[groupIndex].monitorList[index].sendUrl;
+ },
+
+ /**
+ * Should a link to the monitor be shown?
+ * Attempts to guess if a link should be shown based upon if
+ * sendUrl is set and if the URL is default or not.
+ * @param {object} monitor Monitor to check
+ * @param {boolean} ignoreSendUrl Should the presence of the sendUrl
+ * property be ignored. This will only work in edit mode.
+ * @returns {boolean} Should the link be shown?
+ */
+ showLink(monitor, ignoreSendUrl = false) {
+ // We must check if there are any elements in monitorList to
+ // prevent undefined errors if it hasn't been loaded yet
+ if (this.$parent.editMode && ignoreSendUrl && Object.keys(this.$root.monitorList).length) {
+ return this.$root.monitorList[monitor.element.id].type === "http" || this.$root.monitorList[monitor.element.id].type === "keyword" || this.$root.monitorList[monitor.element.id].type === "json-query";
+ }
+ return monitor.element.sendUrl && monitor.element.url && monitor.element.url !== "https://" && !this.editMode;
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.dark {
+ .modal-dialog .form-text, .modal-dialog p {
+ color: $dark-font-color;
+ }
+}
+</style>
diff --git a/src/components/NotificationDialog.vue b/src/components/NotificationDialog.vue
new file mode 100644
index 0000000..f6d7280
--- /dev/null
+++ b/src/components/NotificationDialog.vue
@@ -0,0 +1,352 @@
+<template>
+ <form @submit.prevent="submit">
+ <div ref="modal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 id="exampleModalLabel" class="modal-title">
+ {{ $t("Setup Notification") }}
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
+ </div>
+ <div class="modal-body">
+ <div class="mb-3">
+ <label for="notification-type" class="form-label">{{ $t("Notification Type") }}</label>
+ <select id="notification-type" v-model="notification.type" class="form-select">
+ <option v-for="(name, type) in notificationNameList.regularList" :key="type" :value="type">{{ name }}</option>
+ <optgroup :label="$t('notificationRegional')">
+ <option v-for="(name, type) in notificationNameList.regionalList" :key="type" :value="type">{{ name }}</option>
+ </optgroup>
+ </select>
+ </div>
+
+ <div class="mb-3">
+ <label for="notification-name" class="form-label">{{ $t("Friendly Name") }}</label>
+ <input id="notification-name" v-model="notification.name" type="text" class="form-control" required>
+ </div>
+
+ <!-- form body -->
+ <component :is="currentForm" />
+
+ <div class="mb-3 mt-4">
+ <hr class="dropdown-divider mb-4">
+
+ <div class="form-check form-switch">
+ <input v-model="notification.isDefault" class="form-check-input" type="checkbox">
+ <label class="form-check-label">{{ $t("Default enabled") }}</label>
+ </div>
+ <div class="form-text">
+ {{ $t("enableDefaultNotificationDescription") }}
+ </div>
+
+ <br>
+
+ <div class="form-check form-switch">
+ <input v-model="notification.applyExisting" class="form-check-input" type="checkbox">
+ <label class="form-check-label">{{ $t("Apply on all existing monitors") }}</label>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
+ {{ $t("Delete") }}
+ </button>
+ <button type="button" class="btn btn-warning" :disabled="processing" @click="test">
+ {{ $t("Test") }}
+ </button>
+ <button type="submit" class="btn btn-primary" :disabled="processing">
+ <div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
+ {{ $t("Save") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+
+ <Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteNotification">
+ {{ $t("deleteNotificationMsg") }}
+ </Confirm>
+</template>
+
+<script>
+import { Modal } from "bootstrap";
+
+import Confirm from "./Confirm.vue";
+import NotificationFormList from "./notifications";
+
+export default {
+ components: {
+ Confirm,
+ },
+ props: {},
+ emits: [ "added" ],
+ data() {
+ return {
+ model: null,
+ processing: false,
+ id: null,
+ notificationTypes: Object.keys(NotificationFormList).sort((a, b) => {
+ return a.toLowerCase().localeCompare(b.toLowerCase());
+ }),
+ notification: {
+ name: "",
+ /** @type { null | keyof NotificationFormList } */
+ type: null,
+ isDefault: false,
+ // Do not set default value here, please scroll to show()
+ }
+ };
+ },
+
+ computed: {
+ currentForm() {
+ if (!this.notification.type) {
+ return null;
+ }
+ return NotificationFormList[this.notification.type];
+ },
+
+ notificationNameList() {
+ let regularList = {
+ "alerta": "Alerta",
+ "AlertNow": "AlertNow",
+ "apprise": this.$t("apprise"),
+ "Bark": "Bark",
+ "Bitrix24": "Bitrix24",
+ "clicksendsms": "ClickSend SMS",
+ "CallMeBot": "CallMeBot (WhatsApp, Telegram Call, Facebook Messanger)",
+ "discord": "Discord",
+ "Elks": "46elks",
+ "GoogleChat": "Google Chat (Google Workspace)",
+ "gorush": "Gorush",
+ "gotify": "Gotify",
+ "GrafanaOncall": "Grafana Oncall",
+ "HeiiOnCall": "Heii On-Call",
+ "HomeAssistant": "Home Assistant",
+ "Keep": "Keep",
+ "Kook": "Kook",
+ "line": "LINE Messenger",
+ "LineNotify": "LINE Notify",
+ "lunasea": "LunaSea",
+ "matrix": "Matrix",
+ "mattermost": "Mattermost",
+ "nostr": "Nostr",
+ "ntfy": "Ntfy",
+ "octopush": "Octopush",
+ "OneBot": "OneBot",
+ "Onesender": "Onesender",
+ "Opsgenie": "Opsgenie",
+ "PagerDuty": "PagerDuty",
+ "PagerTree": "PagerTree",
+ "pushbullet": "Pushbullet",
+ "PushByTechulus": "Push by Techulus",
+ "pushover": "Pushover",
+ "pushy": "Pushy",
+ "rocket.chat": "Rocket.Chat",
+ "signal": "Signal",
+ "SIGNL4": "SIGNL4",
+ "slack": "Slack",
+ "squadcast": "SquadCast",
+ "SMSEagle": "SMSEagle",
+ "SMSPartner": "SMS Partner",
+ "smtp": this.$t("smtp"),
+ "stackfield": "Stackfield",
+ "teams": "Microsoft Teams",
+ "telegram": "Telegram",
+ "threema": "Threema",
+ "twilio": "Twilio",
+ "Splunk": "Splunk",
+ "webhook": "Webhook",
+ "GoAlert": "GoAlert",
+ "ZohoCliq": "ZohoCliq",
+ "SevenIO": "SevenIO",
+ "whapi": "WhatsApp (Whapi)",
+ "gtxmessaging": "GtxMessaging",
+ "Cellsynt": "Cellsynt",
+ "SendGrid": "SendGrid"
+ };
+
+ // Put notifications here if it's not supported in most regions or its documentation is not in English
+ let regionalList = {
+ "AliyunSMS": "AliyunSMS (阿里云短信服务)",
+ "DingDing": "DingDing (钉钉自定义机器人)",
+ "Feishu": "Feishu (飞书)",
+ "FlashDuty": "FlashDuty (快猫星云)",
+ "FreeMobile": "FreeMobile (mobile.free.fr)",
+ "PushDeer": "PushDeer",
+ "promosms": "PromoSMS",
+ "serwersms": "SerwerSMS.pl",
+ "SMSManager": "SmsManager (smsmanager.cz)",
+ "WeCom": "WeCom (企业微信群机器人)",
+ "ServerChan": "ServerChan (Server酱)",
+ "smsc": "SMSC",
+ "WPush": "WPush(wpush.cn)",
+ };
+
+ // Sort by notification name
+ // No idea how, but it works
+ // https://stackoverflow.com/questions/1069666/sorting-object-property-by-values
+ let sort = (list2) => {
+ return Object.entries(list2)
+ .sort(([ , a ], [ , b ]) => a.localeCompare(b))
+ .reduce((r, [ k, v ]) => ({
+ ...r,
+ [k]: v
+ }), {});
+ };
+
+ return {
+ regularList: sort(regularList),
+ regionalList: sort(regionalList),
+ };
+ },
+
+ notificationFullNameList() {
+ let list = {};
+ for (let [ key, value ] of Object.entries(this.notificationNameList.regularList)) {
+ list[key] = value;
+ }
+ for (let [ key, value ] of Object.entries(this.notificationNameList.regionalList)) {
+ list[key] = value;
+ }
+ return list;
+ },
+ },
+
+ watch: {
+ "notification.type"(to, from) {
+ let oldName;
+ if (from) {
+ oldName = this.getUniqueDefaultName(from);
+ } else {
+ oldName = "";
+ }
+
+ if (! this.notification.name || this.notification.name === oldName) {
+ this.notification.name = this.getUniqueDefaultName(to);
+ }
+ },
+ },
+ mounted() {
+ this.modal = new Modal(this.$refs.modal);
+ },
+ methods: {
+
+ /**
+ * Show dialog to confirm deletion
+ * @returns {void}
+ */
+ deleteConfirm() {
+ this.modal.hide();
+ this.$refs.confirmDelete.show();
+ },
+
+ /**
+ * Show settings for specified notification
+ * @param {number} notificationID ID of notification to show
+ * @returns {void}
+ */
+ show(notificationID) {
+ if (notificationID) {
+ this.id = notificationID;
+
+ for (let n of this.$root.notificationList) {
+ if (n.id === notificationID) {
+ this.notification = JSON.parse(n.config);
+ break;
+ }
+ }
+ } else {
+ this.id = null;
+ this.notification = {
+ name: "",
+ type: "telegram",
+ isDefault: false,
+ };
+ }
+
+ this.modal.show();
+ },
+
+ /**
+ * Submit the form to the server
+ * @returns {void}
+ */
+ submit() {
+ this.processing = true;
+ this.$root.getSocket().emit("addNotification", this.notification, this.id, (res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+
+ if (res.ok) {
+ this.modal.hide();
+
+ // Emit added event, doesn't emit edit.
+ if (! this.id) {
+ this.$emit("added", res.id);
+ }
+
+ }
+ });
+ },
+
+ /**
+ * Test the notification endpoint
+ * @returns {void}
+ */
+ test() {
+ this.processing = true;
+ this.$root.getSocket().emit("testNotification", this.notification, (res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+ });
+ },
+
+ /**
+ * Delete the notification endpoint
+ * @returns {void}
+ */
+ deleteNotification() {
+ this.processing = true;
+ this.$root.getSocket().emit("deleteNotification", this.id, (res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+
+ if (res.ok) {
+ this.modal.hide();
+ }
+ });
+ },
+ /**
+ * Get a unique default name for the notification
+ * @param {keyof NotificationFormList} notificationKey
+ * Notification to retrieve
+ * @returns {string} Default name
+ */
+ getUniqueDefaultName(notificationKey) {
+
+ let index = 1;
+ let name = "";
+ do {
+ name = this.$t("defaultNotificationName", {
+ notification: this.notificationFullNameList[notificationKey].replace(/\(.+\)/, "").trim(),
+ number: index++
+ });
+ } while (this.$root.notificationList.find(it => it.name === name));
+ return name;
+ }
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.dark {
+ .modal-dialog .form-text, .modal-dialog p {
+ color: $dark-font-color;
+ }
+}
+</style>
diff --git a/src/components/PingChart.vue b/src/components/PingChart.vue
new file mode 100644
index 0000000..60b0d3c
--- /dev/null
+++ b/src/components/PingChart.vue
@@ -0,0 +1,610 @@
+<template>
+ <div>
+ <div class="period-options">
+ <button
+ type="button" class="btn btn-light dropdown-toggle btn-period-toggle" data-bs-toggle="dropdown"
+ aria-expanded="false"
+ >
+ {{ chartPeriodOptions[chartPeriodHrs] }}&nbsp;
+ </button>
+ <ul class="dropdown-menu dropdown-menu-end">
+ <li v-for="(item, key) in chartPeriodOptions" :key="key">
+ <button
+ type="button" class="dropdown-item" :class="{ active: chartPeriodHrs == key }"
+ @click="chartPeriodHrs = key"
+ >
+ {{ item }}
+ </button>
+ </li>
+ </ul>
+ </div>
+ <div class="chart-wrapper" :class="{ loading: loading }">
+ <Line :data="chartData" :options="chartOptions" />
+ </div>
+ </div>
+</template>
+
+<script lang="js">
+import { BarController, BarElement, Chart, Filler, LinearScale, LineController, LineElement, PointElement, TimeScale, Tooltip } from "chart.js";
+import "chartjs-adapter-dayjs-4";
+import { Line } from "vue-chartjs";
+import { UP, DOWN, PENDING, MAINTENANCE } from "../util.ts";
+
+Chart.register(LineController, BarController, LineElement, PointElement, TimeScale, BarElement, LinearScale, Tooltip, Filler);
+
+export default {
+ components: { Line },
+ props: {
+ /** ID of monitor */
+ monitorId: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+
+ loading: false,
+
+ // Time period for the chart to display, in hours
+ // Initial value is 0 as a workaround for triggering a data fetch on created()
+ chartPeriodHrs: "0",
+
+ chartPeriodOptions: {
+ 0: this.$t("recent"),
+ 3: "3h",
+ 6: "6h",
+ 24: "24h",
+ 168: "1w",
+ },
+
+ chartRawData: null,
+ chartDataFetchInterval: null,
+ };
+ },
+ computed: {
+ chartOptions() {
+ return {
+ responsive: true,
+ maintainAspectRatio: false,
+ onResize: (chart) => {
+ chart.canvas.parentNode.style.position = "relative";
+ if (screen.width < 576) {
+ chart.canvas.parentNode.style.height = "275px";
+ } else if (screen.width < 768) {
+ chart.canvas.parentNode.style.height = "320px";
+ } else if (screen.width < 992) {
+ chart.canvas.parentNode.style.height = "300px";
+ } else {
+ chart.canvas.parentNode.style.height = "250px";
+ }
+ },
+ layout: {
+ padding: {
+ left: 10,
+ right: 30,
+ top: 30,
+ bottom: 10,
+ },
+ },
+
+ elements: {
+ point: {
+ // Hide points on chart unless mouse-over
+ radius: 0,
+ hitRadius: 100,
+ },
+ },
+ scales: {
+ x: {
+ type: "time",
+ time: {
+ minUnit: "minute",
+ round: "second",
+ tooltipFormat: "YYYY-MM-DD HH:mm:ss",
+ displayFormats: {
+ minute: "HH:mm",
+ hour: "MM-DD HH:mm",
+ }
+ },
+ ticks: {
+ sampleSize: 3,
+ maxRotation: 0,
+ autoSkipPadding: 30,
+ padding: 3,
+ },
+ grid: {
+ color: this.$root.theme === "light" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.1)",
+ offset: false,
+ },
+ },
+ y: {
+ title: {
+ display: true,
+ text: this.$t("respTime"),
+ },
+ offset: false,
+ grid: {
+ color: this.$root.theme === "light" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.1)",
+ },
+ },
+ y1: {
+ display: false,
+ position: "right",
+ grid: {
+ drawOnChartArea: false,
+ },
+ min: 0,
+ max: 1,
+ offset: false,
+ },
+ },
+ bounds: "ticks",
+ plugins: {
+ tooltip: {
+ mode: "nearest",
+ intersect: false,
+ padding: 10,
+ backgroundColor: this.$root.theme === "light" ? "rgba(212,232,222,1.0)" : "rgba(32,42,38,1.0)",
+ bodyColor: this.$root.theme === "light" ? "rgba(12,12,18,1.0)" : "rgba(220,220,220,1.0)",
+ titleColor: this.$root.theme === "light" ? "rgba(12,12,18,1.0)" : "rgba(220,220,220,1.0)",
+ filter: function (tooltipItem) {
+ return tooltipItem.datasetIndex === 0; // Hide tooltip on Bar Chart
+ },
+ callbacks: {
+ label: (context) => {
+ return ` ${new Intl.NumberFormat().format(context.parsed.y)} ms`;
+ },
+ }
+ },
+ legend: {
+ display: false,
+ },
+ },
+ };
+ },
+ chartData() {
+ if (this.chartPeriodHrs === "0") {
+ return this.getChartDatapointsFromHeartbeatList();
+ } else {
+ return this.getChartDatapointsFromStats();
+ }
+ },
+ },
+ watch: {
+ // Update chart data when the selected chart period changes
+ chartPeriodHrs: function (newPeriod) {
+ if (this.chartDataFetchInterval) {
+ clearInterval(this.chartDataFetchInterval);
+ this.chartDataFetchInterval = null;
+ }
+
+ // eslint-disable-next-line eqeqeq
+ if (newPeriod == "0") {
+ this.heartbeatList = null;
+ this.$root.storage().removeItem(`chart-period-${this.monitorId}`);
+ } else {
+ this.loading = true;
+
+ let period;
+ try {
+ period = parseInt(newPeriod);
+ } catch (e) {
+ // Invalid period
+ period = 24;
+ }
+
+ this.$root.getMonitorChartData(this.monitorId, period, (res) => {
+ if (!res.ok) {
+ this.$root.toastError(res.msg);
+ } else {
+ this.chartRawData = res.data;
+ this.$root.storage()[`chart-period-${this.monitorId}`] = newPeriod;
+ }
+ this.loading = false;
+ });
+
+ this.chartDataFetchInterval = setInterval(() => {
+ this.$root.getMonitorChartData(this.monitorId, period, (res) => {
+ if (res.ok) {
+ this.chartRawData = res.data;
+ }
+ });
+ }, 5 * 60 * 1000);
+ }
+ }
+ },
+ created() {
+ // Load chart period from storage if saved
+ let period = this.$root.storage()[`chart-period-${this.monitorId}`];
+ if (period != null) {
+ // Has this ever been not a string?
+ if (typeof period !== "string") {
+ period = period.toString();
+ }
+ this.chartPeriodHrs = period;
+ } else {
+ this.chartPeriodHrs = "24";
+ }
+ },
+ beforeUnmount() {
+ if (this.chartDataFetchInterval) {
+ clearInterval(this.chartDataFetchInterval);
+ }
+ },
+ methods: {
+ // Get color of bar chart for this datapoint
+ getBarColorForDatapoint(datapoint) {
+ if (datapoint.maintenance != null) {
+ // Target is in maintenance
+ return "rgba(23,71,245,0.41)";
+ } else if (datapoint.down === 0) {
+ // Target is up, no need to display a bar
+ return "#000";
+ } else if (datapoint.up === 0) {
+ // Target is down
+ return "rgba(220, 53, 69, 0.41)";
+ } else {
+ // Show yellow for mixed status
+ return "rgba(245, 182, 23, 0.41)";
+ }
+ },
+ // push datapoint to chartData
+ pushDatapoint(datapoint, avgPingData, minPingData, maxPingData, downData, colorData) {
+ const x = this.$root.unixToDateTime(datapoint.timestamp);
+
+ // Show ping values if it was up in this period
+ avgPingData.push({
+ x,
+ y: datapoint.up > 0 && datapoint.avgPing > 0 ? datapoint.avgPing : null,
+ });
+ minPingData.push({
+ x,
+ y: datapoint.up > 0 && datapoint.avgPing > 0 ? datapoint.minPing : null,
+ });
+ maxPingData.push({
+ x,
+ y: datapoint.up > 0 && datapoint.avgPing > 0 ? datapoint.maxPing : null,
+ });
+ downData.push({
+ x,
+ y: datapoint.down + (datapoint.maintenance || 0),
+ });
+
+ colorData.push(this.getBarColorForDatapoint(datapoint));
+ },
+ // get the average of a set of datapoints
+ getAverage(datapoints) {
+ const totalUp = datapoints.reduce((total, current) => total + current.up, 0);
+ const totalDown = datapoints.reduce((total, current) => total + current.down, 0);
+ const totalMaintenance = datapoints.reduce((total, current) => total + (current.maintenance || 0), 0);
+ const totalPing = datapoints.reduce((total, current) => total + current.avgPing * current.up, 0);
+ const minPing = datapoints.reduce((min, current) => Math.min(min, current.minPing), Infinity);
+ const maxPing = datapoints.reduce((max, current) => Math.max(max, current.maxPing), 0);
+
+ // Find the middle timestamp to use
+ let midpoint = Math.floor(datapoints.length / 2);
+
+ return {
+ timestamp: datapoints[midpoint].timestamp,
+ up: totalUp,
+ down: totalDown,
+ maintenance: totalMaintenance > 0 ? totalMaintenance : undefined,
+ avgPing: totalUp > 0 ? totalPing / totalUp : 0,
+ minPing,
+ maxPing,
+ };
+ },
+ getChartDatapointsFromHeartbeatList() {
+ // Render chart using heartbeatList
+ let lastHeartbeatTime;
+ const monitorInterval = this.$root.monitorList[this.monitorId]?.interval;
+ let pingData = []; // Ping Data for Line Chart, y-axis contains ping time
+ let downData = []; // Down Data for Bar Chart, y-axis is 1 if target is down (red color), under maintenance (blue color) or pending (orange color), 0 if target is up
+ let colorData = []; // Color Data for Bar Chart
+
+ let heartbeatList = (this.monitorId in this.$root.heartbeatList && this.$root.heartbeatList[this.monitorId]) || [];
+
+ for (const beat of heartbeatList) {
+ const beatTime = this.$root.toDayjs(beat.time);
+ const x = beatTime.format("YYYY-MM-DD HH:mm:ss");
+
+ // Insert empty datapoint to separate big gaps
+ if (lastHeartbeatTime && monitorInterval) {
+ const diff = Math.abs(beatTime.diff(lastHeartbeatTime));
+ if (diff > monitorInterval * 1000 * 10) {
+ // Big gap detected
+ const gapX = [
+ lastHeartbeatTime.add(monitorInterval, "second").format("YYYY-MM-DD HH:mm:ss"),
+ beatTime.subtract(monitorInterval, "second").format("YYYY-MM-DD HH:mm:ss")
+ ];
+
+ for (const x of gapX) {
+ pingData.push({
+ x,
+ y: null,
+ });
+ downData.push({
+ x,
+ y: null,
+ });
+ colorData.push("#000");
+ }
+
+ }
+ }
+
+ pingData.push({
+ x,
+ y: beat.status === UP ? beat.ping : null,
+ });
+ downData.push({
+ x,
+ y: (beat.status === DOWN || beat.status === MAINTENANCE || beat.status === PENDING) ? 1 : 0,
+ });
+ switch (beat.status) {
+ case MAINTENANCE:
+ colorData.push("rgba(23 ,71, 245, 0.41)");
+ break;
+ case PENDING:
+ colorData.push("rgba(245, 182, 23, 0.41)");
+ break;
+ default:
+ colorData.push("rgba(220, 53, 69, 0.41)");
+ }
+
+ lastHeartbeatTime = beatTime;
+ }
+
+ return {
+ datasets: [
+ {
+ // Line Chart
+ data: pingData,
+ fill: "origin",
+ tension: 0.2,
+ borderColor: "#5CDD8B",
+ backgroundColor: "#5CDD8B38",
+ yAxisID: "y",
+ label: "ping",
+ },
+ {
+ // Bar Chart
+ type: "bar",
+ data: downData,
+ borderColor: "#00000000",
+ backgroundColor: colorData,
+ yAxisID: "y1",
+ barThickness: "flex",
+ barPercentage: 1,
+ categoryPercentage: 1,
+ inflateAmount: 0.05,
+ label: "status",
+ },
+ ],
+ };
+ },
+ getChartDatapointsFromStats() {
+ // Render chart using UptimeCalculator data
+ let lastHeartbeatTime;
+ const monitorInterval = this.$root.monitorList[this.monitorId]?.interval;
+
+ let avgPingData = []; // Ping Data for Line Chart, y-axis contains ping time
+ let minPingData = []; // Ping Data for Line Chart, y-axis contains ping time
+ let maxPingData = []; // Ping Data for Line Chart, y-axis contains ping time
+ let downData = []; // Down Data for Bar Chart, y-axis is number of down datapoints in this period
+ let colorData = []; // Color Data for Bar Chart
+
+ const period = parseInt(this.chartPeriodHrs);
+ let aggregatePoints = period > 6 ? 12 : 4;
+
+ let aggregateBuffer = [];
+
+ if (this.chartRawData) {
+ for (const datapoint of this.chartRawData) {
+ // Empty datapoints are ignored
+ if (datapoint.up === 0 && datapoint.down === 0 && datapoint.maintenance === 0) {
+ continue;
+ }
+
+ const beatTime = this.$root.unixToDayjs(datapoint.timestamp);
+
+ // Insert empty datapoint to separate big gaps
+ if (lastHeartbeatTime && monitorInterval) {
+ const diff = Math.abs(beatTime.diff(lastHeartbeatTime));
+ const oneSecond = 1000;
+ const oneMinute = oneSecond * 60;
+ const oneHour = oneMinute * 60;
+ if ((period <= 24 && diff > Math.max(oneMinute * 10, monitorInterval * oneSecond * 10)) ||
+ (period > 24 && diff > Math.max(oneHour * 10, monitorInterval * oneSecond * 10))) {
+ // Big gap detected
+ // Clear the aggregate buffer
+ if (aggregateBuffer.length > 0) {
+ const average = this.getAverage(aggregateBuffer);
+ this.pushDatapoint(average, avgPingData, minPingData, maxPingData, downData, colorData);
+ aggregateBuffer = [];
+ }
+
+ const gapX = [
+ lastHeartbeatTime.subtract(monitorInterval, "second").format("YYYY-MM-DD HH:mm:ss"),
+ this.$root.unixToDateTime(datapoint.timestamp + 60),
+ ];
+
+ for (const x of gapX) {
+ avgPingData.push({
+ x,
+ y: null,
+ });
+ minPingData.push({
+ x,
+ y: null,
+ });
+ maxPingData.push({
+ x,
+ y: null,
+ });
+ downData.push({
+ x,
+ y: null,
+ });
+ colorData.push("#000");
+ }
+
+ }
+ }
+
+ if (datapoint.up > 0 && this.chartRawData.length > aggregatePoints * 2) {
+ // Aggregate Up data using a sliding window
+ aggregateBuffer.push(datapoint);
+
+ if (aggregateBuffer.length === aggregatePoints) {
+ const average = this.getAverage(aggregateBuffer);
+ this.pushDatapoint(average, avgPingData, minPingData, maxPingData, downData, colorData);
+ // Remove the first half of the buffer
+ aggregateBuffer = aggregateBuffer.slice(Math.floor(aggregatePoints / 2));
+ }
+ } else {
+ // datapoint is fully down or too few datapoints, no need to aggregate
+ // Clear the aggregate buffer
+ if (aggregateBuffer.length > 0) {
+ const average = this.getAverage(aggregateBuffer);
+ this.pushDatapoint(average, avgPingData, minPingData, maxPingData, downData, colorData);
+ aggregateBuffer = [];
+ }
+
+ this.pushDatapoint(datapoint, avgPingData, minPingData, maxPingData, downData, colorData);
+ }
+
+ lastHeartbeatTime = beatTime;
+ }
+ // Clear the aggregate buffer if there are still datapoints
+ if (aggregateBuffer.length > 0) {
+ const average = this.getAverage(aggregateBuffer);
+ this.pushDatapoint(average, avgPingData, minPingData, maxPingData, downData, colorData);
+ aggregateBuffer = [];
+ }
+ }
+
+ return {
+ datasets: [
+ {
+ // average ping chart
+ data: avgPingData,
+ fill: "origin",
+ tension: 0.2,
+ borderColor: "#5CDD8B",
+ backgroundColor: "#5CDD8B06",
+ yAxisID: "y",
+ label: "avg-ping",
+ },
+ {
+ // minimum ping chart
+ data: minPingData,
+ fill: "origin",
+ tension: 0.2,
+ borderColor: "#3CBD6B38",
+ backgroundColor: "#5CDD8B06",
+ yAxisID: "y",
+ label: "min-ping",
+ },
+ {
+ // maximum ping chart
+ data: maxPingData,
+ fill: "origin",
+ tension: 0.2,
+ borderColor: "#7CBD6B38",
+ backgroundColor: "#5CDD8B06",
+ yAxisID: "y",
+ label: "max-ping",
+ },
+ {
+ // Bar Chart
+ type: "bar",
+ data: downData,
+ borderColor: "#00000000",
+ backgroundColor: colorData,
+ yAxisID: "y1",
+ barThickness: "flex",
+ barPercentage: 1,
+ categoryPercentage: 1,
+ inflateAmount: 0.05,
+ label: "status",
+ },
+ ],
+ };
+ },
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.form-select {
+ width: unset;
+ display: inline-flex;
+}
+
+.period-options {
+ padding: 0.1em 1em;
+ margin-bottom: -1.2em;
+ float: right;
+ position: relative;
+ z-index: 10;
+
+ .dropdown-menu {
+ padding: 0;
+ min-width: 50px;
+ font-size: 0.9em;
+
+ .dark & {
+ background: $dark-bg;
+ }
+
+ .dropdown-item {
+ border-radius: 0.3rem;
+ padding: 2px 16px 4px;
+
+ .dark & {
+ background: $dark-bg;
+ color: $dark-font-color;
+ }
+
+ .dark &:hover {
+ background: $dark-font-color;
+ color: $dark-font-color2;
+ }
+ }
+
+ .dark & .dropdown-item.active {
+ background: $primary;
+ color: $dark-font-color2;
+ }
+ }
+
+ .btn-period-toggle {
+ padding: 2px 15px;
+ background: transparent;
+ border: 0;
+ color: $link-color;
+ opacity: 0.7;
+ font-size: 0.9em;
+
+ &::after {
+ vertical-align: 0.155em;
+ }
+
+ .dark & {
+ color: $dark-font-color;
+ }
+ }
+}
+
+.chart-wrapper {
+ margin-bottom: 0.5em;
+
+ &.loading {
+ filter: blur(10px);
+ }
+}
+</style>
diff --git a/src/components/ProxyDialog.vue b/src/components/ProxyDialog.vue
new file mode 100644
index 0000000..fc92359
--- /dev/null
+++ b/src/components/ProxyDialog.vue
@@ -0,0 +1,224 @@
+<template>
+ <form @submit.prevent="submit">
+ <div ref="modal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 id="exampleModalLabel" class="modal-title">
+ {{ $t("Setup Proxy") }}
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
+ </div>
+ <div class="modal-body">
+ <div class="mb-3">
+ <label for="proxy-protocol" class="form-label">{{ $t("Proxy Protocol") }}</label>
+ <select id="proxy-protocol" v-model="proxy.protocol" class="form-select">
+ <option value="https">HTTPS</option>
+ <option value="http">HTTP</option>
+ <option value="socks">SOCKS</option>
+ <option value="socks5">SOCKS v5</option>
+ <option value="socks5h">SOCKS v5 (+DNS)</option>
+ <option value="socks4">SOCKS v4</option>
+ </select>
+ </div>
+
+ <div class="mb-3">
+ <label for="proxy-host" class="form-label">{{ $t("Proxy Server") }}</label>
+ <div class="d-flex">
+ <input id="proxy-host" v-model="proxy.host" type="text" class="form-control" required :placeholder="$t('Server Address')">
+ <input v-model="proxy.port" type="number" class="form-control ms-2" style="width: 100px;" required min="1" max="65535" :placeholder="$t('Port')">
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <div class="form-check form-switch">
+ <input id="mark-auth" v-model="proxy.auth" class="form-check-input" type="checkbox">
+ <label for="mark-auth" class="form-check-label">{{ $t("Proxy server has authentication") }}</label>
+ </div>
+ </div>
+
+ <div v-if="proxy.auth" class="mb-3">
+ <label for="proxy-username" class="form-label">{{ $t("User") }}</label>
+ <input id="proxy-username" v-model="proxy.username" type="text" class="form-control" required>
+ </div>
+
+ <div v-if="proxy.auth" class="mb-3">
+ <label for="proxy-password" class="form-label">{{ $t("Password") }}</label>
+ <input id="proxy-password" v-model="proxy.password" type="password" class="form-control" required>
+ </div>
+
+ <div class="mb-3 mt-4">
+ <hr class="dropdown-divider mb-4">
+
+ <div class="form-check form-switch">
+ <input id="mark-active" v-model="proxy.active" class="form-check-input" type="checkbox">
+ <label for="mark-active" class="form-check-label">{{ $t("enabled") }}</label>
+ </div>
+ <div class="form-text">
+ {{ $t("enableProxyDescription") }}
+ </div>
+
+ <br />
+
+ <div class="form-check form-switch">
+ <input id="mark-default" v-model="proxy.default" class="form-check-input" type="checkbox">
+ <label for="mark-default" class="form-check-label">{{ $t("setAsDefault") }}</label>
+ </div>
+ <div class="form-text">
+ {{ $t("setAsDefaultProxyDescription") }}
+ </div>
+
+ <br />
+
+ <div class="form-check form-switch">
+ <input id="apply-existing" v-model="proxy.applyExisting" class="form-check-input" type="checkbox">
+ <label class="form-check-label" for="apply-existing">{{ $t("Apply on all existing monitors") }}</label>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
+ {{ $t("Delete") }}
+ </button>
+ <button type="submit" class="btn btn-primary" :disabled="processing">
+ <div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
+ {{ $t("Save") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+
+ <Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteProxy">
+ {{ $t("deleteProxyMsg") }}
+ </Confirm>
+</template>
+
+<script lang="ts">
+import { Modal } from "bootstrap";
+
+import Confirm from "./Confirm.vue";
+
+export default {
+ components: {
+ Confirm,
+ },
+ props: {},
+ emits: [ "added" ],
+ data() {
+ return {
+ model: null,
+ processing: false,
+ id: null,
+ proxy: {
+ protocol: null,
+ host: null,
+ port: null,
+ auth: false,
+ username: null,
+ password: null,
+ active: false,
+ default: false,
+ applyExisting: false,
+ }
+ };
+ },
+
+ mounted() {
+ this.modal = new Modal(this.$refs.modal);
+ },
+
+ methods: {
+ /**
+ * Show dialog to confirm deletion
+ * @returns {void}
+ */
+ deleteConfirm() {
+ this.modal.hide();
+ this.$refs.confirmDelete.show();
+ },
+
+ /**
+ * Show settings for specified proxy
+ * @param {number} proxyID ID of proxy to show
+ * @returns {void}
+ */
+ show(proxyID) {
+ if (proxyID) {
+ this.id = proxyID;
+
+ for (let proxy of this.$root.proxyList) {
+ if (proxy.id === proxyID) {
+ this.proxy = proxy;
+ break;
+ }
+ }
+ } else {
+ this.id = null;
+ this.proxy = {
+ protocol: "https",
+ host: null,
+ port: null,
+ auth: false,
+ username: null,
+ password: null,
+ active: true,
+ default: false,
+ applyExisting: false,
+ };
+ }
+
+ this.modal.show();
+ },
+
+ /**
+ * Submit form data for saving
+ * @returns {void}
+ */
+ submit() {
+ this.processing = true;
+ this.$root.getSocket().emit("addProxy", this.proxy, this.id, (res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+
+ if (res.ok) {
+ this.modal.hide();
+
+ // Emit added event, doesn't emit edit.
+ if (! this.id) {
+ this.$emit("added", res.id);
+ }
+ }
+ });
+ },
+
+ /**
+ * Delete this proxy
+ * @returns {void}
+ */
+ deleteProxy() {
+ this.processing = true;
+ this.$root.getSocket().emit("deleteProxy", this.id, (res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+
+ if (res.ok) {
+ this.modal.hide();
+ }
+ });
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.dark {
+ .modal-dialog .form-text, .modal-dialog p {
+ color: $dark-font-color;
+ }
+}
+</style>
diff --git a/src/components/PublicGroupList.vue b/src/components/PublicGroupList.vue
new file mode 100644
index 0000000..bacddbf
--- /dev/null
+++ b/src/components/PublicGroupList.vue
@@ -0,0 +1,273 @@
+<template>
+ <!-- Group List -->
+ <Draggable
+ v-model="$root.publicGroupList"
+ :disabled="!editMode"
+ item-key="id"
+ :animation="100"
+ >
+ <template #item="group">
+ <div class="mb-5" data-testid="group">
+ <!-- Group Title -->
+ <h2 class="group-title">
+ <font-awesome-icon v-if="editMode && showGroupDrag" icon="arrows-alt-v" class="action drag me-3" />
+ <font-awesome-icon v-if="editMode" icon="times" class="action remove me-3" @click="removeGroup(group.index)" />
+ <Editable v-model="group.element.name" :contenteditable="editMode" tag="span" data-testid="group-name" />
+ </h2>
+
+ <div class="shadow-box monitor-list mt-4 position-relative">
+ <div v-if="group.element.monitorList.length === 0" class="text-center no-monitor-msg">
+ {{ $t("No Monitors") }}
+ </div>
+
+ <!-- Monitor List -->
+ <!-- animation is not working, no idea why -->
+ <Draggable
+ v-model="group.element.monitorList"
+ class="monitor-list"
+ group="same-group"
+ :disabled="!editMode"
+ :animation="100"
+ item-key="id"
+ >
+ <template #item="monitor">
+ <div class="item" data-testid="monitor">
+ <div class="row">
+ <div class="col-9 col-md-8 small-padding">
+ <div class="info">
+ <font-awesome-icon v-if="editMode" icon="arrows-alt-v" class="action drag me-3" />
+ <font-awesome-icon v-if="editMode" icon="times" class="action remove me-3" @click="removeMonitor(group.index, monitor.index)" />
+
+ <Uptime :monitor="monitor.element" type="24" :pill="true" />
+ <a
+ v-if="showLink(monitor)"
+ :href="monitor.element.url"
+ class="item-name"
+ target="_blank"
+ rel="noopener noreferrer"
+ data-testid="monitor-name"
+ >
+ {{ monitor.element.name }}
+ </a>
+ <p v-else class="item-name" data-testid="monitor-name"> {{ monitor.element.name }} </p>
+
+ <span
+ title="Setting"
+ >
+ <font-awesome-icon
+ v-if="editMode"
+ :class="{'link-active': true, 'btn-link': true}"
+ icon="cog" class="action me-3"
+ @click="$refs.monitorSettingDialog.show(group, monitor)"
+ />
+ </span>
+ </div>
+ <div class="extra-info">
+ <div v-if="showCertificateExpiry && monitor.element.certExpiryDaysRemaining">
+ <Tag :item="{name: $t('Cert Exp.'), value: formattedCertExpiryMessage(monitor), color: certExpiryColor(monitor)}" :size="'sm'" />
+ </div>
+ <div v-if="showTags">
+ <Tag v-for="tag in monitor.element.tags" :key="tag" :item="tag" :size="'sm'" data-testid="monitor-tag" />
+ </div>
+ </div>
+ </div>
+ <div :key="$root.userHeartbeatBar" class="col-3 col-md-4">
+ <HeartbeatBar size="mid" :monitor-id="monitor.element.id" />
+ </div>
+ </div>
+ </div>
+ </template>
+ </Draggable>
+ </div>
+ </div>
+ </template>
+ </Draggable>
+ <MonitorSettingDialog ref="monitorSettingDialog" />
+</template>
+
+<script>
+import MonitorSettingDialog from "./MonitorSettingDialog.vue";
+import Draggable from "vuedraggable";
+import HeartbeatBar from "./HeartbeatBar.vue";
+import Uptime from "./Uptime.vue";
+import Tag from "./Tag.vue";
+
+export default {
+ components: {
+ MonitorSettingDialog,
+ Draggable,
+ HeartbeatBar,
+ Uptime,
+ Tag,
+ },
+ props: {
+ /** Are we in edit mode? */
+ editMode: {
+ type: Boolean,
+ required: true,
+ },
+ /** Should tags be shown? */
+ showTags: {
+ type: Boolean,
+ },
+ /** Should expiry be shown? */
+ showCertificateExpiry: {
+ type: Boolean,
+ }
+ },
+ data() {
+ return {
+
+ };
+ },
+ computed: {
+ showGroupDrag() {
+ return (this.$root.publicGroupList.length >= 2);
+ }
+ },
+ created() {
+
+ },
+ methods: {
+ /**
+ * Remove the specified group
+ * @param {number} index Index of group to remove
+ * @returns {void}
+ */
+ removeGroup(index) {
+ this.$root.publicGroupList.splice(index, 1);
+ },
+
+ /**
+ * Remove a monitor from a group
+ * @param {number} groupIndex Index of group to remove monitor
+ * from
+ * @param {number} index Index of monitor to remove
+ * @returns {void}
+ */
+ removeMonitor(groupIndex, index) {
+ this.$root.publicGroupList[groupIndex].monitorList.splice(index, 1);
+ },
+
+ /**
+ * Should a link to the monitor be shown?
+ * Attempts to guess if a link should be shown based upon if
+ * sendUrl is set and if the URL is default or not.
+ * @param {object} monitor Monitor to check
+ * @param {boolean} ignoreSendUrl Should the presence of the sendUrl
+ * property be ignored. This will only work in edit mode.
+ * @returns {boolean} Should the link be shown
+ */
+ showLink(monitor, ignoreSendUrl = false) {
+ // We must check if there are any elements in monitorList to
+ // prevent undefined errors if it hasn't been loaded yet
+ if (this.$parent.editMode && ignoreSendUrl && Object.keys(this.$root.monitorList).length) {
+ return this.$root.monitorList[monitor.element.id].type === "http" || this.$root.monitorList[monitor.element.id].type === "keyword" || this.$root.monitorList[monitor.element.id].type === "json-query";
+ }
+ return monitor.element.sendUrl && monitor.element.url && monitor.element.url !== "https://";
+ },
+
+ /**
+ * Returns formatted certificate expiry or Bad cert message
+ * @param {object} monitor Monitor to show expiry for
+ * @returns {string} Certificate expiry message
+ */
+ formattedCertExpiryMessage(monitor) {
+ if (monitor?.element?.validCert && monitor?.element?.certExpiryDaysRemaining) {
+ return monitor.element.certExpiryDaysRemaining + " " + this.$tc("day", monitor.element.certExpiryDaysRemaining);
+ } else if (monitor?.element?.validCert === false) {
+ return this.$t("noOrBadCertificate");
+ } else {
+ return this.$t("Unknown") + " " + this.$tc("day", 2);
+ }
+ },
+
+ /**
+ * Returns certificate expiry color based on days remaining
+ * @param {object} monitor Monitor to show expiry for
+ * @returns {string} Color for certificate expiry
+ */
+ certExpiryColor(monitor) {
+ if (monitor?.element?.validCert && monitor.element.certExpiryDaysRemaining > 7) {
+ return "#059669";
+ }
+ return "#DC2626";
+ },
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars";
+
+.extra-info {
+ display: flex;
+ margin-bottom: 0.5rem;
+}
+
+.extra-info > div > div:first-child {
+ margin-left: 0 !important;
+}
+
+.no-monitor-msg {
+ position: absolute;
+ width: 100%;
+ top: 20px;
+ left: 0;
+}
+
+.monitor-list {
+ min-height: 46px;
+}
+
+.item-name {
+ padding-left: 5px;
+ padding-right: 5px;
+ margin: 0;
+ display: inline-block;
+}
+
+.btn-link {
+ color: #bbbbbb;
+ margin-left: 5px;
+}
+
+.link-active {
+ color: $primary;
+}
+
+.flip-list-move {
+ transition: transform 0.5s;
+}
+
+.no-move {
+ transition: transform 0s;
+}
+
+.drag {
+ color: #bbb;
+ cursor: grab;
+}
+
+.remove {
+ color: $danger;
+}
+
+.group-title {
+ span {
+ display: inline-block;
+ min-width: 15px;
+ }
+}
+
+.mobile {
+ .item {
+ padding: 13px 0 10px;
+ }
+}
+
+.bg-maintenance {
+ background-color: $maintenance;
+}
+
+</style>
diff --git a/src/components/RemoteBrowserDialog.vue b/src/components/RemoteBrowserDialog.vue
new file mode 100644
index 0000000..941ab8f
--- /dev/null
+++ b/src/components/RemoteBrowserDialog.vue
@@ -0,0 +1,185 @@
+<template>
+ <form @submit.prevent="submit">
+ <div ref="modal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 id="exampleModalLabel" class="modal-title">
+ {{ $t("Add a Remote Browser") }}
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
+ </div>
+ <div class="modal-body">
+ <div class="mb-3">
+ <label for="remote-browser-name" class="form-label">{{ $t("Friendly Name") }}</label>
+ <input id="remote-browser-name" v-model="remoteBrowser.name" type="text" class="form-control" required>
+ </div>
+
+ <div class="mb-3">
+ <label for="remote-browser-url" class="form-label">{{ $t("URL") }}</label>
+ <input id="remote-browser-url" v-model="remoteBrowser.url" type="text" class="form-control" required>
+
+ <div class="form-text mt-3">
+ {{ $t("Examples") }}:
+ <ul>
+ <li>ws://chrome.browserless.io/playwright?token=YOUR-API-TOKEN</li>
+ </ul>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
+ {{ $t("Delete") }}
+ </button>
+ <button type="button" class="btn btn-warning" :disabled="processing" @click="test">
+ {{ $t("Test") }}
+ </button>
+ <button type="submit" class="btn btn-primary" :disabled="processing">
+ <div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
+ {{ $t("Save") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+
+ <Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteDockerHost">
+ {{ $t("deleteRemoteBrowserMessage") }}
+ </Confirm>
+</template>
+
+<script>
+import { Modal } from "bootstrap";
+import Confirm from "./Confirm.vue";
+
+export default {
+ components: {
+ Confirm,
+ },
+ props: {},
+ emits: [ "added" ],
+ data() {
+ return {
+ modal: null,
+ processing: false,
+ id: null,
+ remoteBrowser: {
+ name: "",
+ url: "",
+ // Do not set default value here, please scroll to show()
+ }
+ };
+ },
+
+ mounted() {
+ this.modal = new Modal(this.$refs.modal);
+ },
+ methods: {
+
+ /**
+ * Confirm deletion of docker host
+ * @returns {void}
+ */
+ deleteConfirm() {
+ this.modal.hide();
+ this.$refs.confirmDelete.show();
+ },
+
+ /**
+ * Show specified docker host
+ * @param {number} remoteBrowserID ID of host to show
+ * @returns {void}
+ */
+ show(remoteBrowserID) {
+ if (remoteBrowserID) {
+ let found = false;
+
+ this.id = remoteBrowserID;
+
+ for (let n of this.$root.remoteBrowserList) {
+ if (n.id === remoteBrowserID) {
+ this.remoteBrowser = n;
+ found = true;
+ break;
+ }
+ }
+
+ if (!found) {
+ this.$root.toastError(this.$t("Remote Browser not found!"));
+ }
+
+ } else {
+ this.id = null;
+ this.remoteBrowser = {
+ name: "",
+ url: "",
+ };
+ }
+
+ this.modal.show();
+ },
+
+ /**
+ * Add docker host
+ * @returns {void}
+ */
+ submit() {
+ this.processing = true;
+ this.$root.getSocket().emit("addRemoteBrowser", this.remoteBrowser, this.id, (res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+
+ if (res.ok) {
+ this.modal.hide();
+
+ // Emit added event, doesn't emit edit.
+ if (! this.id) {
+ this.$emit("added", res.id);
+ }
+
+ }
+ });
+ },
+
+ /**
+ * Test the docker host
+ * @returns {void}
+ */
+ test() {
+ this.processing = true;
+ this.$root.getSocket().emit("testRemoteBrowser", this.remoteBrowser, (res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+ });
+ },
+
+ /**
+ * Delete this docker host
+ * @returns {void}
+ */
+ deleteDockerHost() {
+ this.processing = true;
+ this.$root.getSocket().emit("deleteRemoteBrowser", this.id, (res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+
+ if (res.ok) {
+ this.modal.hide();
+ }
+ });
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.dark {
+ .modal-dialog .form-text, .modal-dialog p {
+ color: $dark-font-color;
+ }
+}
+</style>
diff --git a/src/components/ScreenshotDialog.vue b/src/components/ScreenshotDialog.vue
new file mode 100644
index 0000000..bc82909
--- /dev/null
+++ b/src/components/ScreenshotDialog.vue
@@ -0,0 +1,52 @@
+<template>
+ <div ref="modal" class="modal fade" tabindex="-1">
+ <div class="modal-dialog modal-xl modal-dialog-centered">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">
+ {{ $t("Browser Screenshot") }}
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
+ </div>
+ <div class="modal-body"></div>
+ <img :src="imageURL" alt="screenshot of the website">
+ </div>
+ </div>
+ </div>
+</template>
+
+<script lang="ts">
+import { Modal } from "bootstrap";
+
+export default {
+ props: {
+ imageURL: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ modal: null,
+ };
+ },
+ mounted() {
+ this.modal = new Modal(this.$refs.modal);
+ },
+ methods: {
+ show() {
+ this.modal.show();
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.dark {
+ .modal-dialog .form-text, .modal-dialog p {
+ color: $dark-font-color;
+ }
+}
+</style>
diff --git a/src/components/Status.vue b/src/components/Status.vue
new file mode 100644
index 0000000..92ed8a6
--- /dev/null
+++ b/src/components/Status.vue
@@ -0,0 +1,63 @@
+<template>
+ <span class="badge rounded-pill" :class=" 'bg-' + color ">{{ text }}</span>
+</template>
+
+<script>
+export default {
+ props: {
+ /** Current status of monitor */
+ status: {
+ type: Number,
+ default: 0,
+ }
+ },
+
+ computed: {
+ color() {
+ if (this.status === 0) {
+ return "danger";
+ }
+
+ if (this.status === 1) {
+ return "primary";
+ }
+
+ if (this.status === 2) {
+ return "warning";
+ }
+
+ if (this.status === 3) {
+ return "maintenance";
+ }
+
+ return "secondary";
+ },
+
+ text() {
+ if (this.status === 0) {
+ return this.$t("Down");
+ }
+
+ if (this.status === 1) {
+ return this.$t("Up");
+ }
+
+ if (this.status === 2) {
+ return this.$t("Pending");
+ }
+
+ if (this.status === 3) {
+ return this.$t("statusMaintenance");
+ }
+
+ return this.$t("Unknown");
+ },
+ },
+};
+</script>
+
+<style scoped>
+ span {
+ min-width: 64px;
+ }
+</style>
diff --git a/src/components/Tag.vue b/src/components/Tag.vue
new file mode 100644
index 0000000..6c2ff8c
--- /dev/null
+++ b/src/components/Tag.vue
@@ -0,0 +1,87 @@
+<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',
+ 'mx-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>
+/**
+ * @typedef {import('./TagsManager.vue').Tag} Tag
+ */
+
+export default {
+ props: {
+ /**
+ * Object representing tag
+ * @type {Tag}
+ */
+ item: {
+ type: Object,
+ required: true,
+ },
+ /** Function to remove tag */
+ remove: {
+ type: Function,
+ default: null,
+ },
+ /**
+ * Size of tag
+ * @type {"normal" | "small"}
+ */
+ size: {
+ type: String,
+ default: "normal",
+ }
+ },
+ computed: {
+ displayText() {
+ if (this.item.value === "" || this.item.value === undefined) {
+ 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>
diff --git a/src/components/TagEditDialog.vue b/src/components/TagEditDialog.vue
new file mode 100644
index 0000000..77fce26
--- /dev/null
+++ b/src/components/TagEditDialog.vue
@@ -0,0 +1,485 @@
+<template>
+ <form @submit.prevent="submit">
+ <div ref="modal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 id="exampleModalLabel" class="modal-title">
+ {{ $t("Edit Tag") }}
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
+ </div>
+ <div class="modal-body">
+ <div class="mb-3">
+ <label for="tag-name" class="form-label">{{ $t("Name") }}</label>
+ <input
+ id="tag-name"
+ v-model="tag.name"
+ type="text"
+ class="form-control"
+ :class="{'is-invalid': nameInvalid}"
+ required
+ >
+ <div class="invalid-feedback">
+ {{ $t("Tag with this name already exist.") }}
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <label for="tag-color" class="form-label">{{ $t("color") }}</label>
+ <div class="d-flex">
+ <div class="col-8 pe-1">
+ <vue-multiselect
+ v-model="selectedColor"
+ :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 class="col-4 ps-1">
+ <input id="tag-color-hex" v-model="tag.color" type="text" class="form-control">
+ </div>
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <label for="tag-monitors" class="form-label">{{ $tc("Monitor", selectedMonitors.length) }}</label>
+ <div class="tag-monitors-list">
+ <router-link v-for="monitor in selectedMonitors" :key="monitor.id" class="d-flex align-items-center justify-content-between text-decoration-none tag-monitors-list-row py-2 px-3" :to="monitorURL(monitor.id)" @click="modal.hide()">
+ <span>{{ monitor.name }}</span>
+ <button type="button" class="btn-rm-monitor btn btn-outline-danger ms-2 py-1" @click.stop.prevent="removeMonitor(monitor.id)">
+ <font-awesome-icon class="" icon="times" />
+ </button>
+ </router-link>
+ </div>
+ <div v-if="allMonitorList.length > 0" class="pt-3">
+ <label class="form-label">{{ $t("Add a monitor") }}:</label>
+ <VueMultiselect
+ v-model="selectedAddMonitor"
+ :options="allMonitorList"
+ :multiple="false"
+ :searchable="true"
+ :placeholder="$t('Add a monitor')"
+ label="name"
+ trackBy="name"
+ class="mt-1"
+ >
+ <template #option="{ option }">
+ <div class="d-inline-flex">
+ <span>{{ option.name }} <Tag v-for="monitorTag in option.tags" :key="monitorTag" :item="monitorTag" :size="'sm'" /></span>
+ </div>
+ </template>
+ </VueMultiselect>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <button v-if="tag && tag.id !== null" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
+ {{ $t("Delete") }}
+ </button>
+ <button type="submit" class="btn btn-primary" :disabled="processing">
+ <div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
+ {{ $t("Save") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+
+ <Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteTag">
+ {{ $t("confirmDeleteTagMsg") }}
+ </Confirm>
+</template>
+
+<script>
+import { Modal } from "bootstrap";
+import Confirm from "./Confirm.vue";
+import Tag from "./Tag.vue";
+import VueMultiselect from "vue-multiselect";
+import { colorOptions } from "../util-frontend";
+import { getMonitorRelativeURL } from "../util.ts";
+
+export default {
+ components: {
+ VueMultiselect,
+ Confirm,
+ Tag,
+ },
+ props: {
+ updated: {
+ type: Function,
+ default: () => {},
+ },
+ existingTags: {
+ type: Array,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ modal: null,
+ processing: false,
+ selectedColor: {
+ name: null,
+ color: null,
+ },
+ tag: {
+ id: null,
+ name: "",
+ color: "",
+ // Do not set default value here, please scroll to show()
+ },
+ monitors: [],
+ removingMonitor: [],
+ addingMonitor: [],
+ selectedAddMonitor: null,
+ nameInvalid: false,
+ };
+ },
+
+ computed: {
+ colorOptions() {
+ if (!colorOptions(this).find(option => option.color === this.tag.color)) {
+ return colorOptions(this).concat(
+ {
+ name: "custom",
+ color: this.tag.color
+ });
+ } else {
+ return colorOptions(this);
+ }
+ },
+ selectedMonitors() {
+ return this.monitors
+ .concat(Object.values(this.$root.monitorList).filter(monitor => this.addingMonitor.includes(monitor.id)))
+ .filter(monitor => !this.removingMonitor.includes(monitor.id));
+ },
+ allMonitorList() {
+ return Object.values(this.$root.monitorList).filter(monitor => !this.selectedMonitors.includes(monitor));
+ },
+ },
+
+ watch: {
+ // Set color option to "Custom" when a unknown color is entered
+ "tag.color"(to, from) {
+ if (to !== "" && colorOptions(this).find(x => x.color === to) == null) {
+ this.selectedColor.name = this.$t("Custom");
+ this.selectedColor.color = to;
+ }
+ },
+ "tag.name"(to, from) {
+ if (to != null) {
+ this.validate();
+ }
+ },
+ selectedColor(to, from) {
+ if (to != null) {
+ this.tag.color = to.color;
+ }
+ },
+ /**
+ * Selected a monitor and add to the list.
+ * @param {object} monitor Monitor to add
+ * @returns {void}
+ */
+ selectedAddMonitor(monitor) {
+ if (monitor) {
+ if (this.removingMonitor.includes(monitor.id)) {
+ this.removingMonitor = this.removingMonitor.filter(id => id !== monitor.id);
+ } else {
+ this.addingMonitor.push(monitor.id);
+ }
+ this.selectedAddMonitor = null;
+ }
+ },
+ },
+
+ mounted() {
+ this.modal = new Modal(this.$refs.modal);
+ },
+
+ methods: {
+ /**
+ * Show confirmation for deleting a tag
+ * @returns {void}
+ */
+ deleteConfirm() {
+ this.$refs.confirmDelete.show();
+ },
+
+ /**
+ * Reset the editTag form
+ * @returns {void}
+ */
+ reset() {
+ this.selectedColor = null;
+ this.tag = {
+ id: null,
+ name: "",
+ color: "",
+ };
+ this.monitors = [];
+ this.removingMonitor = [];
+ this.addingMonitor = [];
+ },
+
+ /**
+ * Check for existing tags of the same name, set invalid input
+ * @returns {boolean} True if editing tag is valid
+ */
+ validate() {
+ this.nameInvalid = false;
+ const sameName = this.existingTags.find((existingTag) => existingTag.name === this.tag.name);
+ if (sameName != null && sameName.id !== this.tag.id) {
+ this.nameInvalid = true;
+ return false;
+ }
+ return true;
+ },
+
+ /**
+ * Load tag information for display in the edit dialog
+ * @param {object} tag tag object to edit
+ * @returns {void}
+ */
+ show(tag) {
+ if (tag) {
+ this.selectedColor = this.colorOptions.find(x => x.color === tag.color) ?? {
+ name: this.$t("Custom"),
+ color: tag.color
+ };
+ this.tag.id = tag.id;
+ this.tag.name = tag.name;
+ this.tag.color = tag.color;
+ this.monitors = this.monitorsByTag(tag.id);
+ this.removingMonitor = [];
+ this.addingMonitor = [];
+ this.selectedAddMonitor = null;
+ }
+
+ this.modal.show();
+ },
+
+ /**
+ * Submit tag and monitorTag changes to server
+ * @returns {Promise<void>}
+ */
+ async submit() {
+ this.processing = true;
+ let editResult = true;
+
+ if (!this.validate()) {
+ this.processing = false;
+ return;
+ }
+
+ if (this.tag.id == null) {
+ await this.addTagAsync(this.tag).then((res) => {
+ if (!res.ok) {
+ this.$root.toastRes(res.msg);
+ editResult = false;
+ } else {
+ this.tag.id = res.tag.id;
+ this.updated();
+ }
+ });
+ }
+
+ if (!editResult) {
+ return;
+ }
+
+ for (let addId of this.addingMonitor) {
+ await this.addMonitorTagAsync(this.tag.id, addId, "").then((res) => {
+ if (!res.ok) {
+ this.$root.toastError(res.msg);
+ editResult = false;
+ }
+ });
+ }
+
+ for (let removeId of this.removingMonitor) {
+ this.monitors.find(monitor => monitor.id === removeId)?.tags.forEach(async (monitorTag) => {
+ await this.deleteMonitorTagAsync(this.tag.id, removeId, monitorTag.value).then((res) => {
+ if (!res.ok) {
+ this.$root.toastError(res.msg);
+ editResult = false;
+ }
+ });
+ });
+ }
+
+ this.$root.getSocket().emit("editTag", this.tag, (res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+
+ if (res.ok && editResult) {
+ this.updated();
+ this.modal.hide();
+ }
+ });
+ },
+
+ /**
+ * Delete the editing tag from server
+ * @returns {Promise<void>}
+ */
+ async deleteTag() {
+ this.processing = true;
+ await this.deleteTagAsync(this.tag.id).then((res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+
+ if (res.ok) {
+ this.updated();
+ this.modal.hide();
+ }
+ });
+ },
+
+ /**
+ * Remove a monitor from the monitors list locally
+ * @param {number} id id of the tag to remove
+ * @returns {void}
+ */
+ removeMonitor(id) {
+ if (this.addingMonitor.includes(id)) {
+ this.addingMonitor = this.addingMonitor.filter(x => x !== id);
+ } else {
+ this.removingMonitor.push(id);
+ }
+ },
+
+ /**
+ * Get monitors which has a specific tag locally
+ * @param {number} tagId id of the tag to filter
+ * @returns {object[]} list of monitors which has a specific tag
+ */
+ monitorsByTag(tagId) {
+ return Object.values(this.$root.monitorList).filter((monitor) => {
+ return monitor.tags.find(monitorTag => monitorTag.tag_id === tagId);
+ });
+ },
+
+ /**
+ * Get URL of monitor
+ * @param {number} id ID of monitor
+ * @returns {string} Relative URL of monitor
+ */
+ monitorURL(id) {
+ return getMonitorRelativeURL(id);
+ },
+
+ /**
+ * Add a tag asynchronously
+ * @param {object} newTag Object representing new tag to add
+ * @returns {Promise<void>}
+ */
+ addTagAsync(newTag) {
+ return new Promise((resolve) => {
+ this.$root.getSocket().emit("addTag", newTag, resolve);
+ });
+ },
+
+ /**
+ * Delete a tag asynchronously
+ * @param {number} tagId ID of tag to delete
+ * @returns {Promise<void>}
+ */
+ deleteTagAsync(tagId) {
+ return new Promise((resolve) => {
+ this.$root.getSocket().emit("deleteTag", tagId, resolve);
+ });
+ },
+
+ /**
+ * Add a tag to a monitor asynchronously
+ * @param {number} tagId ID of tag to add
+ * @param {number} monitorId ID of monitor to add tag to
+ * @param {string} value Value of tag
+ * @returns {Promise<void>}
+ */
+ addMonitorTagAsync(tagId, monitorId, value) {
+ return new Promise((resolve) => {
+ this.$root.getSocket().emit("addMonitorTag", tagId, monitorId, value, resolve);
+ });
+ },
+ /**
+ * Delete a tag from a monitor asynchronously
+ * @param {number} tagId ID of tag to remove
+ * @param {number} monitorId ID of monitor to remove tag from
+ * @param {string} value Value of tag
+ * @returns {Promise<void>}
+ */
+ deleteMonitorTagAsync(tagId, monitorId, value) {
+ return new Promise((resolve) => {
+ this.$root.getSocket().emit("deleteMonitorTag", tagId, monitorId, value, resolve);
+ });
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.dark {
+ .modal-dialog .form-text, .modal-dialog p {
+ color: $dark-font-color;
+ }
+}
+
+.btn-rm-monitor {
+ padding-left: 11px;
+ padding-right: 11px;
+}
+
+.tag-monitors-list {
+ max-height: 40vh;
+ overflow-y: scroll;
+}
+
+.tag-monitors-list .tag-monitors-list-row {
+ cursor: pointer;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.125);
+
+ .dark & {
+ border-bottom: 1px solid $dark-border-color;
+ }
+
+ &:hover {
+ background-color: $highlight-white;
+ }
+
+ .dark &:hover {
+ background-color: $dark-bg2;
+ }
+}
+
+</style>
diff --git a/src/components/TagsManager.vue b/src/components/TagsManager.vue
new file mode 100644
index 0000000..19c8e48
--- /dev/null
+++ b/src/components/TagsManager.vue
@@ -0,0 +1,475 @@
+<template>
+ <div>
+ <h4 class="mt-5 mb-3">{{ $t("Tags") }}</h4>
+ <div v-if="selectedTags.length > 0" class="mb-2 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"
+ data-testid="add-tag-button"
+ @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')"
+ data-testid="tag-name-input"
+ @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=""
+ data-testid="tag-color-select"
+ >
+ <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)')"
+ data-testid="tag-value-input"
+ @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"
+ data-testid="tag-submit-button"
+ @click.stop="addDraftTag"
+ >
+ {{ $t("Add") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import { Modal } from "bootstrap";
+import VueMultiselect from "vue-multiselect";
+import { colorOptions } from "../util-frontend";
+import Tag from "../components/Tag.vue";
+
+/**
+ * @typedef Tag
+ * @type {object}
+ * @property {number | undefined} id ID of tag assignment
+ * @property {number | undefined} monitor_id ID of monitor tag is
+ * assigned to
+ * @property {number | undefined} tag_id ID of tag
+ * @property {string} value Value given to tag
+ * @property {string} name Name of tag
+ * @property {string} color Colour of tag
+ * @property {boolean | undefined} new Should a new tag be created?
+ */
+
+export default {
+ components: {
+ Tag,
+ VueMultiselect,
+ },
+ props: {
+ /**
+ * Array of tags to be pre-selected
+ * @type {Tag[]}
+ */
+ preSelectedTags: {
+ type: Array,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ /** @type {Modal | null} */
+ modal: null,
+ /** @type {Tag[]} */
+ existingTags: [],
+ processing: false,
+ /** @type {Tag[]} */
+ newTags: [],
+ /** @type {Tag[]} */
+ 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 colorOptions(this);
+ },
+ 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 && this.newDraftTag.select == null) {
+ // 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: {
+ /**
+ * Show the add tag dialog
+ * @returns {void}
+ */
+ showAddDialog() {
+ this.modal.show();
+ },
+ /**
+ * Get all existing tags
+ * @returns {void}
+ */
+ getExistingTags() {
+ this.$root.getSocket().emit("getTags", (res) => {
+ if (res.ok) {
+ this.existingTags = res.tags;
+ } else {
+ this.$root.toastError(res.msg);
+ }
+ });
+ },
+ /**
+ * Delete the specified tag
+ * @param {object} item Object representing tag to delete
+ * @returns {void}
+ */
+ 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);
+ }
+ },
+ /**
+ * Get colour of text inside the tag
+ * @param {object} option The tag that needs to be displayed.
+ * Defaults to "white" unless the tag has no color, which will
+ * then return the body color (based on application theme)
+ * @returns {string} Text color
+ */
+ textColor(option) {
+ if (option.color) {
+ return "white";
+ } else {
+ return this.$root.theme === "light" ? "var(--bs-body-color)" : "inherit";
+ }
+ },
+ /**
+ * Add a draft tag
+ * @returns {void}
+ */
+ 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();
+ },
+ /**
+ * Remove a draft tag
+ * @returns {void}
+ */
+ clearDraftTag() {
+ this.newDraftTag = {
+ name: null,
+ select: null,
+ color: null,
+ value: "",
+ invalid: true,
+ nameInvalid: false,
+ };
+ this.modal.hide();
+ },
+ /**
+ * Add a tag asynchronously
+ * @param {object} newTag Object representing new tag to add
+ * @returns {Promise<void>}
+ */
+ addTagAsync(newTag) {
+ return new Promise((resolve) => {
+ this.$root.getSocket().emit("addTag", newTag, resolve);
+ });
+ },
+ /**
+ * Add a tag to a monitor asynchronously
+ * @param {number} tagId ID of tag to add
+ * @param {number} monitorId ID of monitor to add tag to
+ * @param {string} value Value of tag
+ * @returns {Promise<void>}
+ */
+ addMonitorTagAsync(tagId, monitorId, value) {
+ return new Promise((resolve) => {
+ this.$root.getSocket().emit("addMonitorTag", tagId, monitorId, value, resolve);
+ });
+ },
+ /**
+ * Delete a tag from a monitor asynchronously
+ * @param {number} tagId ID of tag to remove
+ * @param {number} monitorId ID of monitor to remove tag from
+ * @param {string} value Value of tag
+ * @returns {Promise<void>}
+ */
+ deleteMonitorTagAsync(tagId, monitorId, value) {
+ return new Promise((resolve) => {
+ this.$root.getSocket().emit("deleteMonitorTag", tagId, monitorId, value, resolve);
+ });
+ },
+ /**
+ * Handle pressing Enter key when inside the modal
+ * @returns {void}
+ */
+ onEnter() {
+ if (!this.validateDraftTag.invalid) {
+ this.addDraftTag();
+ }
+ },
+ /**
+ * Submit the form data
+ * @param {number} monitorId ID of monitor this change affects
+ * @returns {Promise<void>}
+ */
+ 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) {
+ this.$root.toastError(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) {
+ this.$root.toastError(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) {
+ this.$root.toastError(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>
diff --git a/src/components/ToggleSection.vue b/src/components/ToggleSection.vue
new file mode 100644
index 0000000..a2f7c9d
--- /dev/null
+++ b/src/components/ToggleSection.vue
@@ -0,0 +1,69 @@
+<template>
+ <div class="my-3 py-3">
+ <h5 @click="isOpen = !isOpen">
+ <div
+ class="
+ w-50
+ d-flex
+ justify-content-between
+ align-items-center
+ pe-2
+ "
+ >
+ <span class="pb-2">{{ heading }}</span>
+ <font-awesome-icon
+ icon="chevron-down"
+ class="animated"
+ :class="{ open: isOpen }"
+ />
+ </div>
+ </h5>
+ <transition name="slide-fade-up">
+ <div v-if="isOpen" class="mt-3">
+ <slot></slot>
+ </div>
+ </transition>
+ </div>
+</template>
+
+<script>
+export default {
+ props: {
+ /** Heading of the section */
+ heading: {
+ type: String,
+ default: "",
+ },
+ /** Should the section be open by default? */
+ defaultOpen: {
+ type: Boolean,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ isOpen: this.defaultOpen,
+ };
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+h5::after {
+ content: "";
+ display: block;
+ width: 50%;
+ padding-top: 8px;
+ border-bottom: 1px solid $dark-border-color;
+}
+
+.open {
+ transform: rotate(180deg);
+}
+
+.animated {
+ transition: all 0.2s $easing-in;
+}
+</style>
diff --git a/src/components/TwoFADialog.vue b/src/components/TwoFADialog.vue
new file mode 100644
index 0000000..6a0bf40
--- /dev/null
+++ b/src/components/TwoFADialog.vue
@@ -0,0 +1,225 @@
+<template>
+ <form @submit.prevent="submit">
+ <div ref="modal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">
+ {{ $t("Setup 2FA") }}
+ <span v-if="twoFAStatus == true" class="badge bg-primary">{{ $t("Active") }}</span>
+ <span v-if="twoFAStatus == false" class="badge bg-primary">{{ $t("Inactive") }}</span>
+ </h5>
+ <button :disabled="processing" type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
+ </div>
+ <div class="modal-body">
+ <div class="mb-3">
+ <div v-if="uri && twoFAStatus == false" class="mx-auto text-center" style="width: 210px;">
+ <vue-qrcode :key="uri" :value="uri" type="image/png" :quality="1" :color="{ light: '#ffffffff' }" />
+ <button v-show="!showURI" type="button" class="btn btn-outline-primary btn-sm mt-2" @click="showURI = true">{{ $t("Show URI") }}</button>
+ </div>
+ <p v-if="showURI && twoFAStatus == false" class="text-break mt-2">{{ uri }}</p>
+
+ <div v-if="!(uri && twoFAStatus == false)" class="mb-3">
+ <label for="current-password" class="form-label">
+ {{ $t("Current Password") }}
+ </label>
+ <input
+ id="current-password"
+ v-model="currentPassword"
+ type="password"
+ class="form-control"
+ autocomplete="current-password"
+ required
+ />
+ </div>
+
+ <button v-if="uri == null && twoFAStatus == false" class="btn btn-primary" type="button" @click="prepare2FA()">
+ {{ $t("Enable 2FA") }}
+ </button>
+
+ <button v-if="twoFAStatus == true" class="btn btn-danger" type="button" :disabled="processing" @click="confirmDisableTwoFA()">
+ {{ $t("Disable 2FA") }}
+ </button>
+
+ <div v-if="uri && twoFAStatus == false" class="mt-3">
+ <label for="basic-url" class="form-label">{{ $t("twoFAVerifyLabel") }}</label>
+ <div class="input-group">
+ <input v-model="token" type="text" maxlength="6" class="form-control" autocomplete="one-time-code" required>
+ <button class="btn btn-outline-primary" type="button" @click="verifyToken()">{{ $t("Verify Token") }}</button>
+ </div>
+ <p v-show="tokenValid" class="mt-2" style="color: green;">{{ $t("tokenValidSettingsMsg") }}</p>
+ </div>
+ </div>
+ </div>
+
+ <div v-if="uri && twoFAStatus == false" class="modal-footer">
+ <button type="submit" class="btn btn-primary" :disabled="processing || tokenValid == false" @click="confirmEnableTwoFA()">
+ <div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
+ {{ $t("Save") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+
+ <Confirm ref="confirmEnableTwoFA" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="save2FA">
+ {{ $t("confirmEnableTwoFAMsg") }}
+ </Confirm>
+
+ <Confirm ref="confirmDisableTwoFA" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="disable2FA">
+ {{ $t("confirmDisableTwoFAMsg") }}
+ </Confirm>
+</template>
+
+<script lang="ts">
+import { Modal } from "bootstrap";
+import Confirm from "./Confirm.vue";
+import VueQrcode from "vue-qrcode";
+
+export default {
+ components: {
+ Confirm,
+ VueQrcode,
+ },
+ props: {},
+ data() {
+ return {
+ currentPassword: "",
+ processing: false,
+ uri: null,
+ tokenValid: false,
+ twoFAStatus: null,
+ token: null,
+ showURI: false,
+ };
+ },
+ mounted() {
+ this.modal = new Modal(this.$refs.modal);
+ this.getStatus();
+ },
+ methods: {
+ /**
+ * Show the dialog
+ * @returns {void}
+ */
+ show() {
+ this.modal.show();
+ },
+
+ /**
+ * Show dialog to confirm enabling 2FA
+ * @returns {void}
+ */
+ confirmEnableTwoFA() {
+ this.$refs.confirmEnableTwoFA.show();
+ },
+
+ /**
+ * Show dialog to confirm disabling 2FA
+ * @returns {void}
+ */
+ confirmDisableTwoFA() {
+ this.$refs.confirmDisableTwoFA.show();
+ },
+
+ /**
+ * Prepare 2FA configuration
+ * @returns {void}
+ */
+ prepare2FA() {
+ this.processing = true;
+
+ this.$root.getSocket().emit("prepare2FA", this.currentPassword, (res) => {
+ this.processing = false;
+
+ if (res.ok) {
+ this.uri = res.uri;
+ } else {
+ this.$root.toastError(res.msg);
+ }
+ });
+ },
+
+ /**
+ * Save the current 2FA configuration
+ * @returns {void}
+ */
+ save2FA() {
+ this.processing = true;
+
+ this.$root.getSocket().emit("save2FA", this.currentPassword, (res) => {
+ this.processing = false;
+
+ if (res.ok) {
+ this.$root.toastRes(res);
+ this.getStatus();
+ this.currentPassword = "";
+ this.modal.hide();
+ } else {
+ this.$root.toastError(res.msg);
+ }
+ });
+ },
+
+ /**
+ * Disable 2FA for this user
+ * @returns {void}
+ */
+ disable2FA() {
+ this.processing = true;
+
+ this.$root.getSocket().emit("disable2FA", this.currentPassword, (res) => {
+ this.processing = false;
+
+ if (res.ok) {
+ this.$root.toastRes(res);
+ this.getStatus();
+ this.currentPassword = "";
+ this.modal.hide();
+ } else {
+ this.$root.toastError(res.msg);
+ }
+ });
+ },
+
+ /**
+ * Verify the token generated by the user
+ * @returns {void}
+ */
+ verifyToken() {
+ this.$root.getSocket().emit("verifyToken", this.token, this.currentPassword, (res) => {
+ if (res.ok) {
+ this.tokenValid = res.valid;
+ } else {
+ this.$root.toastError(res.msg);
+ }
+ });
+ },
+
+ /**
+ * Get current status of 2FA
+ * @returns {void}
+ */
+ getStatus() {
+ this.$root.getSocket().emit("twoFAStatus", (res) => {
+ if (res.ok) {
+ this.twoFAStatus = res.status;
+ } else {
+ this.$root.toastError(res.msg);
+ }
+ });
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.dark {
+ .modal-dialog .form-text, .modal-dialog p {
+ color: $dark-font-color;
+ }
+}
+</style>
diff --git a/src/components/Uptime.vue b/src/components/Uptime.vue
new file mode 100644
index 0000000..64bbd4e
--- /dev/null
+++ b/src/components/Uptime.vue
@@ -0,0 +1,103 @@
+<template>
+ <span :class="className" :title="title">{{ uptime }}</span>
+</template>
+
+<script>
+import { DOWN, MAINTENANCE, PENDING, UP } from "../util.ts";
+
+export default {
+ props: {
+ /** Monitor this represents */
+ monitor: {
+ type: Object,
+ default: null,
+ },
+ /** Type of monitor */
+ type: {
+ type: String,
+ default: null,
+ },
+ /** Is this a pill? */
+ pill: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ computed: {
+ uptime() {
+ if (this.type === "maintenance") {
+ return this.$t("statusMaintenance");
+ }
+
+ let key = this.monitor.id + "_" + this.type;
+
+ if (this.$root.uptimeList[key] !== undefined) {
+ let result = Math.round(this.$root.uptimeList[key] * 10000) / 100;
+ // Only perform sanity check on status page. See louislam/uptime-kuma#2628
+ if (this.$route.path.startsWith("/status") && result > 100) {
+ return "100%";
+ } else {
+ return result + "%";
+ }
+ }
+
+ return this.$t("notAvailableShort");
+ },
+
+ color() {
+ if (this.lastHeartBeat.status === MAINTENANCE) {
+ return "maintenance";
+ }
+
+ if (this.lastHeartBeat.status === DOWN) {
+ return "danger";
+ }
+
+ if (this.lastHeartBeat.status === UP) {
+ return "primary";
+ }
+
+ if (this.lastHeartBeat.status === PENDING) {
+ return "warning";
+ }
+
+ return "secondary";
+ },
+
+ lastHeartBeat() {
+ if (this.monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[this.monitor.id]) {
+ return this.$root.lastHeartbeatList[this.monitor.id];
+ }
+
+ return {
+ status: -1,
+ };
+ },
+
+ className() {
+ if (this.pill) {
+ return `badge rounded-pill bg-${this.color}`;
+ }
+
+ return "";
+ },
+
+ title() {
+ if (this.type === "1y") {
+ return `1${this.$t("-year")}`;
+ }
+ if (this.type === "720") {
+ return `30${this.$t("-day")}`;
+ }
+ return `24${this.$t("-hour")}`;
+ }
+ },
+};
+</script>
+
+<style>
+.badge {
+ min-width: 62px;
+}
+</style>
diff --git a/src/components/notifications/46elks.vue b/src/components/notifications/46elks.vue
new file mode 100644
index 0000000..d29655b
--- /dev/null
+++ b/src/components/notifications/46elks.vue
@@ -0,0 +1,48 @@
+<template>
+ <div class="mb-3">
+ <label for="ElksUsername" class="form-label">{{ $t("Username") }}</label>
+ <input id="ElksUsername" v-model="$parent.notification.elksUsername" type="text" class="form-control" required>
+ <label for="ElksPassword" class="form-label">{{ $t("Password") }}</label>
+ </div>
+ <div class="form-text">
+ <HiddenInput id="ElksPassword" v-model="$parent.notification.elksAuthToken" :required="true" autocomplete="new-password"></HiddenInput>
+ <i18n-t tag="p" keypath="Can be found on:">
+ <a href="https://46elks.com/account" target="_blank">https://46elks.com/account</a>
+ </i18n-t>
+ </div>
+ <div class="mb-3">
+ <label for="Elks-from-number" class="form-label">{{ $t("From") }}</label>
+ <input id="Elks-from-number" v-model="$parent.notification.elksFromNumber" type="text" class="form-control" required>
+ <div class="form-text">
+ {{ $t("Either a text sender ID or a phone number in E.164 format if you want to be able to receive replies.") }}
+ <i18n-t tag="p" keypath="More info on:">
+ <a href="https://46elks.se/kb/text-sender-id" target="_blank">https://46elks.se/kb/text-sender-id</a>
+ </i18n-t>
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <label for="Elks-to-number" class="form-label">{{ $t("To Number") }}</label>
+ <input id="Elks-to-number" v-model="$parent.notification.elksToNumber" type="text" class="form-control" required>
+ <div class="form-text">
+ {{ $t("The phone number of the recipient in E.164 format.") }}
+ <i18n-t tag="p" keypath="More info on:">
+ <a href="https://46elks.se/kb/e164" target="_blank">https://46elks.se/kb/e164</a>
+ </i18n-t>
+ </div>
+ </div>
+
+ <i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
+ <a href="https://46elks.com/docs/send-sms" target="_blank">https://46elks.com/docs/send-sms</a>
+ </i18n-t>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/AlertNow.vue b/src/components/notifications/AlertNow.vue
new file mode 100644
index 0000000..93acc95
--- /dev/null
+++ b/src/components/notifications/AlertNow.vue
@@ -0,0 +1,13 @@
+<template>
+ <div class="mb-3">
+ <label for="alertnow-webhook-url" class="form-label">{{ $t("Webhook URL") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="alertnow-webhook-url" v-model="$parent.notification.alertNowWebhookURL" type="text" class="form-control" required>
+
+ <div class="form-text">
+ <span style="color: red;"><sup>*</sup></span>{{ $t("Required") }}
+ <i18n-t tag="p" keypath="aboutWebhooks" style="margin-top: 8px;">
+ <a href="https://service.opsnow.com/docs/alertnow/en/user-guide-alertnow-en.html#standard" target="_blank">{{ $t("here") }}</a>
+ </i18n-t>
+ </div>
+ </div>
+</template>
diff --git a/src/components/notifications/Alerta.vue b/src/components/notifications/Alerta.vue
new file mode 100644
index 0000000..962267f
--- /dev/null
+++ b/src/components/notifications/Alerta.vue
@@ -0,0 +1,14 @@
+<template>
+ <div class="mb-3">
+ <label for="alerta-api-endpoint" class="form-label">{{ $t("alertaApiEndpoint") }}</label>
+ <input id="alerta-api-endpoint" v-model="$parent.notification.alertaApiEndpoint" type="text" class="form-control" required>
+ <label for="alerta-environment" class="form-label">{{ $t("alertaEnvironment") }}</label>
+ <input id="alerta-environment" v-model="$parent.notification.alertaEnvironment" type="text" class="form-control" required>
+ <label for="alerta-api-key" class="form-label">{{ $t("alertaApiKey") }}</label>
+ <input id="alerta-api-key" v-model="$parent.notification.alertaApiKey" type="text" class="form-control" required>
+ <label for="alerta-alert-state" class="form-label">{{ $t("alertaAlertState") }}</label>
+ <input id="alerta-alert-state" v-model="$parent.notification.alertaAlertState" type="text" class="form-control" placeholder="critical" required>
+ <label for="alerta-recover-state" class="form-label">{{ $t("alertaRecoverState") }}</label>
+ <input id="alerta-recover-state" v-model="$parent.notification.alertaRecoverState" type="text" class="form-control" placeholder="cleared" required>
+ </div>
+</template>
diff --git a/src/components/notifications/AliyunSms.vue b/src/components/notifications/AliyunSms.vue
new file mode 100644
index 0000000..422b700
--- /dev/null
+++ b/src/components/notifications/AliyunSms.vue
@@ -0,0 +1,25 @@
+<template>
+ <div class="mb-3">
+ <label for="accessKeyId" class="form-label">{{ $t("AccessKeyId") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="accessKeyId" v-model="$parent.notification.accessKeyId" type="text" class="form-control" required>
+
+ <label for="secretAccessKey" class="form-label">{{ $t("SecretAccessKey") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="secretAccessKey" v-model="$parent.notification.secretAccessKey" type="text" class="form-control" required>
+
+ <label for="phonenumber" class="form-label">{{ $t("PhoneNumbers") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="phonenumber" v-model="$parent.notification.phonenumber" type="text" class="form-control" required>
+
+ <label for="templateCode" class="form-label">{{ $t("TemplateCode") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="templateCode" v-model="$parent.notification.templateCode" type="text" class="form-control" required>
+
+ <label for="signName" class="form-label">{{ $t("SignName") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="signName" v-model="$parent.notification.signName" type="text" class="form-control" required>
+
+ <div class="form-text">
+ <p>{{ $t("Sms template must contain parameters: ") }}<br> <code>${name} ${time} ${status} ${msg}</code></p>
+ <i18n-t tag="p" keypath="Read more:">
+ <a href="https://help.aliyun.com/document_detail/101414.html" target="_blank">https://help.aliyun.com/document_detail/101414.html</a>
+ </i18n-t>
+ </div>
+ </div>
+</template>
diff --git a/src/components/notifications/Apprise.vue b/src/components/notifications/Apprise.vue
new file mode 100644
index 0000000..7432554
--- /dev/null
+++ b/src/components/notifications/Apprise.vue
@@ -0,0 +1,38 @@
+<template>
+ <div class="mb-3">
+ <label for="apprise-url" class="form-label">{{ $t("Apprise URL") }}</label>
+ <input id="apprise-url" v-model="$parent.notification.appriseURL" type="text" class="form-control" required>
+ <div class="form-text">
+ <p>{{ $t("Example:", ["twilio://AccountSid:AuthToken@FromPhoneNo"]) }}</p>
+ <i18n-t tag="p" keypath="Read more:">
+ <a href="https://github.com/caronc/apprise/wiki#notification-services" target="_blank">https://github.com/caronc/apprise/wiki#notification-services</a>
+ </i18n-t>
+ </div>
+
+ <label for="title" class="form-label">{{ $t("Title") }}</label>
+ <input id="title" v-model="$parent.notification.title" type="text" class="form-control">
+ </div>
+ <div class="mb-3">
+ <i18n-t tag="p" keypath="Status:">
+ <span v-if="appriseInstalled" class="text-primary">{{ $t("appriseInstalled") }}</span>
+ <i18n-t v-else tag="span" keypath="appriseNotInstalled" class="text-danger">
+ <a href="https://github.com/caronc/apprise" target="_blank">{{ $t("Read more") }}</a>
+ </i18n-t>
+ </i18n-t>
+ </div>
+</template>
+
+<script>
+export default {
+ data() {
+ return {
+ appriseInstalled: false
+ };
+ },
+ mounted() {
+ this.$root.getSocket().emit("checkApprise", (installed) => {
+ this.appriseInstalled = installed;
+ });
+ },
+};
+</script>
diff --git a/src/components/notifications/Bark.vue b/src/components/notifications/Bark.vue
new file mode 100644
index 0000000..b38a7dc
--- /dev/null
+++ b/src/components/notifications/Bark.vue
@@ -0,0 +1,60 @@
+<template>
+ <div class="mb-3">
+ <label for="Bark API Version" class="form-label">{{ $t("Bark API Version") }}</label>
+ <select id="Bark API Version" v-model="$parent.notification.apiVersion" class="form-select" required>
+ <option value="v1">v1</option>
+ <option value="v2">v2</option>
+ </select>
+ </div>
+ <div class="mb-3">
+ <label for="Bark Endpoint" class="form-label">{{ $t("Bark Endpoint") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="Bark Endpoint" v-model="$parent.notification.barkEndpoint" type="text" class="form-control" required>
+ <i18n-t tag="div" keypath="wayToGetTeamsURL" class="form-text">
+ <a
+ href="https://github.com/Finb/Bark"
+ target="_blank"
+ >{{ $t("here") }}</a>
+ </i18n-t>
+ </div>
+ <div class="mb-3">
+ <label for="Bark Group" class="form-label">{{ $t("Bark Group") }}</label>
+ <input id="Bark Group" v-model="$parent.notification.barkGroup" type="text" class="form-control" required>
+ </div>
+ <div class="mb-3">
+ <label for="Bark Sound" class="form-label">{{ $t("Bark Sound") }}</label>
+ <select id="Bark Sound" v-model="$parent.notification.barkSound" class="form-select" required>
+ <option value="alarm">alarm</option>
+ <option value="anticipate">anticipate</option>
+ <option value="bell">bell</option>
+ <option value="birdsong">birdsong</option>
+ <option value="bloom">bloom</option>
+ <option value="calypso">calypso</option>
+ <option value="chime">chime</option>
+ <option value="choo">choo</option>
+ <option value="descent">descent</option>
+ <option value="electronic">electronic</option>
+ <option value="fanfare">fanfare</option>
+ <option value="glass">glass</option>
+ <option value="gotosleep">gotosleep</option>
+ <option value="healthnotification">healthnotification</option>
+ <option value="horn">horn</option>
+ <option value="ladder">ladder</option>
+ <option value="mailsent">mailsent</option>
+ <option value="minuet">minuet</option>
+ <option value="multiwayinvitation">multiwayinvitation</option>
+ <option value="newmail">newmail</option>
+ <option value="newsflash">newsflash</option>
+ <option value="noir">noir</option>
+ <option value="paymentsuccess">paymentsuccess</option>
+ <option value="shake">shake</option>
+ <option value="sherwoodforest">sherwoodforest</option>
+ <option value="silence">silence</option>
+ <option value="spell">spell</option>
+ <option value="suspense">suspense</option>
+ <option value="telegraph">telegraph</option>
+ <option value="tiptoes">tiptoes</option>
+ <option value="typewriters">typewriters</option>
+ <option value="update">update</option>
+ </select>
+ </div>
+</template>
diff --git a/src/components/notifications/Bitrix24.vue b/src/components/notifications/Bitrix24.vue
new file mode 100644
index 0000000..ac3dc31
--- /dev/null
+++ b/src/components/notifications/Bitrix24.vue
@@ -0,0 +1,24 @@
+<template>
+ <div class="mb-3">
+ <label for="bitrix24-webhook-url" class="form-label">{{ $t("Bitrix24 Webhook URL") }}</label>
+ <HiddenInput id="bitrix24-webhook-url" v-model="$parent.notification.bitrix24WebhookURL" :required="true" autocomplete="new-password"></HiddenInput>
+ <i18n-t tag="div" keypath="wayToGetBitrix24Webhook" class="form-text">
+ <a href="https://helpdesk.bitrix24.com/open/12357038/" target="_blank">https://helpdesk.bitrix24.com/open/12357038/</a>
+ </i18n-t>
+ </div>
+
+ <div class="mb-3">
+ <label for="bitrix24-user-id" class="form-label">{{ $t("User ID") }}</label>
+ <input id="bitrix24-user-id" v-model="$parent.notification.bitrix24UserID" type="text" class="form-control" required>
+ <div class="form-text">{{ $t("bitrix24SupportUserID") }}</div>
+ </div>
+</template>
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ }
+};
+</script>
diff --git a/src/components/notifications/CallMeBot.vue b/src/components/notifications/CallMeBot.vue
new file mode 100644
index 0000000..74c69ea
--- /dev/null
+++ b/src/components/notifications/CallMeBot.vue
@@ -0,0 +1,13 @@
+<template>
+ <div class="mb-3">
+ <label for="callmebot-endpoint" class="form-label">{{ $t("Endpoint") }}</label>
+ <input id="callmebot-endpoint" v-model="$parent.notification.callMeBotEndpoint" type="text" class="form-control" required>
+ <i18n-t tag="div" keypath="callMeBotGet" class="form-text">
+ <a href="https://www.callmebot.com/blog/free-api-facebook-messenger/" target="_blank">Facebook Messenger</a>
+ <a href="https://www.callmebot.com/blog/test-whatsapp-api/" target="_blank">WhatsApp</a>
+ <a href="https://www.callmebot.com/blog/telegram-phone-call-using-your-browser/" target="_blank">Telegram Call</a>
+ 1 message / 10 sec; 1 call / 65 sec
+ <!--There is no public documentation available. This data is based on testing!-->
+ </i18n-t>
+ </div>
+</template>
diff --git a/src/components/notifications/Cellsynt.vue b/src/components/notifications/Cellsynt.vue
new file mode 100644
index 0000000..2e8a6de
--- /dev/null
+++ b/src/components/notifications/Cellsynt.vue
@@ -0,0 +1,54 @@
+<template>
+ <div class="mb-3">
+ <label for="cellsynt-login" class="form-label">{{ $t("Username") }}</label>
+ <input id="cellsynt-login" v-model="$parent.notification.cellsyntLogin" type="text" class="form-control" required>
+ </div>
+ <div class="mb-3">
+ <label for="cellsynt-key" class="form-label">{{ $t("Password") }}</label>
+ <HiddenInput id="cellsynt-key" v-model="$parent.notification.cellsyntPassword" :required="true" autocomplete="new-password"></HiddenInput>
+ </div>
+ <div class="mb-3">
+ <label for="cellsynt-Originatortype" class="form-label">{{ $t("Originator type") }}</label>
+ <select id="cellsynt-Originatortype" v-model="$parent.notification.cellsyntOriginatortype" :required="true" class="form-select">
+ <option value="alpha">{{ $t("Alphanumeric (recommended)") }}</option>
+ <option value="numeric">{{ $t("Telephone number") }}</option>
+ </select>
+ <div class="form-text">
+ <p><b>{{ $t("Alphanumeric (recommended)") }}:</b><br /> {{ $t("Alphanumeric string (max 11 alphanumeric characters). Recipients can not reply to the message.") }}</p>
+ <p><b>{{ $t("Telephone number") }}:</b><br /> {{ $t("Numeric value (max 15 digits) with telephone number on international format without leading 00 (example UK number 07920 110 000 should be set as 447920110000). Recipients can reply to the message.") }}</p>
+ </div>
+ </div>
+ <div class="mb-3">
+ <label for="cellsynt-originator" class="form-label">{{ $t("Originator") }} <small>({{ $parent.notification.cellsyntOriginatortype === 'alpha' ? $t("max 11 alphanumeric characters") : $t("max 15 digits") }})</small></label>
+ <input v-if="$parent.notification.cellsyntOriginatortype === 'alpha'" id="cellsynt-originator" v-model="$parent.notification.cellsyntOriginator" type="text" class="form-control" pattern="[a-zA-Z0-9\s]+" maxlength="11" required>
+ <input v-else id="cellsynt-originator" v-model="$parent.notification.cellsyntOriginator" type="number" class="form-control" pattern="[0-9]+" maxlength="15" required>
+ <div class="form-text"><p>{{ $t("Visible on recipient's mobile phone as originator of the message. Allowed values and function depends on parameter originatortype.") }}</p></div>
+ </div>
+ <div class="mb-3">
+ <label for="cellsynt-destination" class="form-label">{{ $t("Destination") }}</label>
+ <input id="cellsynt-destination" v-model="$parent.notification.cellsyntDestination" type="text" class="form-control" required>
+ <div class="form-text"><p>{{ $t("Recipient's telephone number using international format with leading 00 followed by country code, e.g. 00447920110000 for the UK number 07920 110 000 (max 17 digits in total). Max 25000 comma separated recipients per HTTP request.") }}</p></div>
+ </div>
+ <div class="form-check form-switch">
+ <input id="cellsynt-allow-long" v-model="$parent.notification.cellsyntAllowLongSMS" type="checkbox" class="form-check-input">
+ <label for="cellsynt-allow-long" class="form-label">{{ $t("Allow Long SMS") }}</label>
+ <div class="form-text">{{ $t("Split long messages into up to 6 parts. 153 x 6 = 918 characters.") }}</div>
+ </div>
+ <i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
+ <a href="https://www.cellsynt.com/en/" target="_blank">https://www.cellsynt.com/en/</a>
+ </i18n-t>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput
+ },
+ mounted() {
+ this.$parent.notification.cellsyntOriginatortype ||= "alpha";
+ this.$parent.notification.cellsyntOriginator ||= "uptimekuma";
+ }
+};
+</script>
diff --git a/src/components/notifications/ClickSendSMS.vue b/src/components/notifications/ClickSendSMS.vue
new file mode 100644
index 0000000..dbaca04
--- /dev/null
+++ b/src/components/notifications/ClickSendSMS.vue
@@ -0,0 +1,37 @@
+<template>
+ <div class="mb-3">
+ <label for="clicksendsms-login" class="form-label">{{ $t("API Username") }}</label>
+ <i18n-t tag="div" class="form-text" keypath="wayToGetClickSendSMSToken">
+ <a href="http://dashboard.clicksend.com/account/subaccounts" target="_blank">{{ $t("here") }}</a>
+ </i18n-t>
+ <input id="clicksendsms-login" v-model="$parent.notification.clicksendsmsLogin" type="text" class="form-control" required>
+ <label for="clicksendsms-key" class="form-label">{{ $t("API Key") }}</label>
+ <HiddenInput id="clicksendsms-key" v-model="$parent.notification.clicksendsmsPassword" :required="true" autocomplete="new-password"></HiddenInput>
+ </div>
+ <div class="mb-3">
+ <div class="form-text">
+ {{ $t("checkPrice", [$t("clicksendsms")]) }}
+ <a href="https://www.clicksend.com/us/pricing" target="_blank">https://clicksend.com/us/pricing</a>
+ </div>
+ </div>
+ <div class="mb-3">
+ <label for="clicksendsms-to-number" class="form-label">{{ $t("Recipient Number") }}</label>
+ <input id="clicksendsms-to-number" v-model="$parent.notification.clicksendsmsToNumber" type="text" minlength="8" maxlength="14" class="form-control" required>
+ </div>
+ <div class="mb-3">
+ <label for="clicksendsms-sender-name" class="form-label">{{ $t("From Name/Number") }} -
+ <a href="https://help.clicksend.com/article/4kgj7krx00-what-is-a-sender-id-or-sender-number" target="_blank">{{ $t("Read more") }}</a>
+ </label>
+ <input id="clicksendsms-sender-name" v-model="$parent.notification.clicksendsmsSenderName" type="text" minlength="3" maxlength="11" class="form-control">
+ <div class="form-text">{{ $t("Leave blank to use a shared sender number.") }}</div>
+ </div>
+</template>
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/DingDing.vue b/src/components/notifications/DingDing.vue
new file mode 100644
index 0000000..710677f
--- /dev/null
+++ b/src/components/notifications/DingDing.vue
@@ -0,0 +1,37 @@
+<template>
+ <div class="mb-3">
+ <label for="WebHookUrl" class="form-label">{{ $t("WebHookUrl") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="WebHookUrl" v-model="$parent.notification.webHookUrl" type="text" class="form-control" required>
+ </div>
+ <div class="mb-3">
+ <label for="secretKey" class="form-label">{{ $t("SecretKey") }}<span style="color: red;"><sup>*</sup></span></label>
+ <HiddenInput id="secretKey" v-model="$parent.notification.secretKey" :required="true" autocomplete="new-password"></HiddenInput>
+
+ <div class="form-text">
+ <p>{{ $t("For safety, must use secret key") }}</p>
+ <i18n-t tag="p" keypath="Read more:">
+ <a href="https://developers.dingtalk.com/document/robots/custom-robot-access" target="_blank">https://developers.dingtalk.com/document/robots/custom-robot-access</a> <a href="https://open.dingtalk.com/document/robots/customize-robot-security-settings#title-7fs-kgs-36x" target="_blank">https://open.dingtalk.com/document/robots/customize-robot-security-settings#title-7fs-kgs-36x</a>
+ </i18n-t>
+ </div>
+ </div>
+ <div class="mb-3">
+ <label for="mentioning" class="form-label">{{ $t("Mentioning") }}<span style="color: red;"><sup>*</sup></span></label>
+ <select id="mentioning" v-model="$parent.notification.mentioning" class="form-select" required>
+ <option value="nobody">{{ $t("Don't mention people") }}</option>
+ <option value="everyone">{{ $t("Mention group", { group: "@everyone" }) }}</option>
+ </select>
+ </div>
+</template>
+
+<script lang="ts">
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: { HiddenInput },
+ mounted() {
+ if (typeof this.$parent.notification.mentioning === "undefined") {
+ this.$parent.notification.mentioning = "nobody";
+ }
+ }
+};
+</script>
diff --git a/src/components/notifications/Discord.vue b/src/components/notifications/Discord.vue
new file mode 100644
index 0000000..5d8334f
--- /dev/null
+++ b/src/components/notifications/Discord.vue
@@ -0,0 +1,65 @@
+<template>
+ <div class="mb-3">
+ <label for="discord-webhook-url" class="form-label">{{ $t("Discord Webhook URL") }}</label>
+ <input id="discord-webhook-url" v-model="$parent.notification.discordWebhookUrl" type="text" class="form-control" required autocomplete="false">
+ <div class="form-text">
+ {{ $t("wayToGetDiscordURL") }}
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <label for="discord-username" class="form-label">{{ $t("Bot Display Name") }}</label>
+ <input id="discord-username" v-model="$parent.notification.discordUsername" type="text" class="form-control" autocomplete="false" :placeholder="$root.appName">
+ </div>
+
+ <div class="mb-3">
+ <label for="discord-prefix-message" class="form-label">{{ $t("Prefix Custom Message") }}</label>
+ <input id="discord-prefix-message" v-model="$parent.notification.discordPrefixMessage" type="text" class="form-control" autocomplete="false" :placeholder="$t('Hello @everyone is...')">
+ </div>
+
+ <div class="mb-3">
+ <label for="discord-message-type" class="form-label">{{ $t("Select message type") }}</label>
+ <select id="discord-message-type" v-model="$parent.notification.discordChannelType" class="form-select">
+ <option value="channel">{{ $t("Send to channel") }}</option>
+ <option value="createNewForumPost">{{ $t("Create new forum post") }}</option>
+ <option value="postToThread">{{ $t("postToExistingThread") }}</option>
+ </select>
+ </div>
+
+ <div v-if="$parent.notification.discordChannelType === 'createNewForumPost'">
+ <div class="mb-3">
+ <label for="discord-target" class="form-label">
+ {{ $t("forumPostName") }}
+ </label>
+ <input id="discord-target" v-model="$parent.notification.postName" type="text" class="form-control" autocomplete="false">
+ <div class="form-text">
+ {{ $t("whatHappensAtForumPost", { option: $t("postToExistingThread") }) }}
+ </div>
+ </div>
+ </div>
+ <div v-if="$parent.notification.discordChannelType === 'postToThread'">
+ <div class="mb-3">
+ <label for="discord-target" class="form-label">
+ {{ $t("threadForumPostID") }}
+ </label>
+ <input id="discord-target" v-model="$parent.notification.threadId" type="text" class="form-control" autocomplete="false" :placeholder="$t('e.g. {discordThreadID}', { discordThreadID: 1177566663751782411 })">
+ <div class="form-text">
+ <i18n-t keypath="wayToGetDiscordThreadId">
+ <a
+ href="https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-"
+ target="_blank"
+ >{{ $t("here") }}</a>
+ </i18n-t>
+ </div>
+ </div>
+ </div>
+</template>
+<script>
+export default {
+ mounted() {
+ if (!this.$parent.notification.discordChannelType) {
+ this.$parent.notification.discordChannelType = "channel";
+ }
+ }
+};
+</script>
diff --git a/src/components/notifications/Feishu.vue b/src/components/notifications/Feishu.vue
new file mode 100644
index 0000000..6e00a31
--- /dev/null
+++ b/src/components/notifications/Feishu.vue
@@ -0,0 +1,15 @@
+<template>
+ <div class="mb-3">
+ <label for="Feishu-WebHookUrl" class="form-label">{{ $t("Feishu WebHookUrl") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="Feishu-WebHookUrl" v-model="$parent.notification.feishuWebHookUrl" type="text" class="form-control" required>
+ <div class="form-text">
+ <p><span style="color: red;"><sup>*</sup></span>{{ $t("Required") }}</p>
+ </div>
+ <i18n-t tag="div" keypath="wayToGetTeamsURL" class="form-text">
+ <a
+ href="https://www.feishu.cn/hc/zh-CN/articles/360024984973"
+ target="_blank"
+ >{{ $t("here") }}</a>
+ </i18n-t>
+ </div>
+</template>
diff --git a/src/components/notifications/FlashDuty.vue b/src/components/notifications/FlashDuty.vue
new file mode 100644
index 0000000..a66ada0
--- /dev/null
+++ b/src/components/notifications/FlashDuty.vue
@@ -0,0 +1,29 @@
+<template>
+ <div class="mb-3">
+ <label for="flashduty-integration-url" class="form-label">Integration Key</label>
+ <HiddenInput id="flashduty-integration-url" v-model="$parent.notification.flashdutyIntegrationKey" autocomplete="false"></HiddenInput>
+ <i18n-t tag="div" keypath="wayToGetFlashDutyKey" class="form-text">
+ <a href="https://flashcat.cloud/product/flashduty?from=kuma" target="_blank">{{ $t("here") }}</a>
+ </i18n-t>
+ </div>
+ <div class="mb-3">
+ <label for="flashduty-severity" class="form-label">{{ $t("FlashDuty Severity") }}</label>
+ <select id="flashduty-severity" v-model="$parent.notification.flashdutySeverity" class="form-select" :required="true">
+ <option value="Info" selected>Info</option>
+ <option value="Warning" selected>Warning</option>
+ <option value="Critical">Critical</option>
+ </select>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+ mounted() {
+ }
+};
+</script>
diff --git a/src/components/notifications/FreeMobile.vue b/src/components/notifications/FreeMobile.vue
new file mode 100644
index 0000000..852d9ae
--- /dev/null
+++ b/src/components/notifications/FreeMobile.vue
@@ -0,0 +1,12 @@
+<template>
+ <div class="mb-3">
+ <label for="freemobileUser" class="form-label">{{ $t("Free Mobile User Identifier") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="freemobileUser" v-model="$parent.notification.freemobileUser" type="text" class="form-control" required>
+ </div>
+
+ <div class="mb-3">
+ <label for="freemobilePass" class="form-label">{{ $t("Free Mobile API Key") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="freemobilePass" v-model="$parent.notification.freemobilePass" type="text" class="form-control" required>
+ </div>
+</template>
+
diff --git a/src/components/notifications/GoAlert.vue b/src/components/notifications/GoAlert.vue
new file mode 100644
index 0000000..185aaf2
--- /dev/null
+++ b/src/components/notifications/GoAlert.vue
@@ -0,0 +1,28 @@
+<template>
+ <div class="mb-3">
+ <label for="goalert-base-url" class="form-label">{{ $t("Base URL") }}</label>
+ <input id="goalert-base-url" v-model="$parent.notification.goAlertBaseURL" type="text" class="form-control" required>
+ <i18n-t tag="div" keypath="goAlertInfo" class="form-text">
+ <a href="https://goalert.me" target="_blank">https://goalert.me</a>
+ </i18n-t>
+ </div>
+
+ <div class="mb-3">
+ <label for="goalert-token" class="form-label">{{ $t("Token") }}</label>
+ <HiddenInput id="goalert-token" v-model="$parent.notification.goAlertToken" autocomplete="new-password" :required="true"></HiddenInput>
+
+ <div class="form-text">
+ {{ $t("goAlertIntegrationKeyInfo") }}
+ </div>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/GoogleChat.vue b/src/components/notifications/GoogleChat.vue
new file mode 100644
index 0000000..c19cae0
--- /dev/null
+++ b/src/components/notifications/GoogleChat.vue
@@ -0,0 +1,13 @@
+<template>
+ <div class="mb-3">
+ <label for="google-chat-webhook-url" class="form-label">{{ $t("Webhook URL") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="google-chat-webhook-url" v-model="$parent.notification.googleChatWebhookURL" type="text" class="form-control" required>
+
+ <div class="form-text">
+ <span style="color: red;"><sup>*</sup></span>{{ $t("Required") }}
+ <i18n-t tag="p" keypath="aboutWebhooks" style="margin-top: 8px;">
+ <a href="https://developers.google.com/chat/how-tos/webhooks" target="_blank">https://developers.google.com/chat/how-tos/webhooks</a>
+ </i18n-t>
+ </div>
+ </div>
+</template>
diff --git a/src/components/notifications/Gorush.vue b/src/components/notifications/Gorush.vue
new file mode 100644
index 0000000..1507bcd
--- /dev/null
+++ b/src/components/notifications/Gorush.vue
@@ -0,0 +1,47 @@
+<template>
+ <div class="mb-3">
+ <label for="gorush-device-token" class="form-label">{{ $t("Device Token") }}</label><span style="color: red;"><sup>*</sup></span>
+ <input id="gorush-device-token" v-model="$parent.notification.gorushDeviceToken" type="text" class="form-control" required>
+ </div>
+
+ <div class="mb-3">
+ <label for="gorush-server-url" class="form-label">{{ $t("Server URL") }}</label><span style="color: red;"><sup>*</sup></span>
+ <input id="gorush-server-url" v-model="$parent.notification.gorushServerURL" type="text" class="form-control" required>
+ </div>
+
+ <div class="mb-3">
+ <label for="gorush-platform" class="form-label">{{ $t("Platform") }}</label><span style="color: red;"><sup>*</sup></span>
+ <select id="gorush-platform" v-model="$parent.notification.gorushPlatform" class="form-select">
+ <option value="ios">iOS</option>
+ <option value="android">Android</option>
+ <option value="huawei">{{ $t("Huawei") }}</option>
+ </select>
+ </div>
+
+ <div class="mb-3">
+ <label for="gorush-title" class="form-label">{{ $t("Title") }}</label>
+ <input id="gorush-title" v-model="$parent.notification.gorushTitle" type="text" class="form-control">
+ </div>
+
+ <div class="mb-3">
+ <label for="gorush-priority" class="form-label">{{ $t("Priority") }}</label>
+ <select id="gorush-priority" v-model="$parent.notification.gorushPriority" class="form-select">
+ <option value="normal">{{ $t("Normal") }}</option>
+ <option value="high">{{ $t("High") }}</option>
+ </select>
+ </div>
+
+ <div class="mb-3">
+ <label for="gorush-retry" class="form-label">{{ $t("Retry") }}</label>
+ <input id="gorush-retry" v-model="$parent.notification.gorushRetry" type="number" class="form-control">
+ </div>
+
+ <div class="mb-3">
+ <label for="gorush-topic" class="form-label">{{ $t("Topic") }}</label>
+ <input id="gorush-topic" v-model="$parent.notification.gorushTopic" type="text" class="form-control">
+ </div>
+
+ <div class="form-text">
+ <span style="color: red;"><sup>*</sup></span>{{ $t("Required") }}
+ </div>
+</template>
diff --git a/src/components/notifications/Gotify.vue b/src/components/notifications/Gotify.vue
new file mode 100644
index 0000000..d992e96
--- /dev/null
+++ b/src/components/notifications/Gotify.vue
@@ -0,0 +1,30 @@
+<template>
+ <div class="mb-3">
+ <label for="gotify-application-token" class="form-label">{{ $t("Application Token") }}</label>
+ <HiddenInput id="gotify-application-token" v-model="$parent.notification.gotifyapplicationToken" :required="true" autocomplete="new-password"></HiddenInput>
+ </div>
+ <div class="mb-3">
+ <label for="gotify-server-url" class="form-label">{{ $t("Server URL") }}</label>
+ <input id="gotify-server-url" v-model="$parent.notification.gotifyserverurl" type="text" class="form-control" required>
+ </div>
+
+ <div class="mb-3">
+ <label for="gotify-priority" class="form-label">{{ $t("Priority") }}</label>
+ <input id="gotify-priority" v-model="$parent.notification.gotifyPriority" type="number" class="form-control" required min="0" max="10" step="1">
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+ mounted() {
+ if (typeof this.$parent.notification.gotifyPriority === "undefined") {
+ this.$parent.notification.gotifyPriority = 8;
+ }
+ },
+};
+</script>
diff --git a/src/components/notifications/GrafanaOncall.vue b/src/components/notifications/GrafanaOncall.vue
new file mode 100644
index 0000000..a8d3c2b
--- /dev/null
+++ b/src/components/notifications/GrafanaOncall.vue
@@ -0,0 +1,7 @@
+<template>
+ <div class="mb-3">
+ <label for="GrafanaOncallURL" class="form-label">{{ $t("GrafanaOncallURL") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="GrafanaOncallURL" v-model="$parent.notification.GrafanaOncallURL" type="text" class="form-control" required>
+ </div>
+</template>
+
diff --git a/src/components/notifications/GtxMessaging.vue b/src/components/notifications/GtxMessaging.vue
new file mode 100644
index 0000000..24118fe
--- /dev/null
+++ b/src/components/notifications/GtxMessaging.vue
@@ -0,0 +1,49 @@
+<template>
+ <div class="mb-3">
+ <label for="gtxmessaging-api-key" class="form-label">{{ $t("API Key") }}</label>
+ <HiddenInput id="gtxmessaging-api-key" v-model="$parent.notification.gtxMessagingApiKey" :required="true"></HiddenInput>
+ <div class="form-text">
+ {{ $t("gtxMessagingApiKeyHint") }}
+ </div>
+ </div>
+ <div class="mb-3">
+ <label for="gtxmessaging-from" class="form-label">{{ $t("From Phone Number / Transmission Path Originating Address (TPOA)") }}</label>
+ <input id="gtxmessaging-from" v-model="$parent.notification.gtxMessagingFrom" type="text" class="form-control" required>
+ <i18n-t tag="div" keypath="gtxMessagingFromHint" class="form-text">
+ <template #e164>
+ <a href="https://wikipedia.org/wiki/E.164">E.164</a>
+ </template>
+ <template #e212>
+ <a href="https://wikipedia.org/wiki/E.212">E.212</a>
+ </template>
+ <template #e214>
+ <a href="https://wikipedia.org/wiki/E.214">E.214</a>
+ </template>
+ </i18n-t>
+ </div>
+ <div class="mb-3">
+ <label for="gtxmessaging-to" class="form-label">{{ $t("To Phone Number") }}</label>
+ <input id="gtxmessaging-to" v-model="$parent.notification.gtxMessagingTo" type="text" pattern="^\+\d+$" class="form-control" required>
+ <i18n-t tag="div" keypath="gtxMessagingToHint" class="form-text">
+ <template #e164>
+ <a href="https://wikipedia.org/wiki/E.164">E.164</a>
+ </template>
+ <template #e212>
+ <a href="https://wikipedia.org/wiki/E.212">E.212</a>
+ </template>
+ <template #e214>
+ <a href="https://wikipedia.org/wiki/E.214">E.214</a>
+ </template>
+ </i18n-t>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput
+ }
+};
+</script>
diff --git a/src/components/notifications/HeiiOnCall.vue b/src/components/notifications/HeiiOnCall.vue
new file mode 100644
index 0000000..a61c1c3
--- /dev/null
+++ b/src/components/notifications/HeiiOnCall.vue
@@ -0,0 +1,34 @@
+<template>
+ <div class="mb-3">
+ <label for="heiioncall-apikey" class="form-label">{{ $t("API Key") }}<span
+ style="color: red;"
+ ><sup>*</sup></span></label>
+ <HiddenInput
+ id="heiioncall-apikey" v-model="$parent.notification.heiiOnCallApiKey" required="true"
+ autocomplete="false"
+ ></HiddenInput>
+ </div>
+ <div class="mb-3">
+ <label for="heiioncall-trigger-id" class="form-label">Trigger ID<span
+ style="color: red;"
+ ><sup>*</sup></span></label>
+ <HiddenInput
+ id="heiioncall-trigger-id" v-model="$parent.notification.heiiOnCallTriggerId" required="true"
+ autocomplete="false"
+ ></HiddenInput>
+ </div>
+ <i18n-t tag="p" keypath="wayToGetHeiiOnCallDetails" class="form-text mt-3">
+ <template #documentation>
+ <a href="https://heiioncall.com/docs" target="_blank">{{ $t("documentationOf", ["Heii On-Call"]) }}</a>
+ </template>
+ </i18n-t>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/HomeAssistant.vue b/src/components/notifications/HomeAssistant.vue
new file mode 100644
index 0000000..de36809
--- /dev/null
+++ b/src/components/notifications/HomeAssistant.vue
@@ -0,0 +1,40 @@
+<template>
+ <div class="mb-3">
+ <label for="homeAssistantUrl" class="form-label">{{ $t("Home Assistant URL") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="homeAssistantUrl" v-model="$parent.notification.homeAssistantUrl" type="url" class="form-control" required>
+ </div>
+
+ <div class="mb-3">
+ <label for="longLivedAccessToken" class="form-label">{{ $t("Long-Lived Access Token") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="longLivedAccessToken" v-model="$parent.notification.longLivedAccessToken" type="text" class="form-control" required>
+
+ <div class="form-text">
+ <p>{{ $t("Long-Lived Access Token can be created by clicking on your profile name (bottom left) and scrolling to the bottom then click Create Token. ") }}</p>
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <label for="notificationService" class="form-label">{{ $t("Notification Service") }}</label>
+ <input id="notificationService" v-model="$parent.notification.notificationService" type="text" :placeholder="$t('default: notify all devices')" class="form-control">
+
+ <div class="form-text">
+ <p>{{ $t('A list of Notification Services can be found in Home Assistant under "Developer Tools > Services" search for "notification" to find your device/phone name.') }}</p>
+ <p>{{ $t("Automations can optionally be triggered in Home Assistant:") }}</p>
+ <p>
+ {{ $t("Trigger type:") }} <code>Event</code><br />
+ {{ $t("Event type:") }} <code>call_service</code><br />
+ {{ $t("Event data:") }}
+ </p>
+ <pre>domain: notify
+service: mobile_app_my_phone # change to your device name
+service_data:
+ title: Uptime Kuma
+ data:
+ status: 0 # 0=down 1=up
+ # name: Optional Uptime Kuma Monitor Name to filter by</pre>
+ <p>
+ {{ $t("Then choose an action, for example switch the scene to where an RGB light is red.") }}
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/src/components/notifications/Keep.vue b/src/components/notifications/Keep.vue
new file mode 100644
index 0000000..f0c856e
--- /dev/null
+++ b/src/components/notifications/Keep.vue
@@ -0,0 +1,42 @@
+<template>
+ <div class="mb-3">
+ <label for="webhook-url" class="form-label">{{ $t("Host URL") }}</label>
+ <input
+ id="webhook-url"
+ v-model="$parent.notification.webhookURL"
+ type="url"
+ pattern="https?://.+"
+ class="form-control"
+ required
+ />
+ <div class="form-text">
+ <i18n-t tag="p" keypath="Read more:">
+ <a href="https://docs.keephq.dev/providers/documentation/uptimekuma-provider" target="_blank">https://docs.keephq.dev/providers/documentation/uptimekuma-provider</a>
+ </i18n-t>
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <label for="webhook-apikey" class="form-label">{{
+ $t("API Key")
+ }}</label>
+ <HiddenInput
+ id="webhook-apikey"
+ v-model="$parent.notification.webhookAPIKey"
+ :required="true"
+ ></HiddenInput>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+ mounted() {
+ this.$parent.notification.webhookURL ||= "";
+ },
+};
+</script>
diff --git a/src/components/notifications/Kook.vue b/src/components/notifications/Kook.vue
new file mode 100644
index 0000000..704878b
--- /dev/null
+++ b/src/components/notifications/Kook.vue
@@ -0,0 +1,33 @@
+<template>
+ <div class="mb-3">
+ <label for="kook-bot-token" class="form-label">{{ $t("Bot Token") }}</label>
+ <HiddenInput id="kook-bot-token" v-model="$parent.notification.kookBotToken" :required="true" autocomplete="new-password"></HiddenInput>
+ <i18n-t tag="div" keypath="wayToGetKookBotToken" class="form-text">
+ <a href="https://developer.kookapp.cn/bot" target="_blank">https://developer.kookapp.cn/bot</a>
+ </i18n-t>
+ </div>
+
+ <div class="mb-3">
+ <label for="kook-guild-id" class="form-label">{{ $t("Guild ID") }}</label>
+ <input id="kook-guild-id" v-model="$parent.notification.kookGuildID" type="text" class="form-control" required>
+
+ <div class="form-text">
+ <p style="margin-top: 8px;">
+ {{ $t("wayToGetKookGuildID") }}
+ </p>
+ </div>
+ </div>
+
+ <i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
+ <a href="https://developer.kookapp.cn" target="_blank">https://developer.kookapp.cn</a>
+ </i18n-t>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+export default {
+ components: {
+ HiddenInput,
+ }
+};
+</script>
diff --git a/src/components/notifications/Line.vue b/src/components/notifications/Line.vue
new file mode 100644
index 0000000..8ab6a04
--- /dev/null
+++ b/src/components/notifications/Line.vue
@@ -0,0 +1,29 @@
+<template>
+ <div class="mb-3">
+ <label for="line-channel-access-token" class="form-label">{{ $t("Channel access token (Long-lived)") }}</label>
+ <HiddenInput id="line-channel-access-token" v-model="$parent.notification.lineChannelAccessToken" :required="true" autocomplete="new-password"></HiddenInput>
+ </div>
+ <i18n-t tag="div" keypath="lineDevConsoleTo" class="form-text">
+ <b>{{ $t("Messaging API") }}</b>
+ </i18n-t>
+ <div class="mb-3" style="margin-top: 12px;">
+ <label for="line-user-id" class="form-label">{{ $t("Your User ID") }}</label>
+ <input id="line-user-id" v-model="$parent.notification.lineUserID" type="text" class="form-control" required>
+ </div>
+ <i18n-t tag="div" keypath="lineDevConsoleTo" class="form-text">
+ <b>{{ $t("Basic Settings") }}</b>
+ </i18n-t>
+ <i18n-t tag="div" keypath="wayToGetLineChannelToken" class="form-text" style="margin-top: 8px;">
+ <a href="https://developers.line.biz/console/" target="_blank">{{ $t("Line Developers Console") }}</a>
+ </i18n-t>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/LineNotify.vue b/src/components/notifications/LineNotify.vue
new file mode 100644
index 0000000..0f6897f
--- /dev/null
+++ b/src/components/notifications/LineNotify.vue
@@ -0,0 +1,9 @@
+<template>
+ <div class="mb-3">
+ <label for="line-notify-access-token" class="form-label">{{ $t("Access Token") }}</label>
+ <input id="line-notify-access-token" v-model="$parent.notification.lineNotifyAccessToken" type="text" class="form-control" :required="true">
+ </div>
+ <i18n-t tag="div" keypath="wayToGetLineNotifyToken" class="form-text" style="margin-top: 8px;">
+ <a href="https://notify-bot.line.me/" target="_blank">https://notify-bot.line.me/</a>
+ </i18n-t>
+</template>
diff --git a/src/components/notifications/LunaSea.vue b/src/components/notifications/LunaSea.vue
new file mode 100644
index 0000000..4c55b3c
--- /dev/null
+++ b/src/components/notifications/LunaSea.vue
@@ -0,0 +1,33 @@
+<template>
+ <div class="mb-3">
+ <label for="lunasea-notification-target" class="form-label">{{ $t("lunaseaTarget") }}<span style="color: red;"><sup>*</sup></span></label>
+ <div class="form-text">
+ <p>
+ <select id="lunasea-notification-target" v-model="$parent.notification.lunaseaTarget" class="form-select" required>
+ <option value="device">{{ $t("lunaseaDeviceID") }}</option>
+ <option value="user">{{ $t("lunaseaUserID") }}</option>
+ </select>
+ </p>
+ </div>
+ <div v-if="$parent.notification.lunaseaTarget === 'device'">
+ <label for="lunasea-device" class="form-label">{{ $t("lunaseaDeviceID") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="lunasea-device" v-model="$parent.notification.lunaseaDevice" type="text" class="form-control">
+ </div>
+ <div v-if="$parent.notification.lunaseaTarget === 'user'">
+ <label for="lunasea-device" class="form-label">{{ $t("lunaseaUserID") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="lunasea-device" v-model="$parent.notification.lunaseaUserID" type="text" class="form-control">
+ </div>
+ </div>
+</template>
+
+<script lang="ts">
+
+export default {
+ mounted() {
+ if (typeof this.$parent.notification.lunaseaTarget === "undefined") {
+ this.$parent.notification.lunaseaTarget = "device";
+ }
+ }
+};
+
+</script>
diff --git a/src/components/notifications/Matrix.vue b/src/components/notifications/Matrix.vue
new file mode 100644
index 0000000..a9fd634
--- /dev/null
+++ b/src/components/notifications/Matrix.vue
@@ -0,0 +1,34 @@
+<template>
+ <div class="mb-3">
+ <label for="homeserver-url" class="form-label">{{ $t("matrixHomeserverURL") }}</label><span style="color: red;"><sup>*</sup></span>
+ <input id="homeserver-url" v-model="$parent.notification.homeserverUrl" type="text" class="form-control" :required="true">
+ </div>
+ <div class="mb-3">
+ <label for="internal-room-id" class="form-label">{{ $t("Internal Room Id") }}</label><span style="color: red;"><sup>*</sup></span>
+ <input id="internal-room-id" v-model="$parent.notification.internalRoomId" type="text" class="form-control" required="true">
+ </div>
+ <div class="mb-3">
+ <label for="access-token" class="form-label">{{ $t("Access Token") }}</label><span style="color: red;"><sup>*</sup></span>
+ <HiddenInput id="access-token" v-model="$parent.notification.accessToken" :required="true" autocomplete="new-password" :maxlength="500"></HiddenInput>
+ </div>
+
+ <div class="form-text">
+ <span style="color: red;"><sup>*</sup></span>{{ $t("Required") }}
+ <p style="margin-top: 8px;">
+ {{ $t("matrixDesc1") }}
+ </p>
+ <i18n-t tag="p" keypath="matrixDesc2" style="margin-top: 8px;">
+ <code>curl -XPOST -d '{"type": "m.login.password", "identifier": {"user": "botusername", "type": "m.id.user"}, "password": "passwordforuser"}' "https://home.server/_matrix/client/r0/login"</code>.
+ </i18n-t>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/Mattermost.vue b/src/components/notifications/Mattermost.vue
new file mode 100644
index 0000000..1fa094f
--- /dev/null
+++ b/src/components/notifications/Mattermost.vue
@@ -0,0 +1,32 @@
+<template>
+ <div class="mb-3">
+ <label for="mattermost-webhook-url" class="form-label">{{ $t("Webhook URL") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="mattermost-webhook-url" v-model="$parent.notification.mattermostWebhookUrl" type="text" class="form-control" required>
+ <label for="mattermost-username" class="form-label">{{ $t("Username") }}</label>
+ <input id="mattermost-username" v-model="$parent.notification.mattermostusername" type="text" class="form-control">
+ <label for="mattermost-iconurl" class="form-label">{{ $t("Icon URL") }}</label>
+ <input id="mattermost-iconurl" v-model="$parent.notification.mattermosticonurl" type="text" class="form-control">
+ <label for="mattermost-iconemo" class="form-label">{{ $t("Icon Emoji") }}</label>
+ <input id="mattermost-iconemo" v-model="$parent.notification.mattermosticonemo" type="text" class="form-control">
+ <label for="mattermost-channel" class="form-label">{{ $t("Channel Name") }}</label>
+ <input id="mattermost-channel-name" v-model="$parent.notification.mattermostchannel" type="text" class="form-control">
+ <div class="form-text">
+ <span style="color: red;"><sup>*</sup></span>{{ $t("Required") }}
+ <i18n-t tag="p" keypath="aboutWebhooks" style="margin-top: 8px;">
+ <a href="https://developers.mattermost.com/integrate/webhooks/incoming/" target="_blank">https://developers.mattermost.com/integrate/webhooks/incoming/</a>
+ </i18n-t>
+ <p style="margin-top: 8px;">
+ {{ $t("aboutMattermostChannelName") }}
+ </p>
+ <p style="margin-top: 8px;">
+ {{ $t("aboutKumaURL") }}
+ </p>
+ <p style="margin-top: 8px;">
+ {{ $t("aboutIconURL") }}
+ </p>
+ <i18n-t tag="p" keypath="emojiCheatSheet" style="margin-top: 8px;">
+ <a href="https://www.webfx.com/tools/emoji-cheat-sheet/" target="_blank">https://www.webfx.com/tools/emoji-cheat-sheet/</a>
+ </i18n-t>
+ </div>
+ </div>
+</template>
diff --git a/src/components/notifications/Nostr.vue b/src/components/notifications/Nostr.vue
new file mode 100644
index 0000000..83f84b0
--- /dev/null
+++ b/src/components/notifications/Nostr.vue
@@ -0,0 +1,26 @@
+<template>
+ <div class="mb-3">
+ <label for="nostr-relays" class="form-label">{{ $t("nostrRelays") }}<span style="color: red;"><sup>*</sup></span></label>
+ <textarea id="nostr-relays" v-model="$parent.notification.relays" class="form-control" :required="true" placeholder="wss://127.0.0.1:7777/"></textarea>
+ <small class="form-text text-muted">{{ $t("nostrRelaysHelp") }}</small>
+ </div>
+ <div class="mb-3">
+ <label for="nostr-sender" class="form-label">{{ $t("nostrSender") }}<span style="color: red;"><sup>*</sup></span></label>
+ <HiddenInput id="nostr-sender" v-model="$parent.notification.sender" autocomplete="new-password" :required="true"></HiddenInput>
+ </div>
+ <div class="mb-3">
+ <label for="nostr-recipients" class="form-label">{{ $t("nostrRecipients") }}<span style="color: red;"><sup>*</sup></span></label>
+ <textarea id="nostr-recipients" v-model="$parent.notification.recipients" class="form-control" :required="true" placeholder="npub123...&#10;npub789..."></textarea>
+ <small class="form-text text-muted">{{ $t("nostrRecipientsHelp") }}</small>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/Ntfy.vue b/src/components/notifications/Ntfy.vue
new file mode 100644
index 0000000..ba94451
--- /dev/null
+++ b/src/components/notifications/Ntfy.vue
@@ -0,0 +1,82 @@
+<template>
+ <div class="mb-3">
+ <label for="ntfy-ntfytopic" class="form-label">{{ $t("ntfy Topic") }}</label>
+ <input id="ntfy-ntfytopic" v-model="$parent.notification.ntfytopic" type="text" class="form-control" required>
+ </div>
+ <div class="mb-3">
+ <label for="ntfy-server-url" class="form-label">{{ $t("Server URL") }}</label>
+ <input id="ntfy-server-url" v-model="$parent.notification.ntfyserverurl" type="text" class="form-control" required>
+ <div class="form-text">
+ {{ $t("Server URL should not contain the nfty topic") }}
+ </div>
+ </div>
+ <div class="mb-3">
+ <label for="ntfy-priority" class="form-label">{{ $t("Priority") }}</label>
+ <input id="ntfy-priority" v-model="$parent.notification.ntfyPriority" type="number" class="form-control" required min="1" max="5" step="1">
+ <div class="form-text">
+ <p v-if="$parent.notification.ntfyPriority >= 5">
+ {{ $t("ntfyPriorityHelptextAllEvents") }}
+ </p>
+ <i18n-t v-else tag="p" keypath="ntfyPriorityHelptextAllExceptDown">
+ <code>DOWN</code>
+ <code>{{ $parent.notification.ntfyPriority + 1 }}</code>
+ </i18n-t>
+ </div>
+ </div>
+ <div class="mb-3">
+ <label for="authentication-method" class="form-label">{{ $t("ntfyAuthenticationMethod") }}</label>
+ <select id="authentication-method" v-model="$parent.notification.ntfyAuthenticationMethod" class="form-select">
+ <option v-for="(name, type) in authenticationMethods" :key="type" :value="type">{{ name }}</option>
+ </select>
+ </div>
+ <div v-if="$parent.notification.ntfyAuthenticationMethod === 'usernamePassword'" class="mb-3">
+ <label for="ntfy-username" class="form-label">{{ $t("Username") }}</label>
+ <input id="ntfy-username" v-model="$parent.notification.ntfyusername" type="text" class="form-control">
+ </div>
+ <div v-if="$parent.notification.ntfyAuthenticationMethod === 'usernamePassword'" class="mb-3">
+ <label for="ntfy-password" class="form-label">{{ $t("Password") }}</label>
+ <HiddenInput id="ntfy-password" v-model="$parent.notification.ntfypassword" autocomplete="new-password"></HiddenInput>
+ </div>
+ <div v-if="$parent.notification.ntfyAuthenticationMethod === 'accessToken'" class="mb-3">
+ <label for="ntfy-access-token" class="form-label">{{ $t("Access Token") }}</label>
+ <HiddenInput id="ntfy-access-token" v-model="$parent.notification.ntfyaccesstoken"></HiddenInput>
+ </div>
+ <div class="mb-3">
+ <label for="ntfy-icon" class="form-label">{{ $t("IconUrl") }}</label>
+ <input id="ntfy-icon" v-model="$parent.notification.ntfyIcon" type="text" class="form-control">
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+ computed: {
+ authenticationMethods() {
+ return {
+ none: this.$t("None"),
+ usernamePassword: this.$t("ntfyUsernameAndPassword"),
+ accessToken: this.$t("Access Token")
+ };
+ }
+ },
+ mounted() {
+ if (typeof this.$parent.notification.ntfyPriority === "undefined") {
+ this.$parent.notification.ntfyserverurl = "https://ntfy.sh";
+ this.$parent.notification.ntfyPriority = 5;
+ }
+
+ // Handling notifications that added before 1.22.0
+ if (typeof this.$parent.notification.ntfyAuthenticationMethod === "undefined") {
+ if (!this.$parent.notification.ntfyusername) {
+ this.$parent.notification.ntfyAuthenticationMethod = "none";
+ } else {
+ this.$parent.notification.ntfyAuthenticationMethod = "usernamePassword";
+ }
+ }
+ },
+};
+</script>
diff --git a/src/components/notifications/Octopush.vue b/src/components/notifications/Octopush.vue
new file mode 100644
index 0000000..15cebe8
--- /dev/null
+++ b/src/components/notifications/Octopush.vue
@@ -0,0 +1,50 @@
+<template>
+ <div class="mb-3">
+ <label for="octopush-version" class="form-label">{{ $t("Octopush API Version") }}</label>
+ <select id="octopush-version" v-model="$parent.notification.octopushVersion" class="form-select">
+ <option value="2">{{ $t("octopush") }} ({{ $t("endpoint") }}: api.octopush.com)</option>
+ <option value="1">{{ $t("Legacy Octopush-DM") }} ({{ $t("endpoint") }}: www.octopush-dm.com)</option>
+ </select>
+ <div class="form-text">
+ {{ $t("octopushLegacyHint") }}
+ </div>
+ </div>
+ <div class="mb-3">
+ <label for="octopush-key" class="form-label">{{ $t("octopushAPIKey") }}</label>
+ <HiddenInput id="octopush-key" v-model="$parent.notification.octopushAPIKey" :required="true" autocomplete="new-password"></HiddenInput>
+ <label for="octopush-login" class="form-label">{{ $t("octopushLogin") }}</label>
+ <input id="octopush-login" v-model="$parent.notification.octopushLogin" type="text" class="form-control" required>
+ </div>
+ <div class="mb-3">
+ <label for="octopush-type-sms" class="form-label">{{ $t("SMS Type") }}</label>
+ <select id="octopush-type-sms" v-model="$parent.notification.octopushSMSType" class="form-select">
+ <option value="sms_premium">{{ $t("octopushTypePremium") }}</option>
+ <option value="sms_low_cost">{{ $t("octopushTypeLowCost") }}</option>
+ </select>
+ <i18n-t tag="div" keypath="Check octopush prices" class="form-text">
+ <a href="https://octopush.com/tarifs-sms-international/" target="_blank">https://octopush.com/tarifs-sms-international/</a>
+ </i18n-t>
+ </div>
+ <div class="mb-3">
+ <label for="octopush-phone-number" class="form-label">{{ $t("octopushPhoneNumber") }}</label>
+ <input id="octopush-phone-number" v-model="$parent.notification.octopushPhoneNumber" type="text" class="form-control" required>
+ </div>
+ <div class="mb-3">
+ <label for="octopush-sender-name" class="form-label">{{ $t("octopushSMSSender") }}</label>
+ <input id="octopush-sender-name" v-model="$parent.notification.octopushSenderName" type="text" minlength="3" maxlength="11" class="form-control">
+ </div>
+
+ <i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
+ <a href="https://octopush.com/api-sms-documentation/envoi-de-sms/" target="_blank">https://octopush.com/api-sms-documentation/envoi-de-sms/</a>
+ </i18n-t>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/OneBot.vue b/src/components/notifications/OneBot.vue
new file mode 100644
index 0000000..63604d6
--- /dev/null
+++ b/src/components/notifications/OneBot.vue
@@ -0,0 +1,34 @@
+<template>
+ <div class="mb-3">
+ <div class="mb-3">
+ <label for="onebot-http-addr" class="form-label">{{ $t("onebotHttpAddress") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="HttpUrl" v-model="$parent.notification.httpAddr" type="text" class="form-control" required>
+ </div>
+ <div class="mb-3">
+ <label for="onebot-access-token" class="form-label">AccessToken<span style="color: red;"><sup>*</sup></span></label>
+ <input id="HttpUrl" v-model="$parent.notification.accessToken" type="text" class="form-control" required>
+ <div class="form-text">
+ <p>{{ $t("onebotSafetyTips") }}</p>
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <label for="onebot-msg-type" class="form-label">{{ $t("onebotMessageType") }}</label>
+ <select id="onebot-msg-type" v-model="$parent.notification.msgType" class="form-select">
+ <option value="group">{{ $t("onebotGroupMessage") }}</option>
+ <option value="private">{{ $t("onebotPrivateMessage") }}</option>
+ </select>
+ </div>
+
+ <div class="mb-3">
+ <label for="onebot-reciever-id" class="form-label">{{ $t("onebotUserOrGroupId") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="secretKey" v-model="$parent.notification.recieverId" type="text" class="form-control" required>
+ </div>
+
+ <div class="form-text">
+ <i18n-t tag="p" keypath="Read more:">
+ <a href="https://github.com/botuniverse/onebot-11" target="_blank">https://github.com/botuniverse/onebot-11</a>
+ </i18n-t>
+ </div>
+ </div>
+</template>
diff --git a/src/components/notifications/Onesender.vue b/src/components/notifications/Onesender.vue
new file mode 100644
index 0000000..81dbe7f
--- /dev/null
+++ b/src/components/notifications/Onesender.vue
@@ -0,0 +1,81 @@
+<template>
+ <div class="mb-3">
+ <label for="host-onesender" class="form-label">{{ $t("Host Onesender") }}</label>
+ <input
+ id="host-onesender"
+ v-model="$parent.notification.onesenderURL"
+ type="url"
+ placeholder="https://xxxxxxxxxxx.com/api/v1/messages"
+ pattern="https?://.+"
+ class="form-control"
+ required
+ />
+ </div>
+
+ <div class="mb-3">
+ <label for="receiver-onesender" class="form-label">{{ $t("Token Onesender") }}</label>
+ <HiddenInput id="receiver-onesender" v-model="$parent.notification.onesenderToken" :required="true" autocomplete="false"></HiddenInput>
+ <i18n-t tag="div" keypath="wayToGetOnesenderUrlandToken" class="form-text">
+ <a href="https://onesender.net/" target="_blank">{{ $t("here") }}</a>
+ </i18n-t>
+ </div>
+
+ <div class="mb-3">
+ <label for="webhook-request-body" class="form-label">{{ $t("Recipient Type") }}</label>
+ <select
+ id="webhook-request-body"
+ v-model="$parent.notification.onesenderTypeReceiver"
+ class="form-select"
+ required
+ >
+ <option value="private">{{ $t("Private Number") }}</option>
+ <option value="group">{{ $t("Group ID") }}</option>
+ </select>
+ </div>
+ <div v-if="$parent.notification.onesenderTypeReceiver == 'private'" class="form-text">{{ $t("privateOnesenderDesc", ['"application/json"']) }}</div>
+ <div v-else class="form-text">{{ $t("groupOnesenderDesc") }}</div>
+ <div class="mb-3">
+ <input
+ id="type-receiver-onesender"
+ v-model="$parent.notification.onesenderReceiver"
+ type="text"
+ placeholder="628123456789 or 628123456789-34534"
+ class="form-control"
+ required
+ />
+ </div>
+ <div class="mb-3">
+ <input
+ id="type-receiver-onesender"
+ v-model="computedReceiverResult"
+ type="text"
+ class="form-control"
+ disabled
+ />
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+ data() {
+ return {};
+ },
+ computed: {
+ computedReceiverResult() {
+ let receiver = this.$parent.notification.onesenderReceiver;
+ return this.$parent.notification.onesenderTypeReceiver === "private" ? receiver + "@s.whatsapp.net" : receiver + "@g.us";
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+textarea {
+ min-height: 200px;
+}
+</style>
diff --git a/src/components/notifications/Opsgenie.vue b/src/components/notifications/Opsgenie.vue
new file mode 100644
index 0000000..3f07d05
--- /dev/null
+++ b/src/components/notifications/Opsgenie.vue
@@ -0,0 +1,36 @@
+<template>
+ <div class="mb-3">
+ <label for="opsgenie-region" class="form-label">{{ $t("Region") }}<span style="color: red;"><sup>*</sup></span></label>
+ <select id="opsgenie-region" v-model="$parent.notification.opsgenieRegion" class="form-select" required>
+ <option value="us">
+ US (Default)
+ </option>
+ <option value="eu">
+ EU
+ </option>
+ </select>
+ </div>
+ <div class="mb-3">
+ <label for="opsgenie-apikey" class="form-label">{{ $t("API Key") }}<span style="color: red;"><sup>*</sup></span></label>
+ <HiddenInput id="opsgenie-apikey" v-model="$parent.notification.opsgenieApiKey" required="true" autocomplete="false"></HiddenInput>
+ </div>
+ <div class="mb-3">
+ <label for="opsgenie-priority" class="form-label">{{ $t("Priority") }}</label>
+ <input id="opsgenie-priority" v-model="$parent.notification.opsgeniePriority" type="number" class="form-control" min="1" max="5" step="1">
+ </div>
+ <div class="form-text">
+ <span style="color: red;"><sup>*</sup></span>{{ $t("Required") }}
+ <i18n-t tag="p" keypath="aboutWebhooks" style="margin-top: 8px;">
+ <a href="https://docs.opsgenie.com/docs/alert-api" target="_blank">https://docs.opsgenie.com/docs/alert-api</a>
+ </i18n-t>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/PagerDuty.vue b/src/components/notifications/PagerDuty.vue
new file mode 100644
index 0000000..059a9ae
--- /dev/null
+++ b/src/components/notifications/PagerDuty.vue
@@ -0,0 +1,45 @@
+<template>
+ <div class="mb-3">
+ <label for="pagerduty-integration-key" class="form-label">{{ $t("Integration Key") }}</label>
+ <HiddenInput id="pagerduty-integration-key" v-model="$parent.notification.pagerdutyIntegrationKey" :required="true" autocomplete="false"></HiddenInput>
+ <i18n-t tag="div" keypath="wayToGetPagerDutyKey" class="form-text">
+ <a href="https://support.pagerduty.com/docs/services-and-integrations" target="_blank">{{ $t("here") }}</a>
+ </i18n-t>
+ </div>
+ <div class="mb-3">
+ <label for="pagerduty-integration-url" class="form-label">{{ $t("Integration URL") }}</label>
+ <input id="pagerduty-integration-url" v-model="$parent.notification.pagerdutyIntegrationUrl" type="text" class="form-control" autocomplete="false">
+ </div>
+ <div class="mb-3">
+ <label for="pagerduty-priority" class="form-label">{{ $t("Priority") }}</label>
+ <select id="pagerduty-priority" v-model="$parent.notification.pagerdutyPriority" class="form-select">
+ <option value="info">{{ $t("info") }}</option>
+ <option value="warning" selected="selected">{{ $t("warning") }}</option>
+ <option value="error">{{ $t("error") }}</option>
+ <option value="critical">{{ $t("critical") }}</option>
+ </select>
+ </div>
+ <div class="mb-3">
+ <label for="pagerduty-resolve" class="form-label">{{ $t("Auto resolve or acknowledged") }}</label>
+ <select id="pagerduty-resolve" v-model="$parent.notification.pagerdutyAutoResolve" class="form-select">
+ <option value="0" selected="selected">{{ $t("do nothing") }}</option>
+ <option value="acknowledge">{{ $t("auto acknowledged") }}</option>
+ <option value="resolve">{{ $t("auto resolve") }}</option>
+ </select>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+ mounted() {
+ if (typeof this.$parent.notification.pagerdutyIntegrationUrl === "undefined") {
+ this.$parent.notification.pagerdutyIntegrationUrl = "https://events.pagerduty.com/v2/enqueue";
+ }
+ }
+};
+</script>
diff --git a/src/components/notifications/PagerTree.vue b/src/components/notifications/PagerTree.vue
new file mode 100644
index 0000000..0121f65
--- /dev/null
+++ b/src/components/notifications/PagerTree.vue
@@ -0,0 +1,31 @@
+<template>
+ <div class="mb-3">
+ <label for="pagertree-integration-url" class="form-label">{{ $t("pagertreeIntegrationUrl") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="pagertree-integration-url" v-model="$parent.notification.pagertreeIntegrationUrl" type="text" class="form-control" autocomplete="false">
+ <i18n-t tag="div" keypath="wayToGetPagerTreeIntegrationURL" class="form-text">
+ <a href="https://pagertree.com/docs/integration-guides/introduction#copy-the-endpoint-url" target="_blank">{{ $t("here") }}</a>
+ </i18n-t>
+ </div>
+ <div class="mb-3">
+ <label for="pagertree-urgency" class="form-label">{{ $t("pagertreeUrgency") }}</label>
+ <select id="pagertree-urgency" v-model="$parent.notification.pagertreeUrgency" class="form-select">
+ <option value="silent">{{ $t("pagertreeSilent") }}</option>
+ <option value="low">{{ $t("pagertreeLow") }}</option>
+ <option value="medium" selected="selected">{{ $t("pagertreeMedium") }}</option>
+ <option value="high">{{ $t("pagertreeHigh") }}</option>
+ <option value="critical">{{ $t("pagertreeCritical") }}</option>
+ </select>
+ </div>
+ <div class="mb-3">
+ <label for="pagertree-resolve" class="form-label">{{ $t("pagertreeResolve") }}</label>
+ <select id="pagertree-resolve" v-model="$parent.notification.pagertreeAutoResolve" class="form-select">
+ <option value="resolve" selected="selected">{{ $t("pagertreeResolve") }}</option>
+ <option value="0">{{ $t("pagertreeDoNothing") }}</option>
+ </select>
+ </div>
+</template>
+
+<script>
+export default {
+};
+</script>
diff --git a/src/components/notifications/PromoSMS.vue b/src/components/notifications/PromoSMS.vue
new file mode 100644
index 0000000..15ed241
--- /dev/null
+++ b/src/components/notifications/PromoSMS.vue
@@ -0,0 +1,43 @@
+<template>
+ <div class="mb-3">
+ <label for="promosms-login" class="form-label">{{ $t("promosmsLogin") }}</label>
+ <input id="promosms-login" v-model="$parent.notification.promosmsLogin" type="text" class="form-control" required>
+ <label for="promosms-key" class="form-label">{{ $t("promosmsPassword") }}</label>
+ <HiddenInput id="promosms-key" v-model="$parent.notification.promosmsPassword" :required="true" autocomplete="new-password"></HiddenInput>
+ </div>
+ <div class="mb-3">
+ <label for="promosms-type-sms" class="form-label">{{ $t("SMS Type") }}</label>
+ <select id="promosms-type-sms" v-model="$parent.notification.promosmsSMSType" class="form-select">
+ <option value="0">{{ $t("promosmsTypeFlash") }}</option>
+ <option value="1">{{ $t("promosmsTypeEco") }}</option>
+ <option value="3">{{ $t("promosmsTypeFull") }}</option>
+ <option value="4">{{ $t("promosmsTypeSpeed") }}</option>
+ </select>
+ <div class="form-text">
+ {{ $t("checkPrice", [$t("promosms")]) }}
+ <a href="https://promosms.com/cennik/" target="_blank">https://promosms.com/cennik/</a>
+ </div>
+ </div>
+ <div class="mb-3">
+ <label for="promosms-phone-number" class="form-label">{{ $t("promosmsPhoneNumber") }}</label>
+ <input id="promosms-phone-number" v-model="$parent.notification.promosmsPhoneNumber" type="text" class="form-control" required>
+ </div>
+ <div class="mb-3">
+ <label for="promosms-sender-name" class="form-label">{{ $t("promosmsSMSSender") }}</label>
+ <input id="promosms-sender-name" v-model="$parent.notification.promosmsSenderName" type="text" minlength="3" maxlength="11" class="form-control">
+ </div>
+ <div class="form-check form-switch">
+ <input id="promosms-allow-long" v-model="$parent.notification.promosmsAllowLongSMS" type="checkbox" class="form-check-input">
+ <label for="promosms-allow-long" class="form-label">{{ $t("promosmsAllowLongSMS") }}</label>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/PushDeer.vue b/src/components/notifications/PushDeer.vue
new file mode 100644
index 0000000..4fff3be
--- /dev/null
+++ b/src/components/notifications/PushDeer.vue
@@ -0,0 +1,24 @@
+<template>
+ <div class="mb-3">
+ <label for="pushdeer-server" class="form-label">{{ $t("PushDeer Server URL") }}</label>
+ <input id="pushdeer-server" v-model="$parent.notification.pushdeerServer" type="text" class="form-control" placeholder="https://api2.pushdeer.com">
+ <div class="form-text">{{ $t("pushDeerServerDescription") }}</div>
+ </div>
+ <div class="mb-3">
+ <label for="pushdeer-key" class="form-label">{{ $t("PushDeer Key") }}</label>
+ <HiddenInput id="pushdeer-key" v-model="$parent.notification.pushdeerKey" :required="true" autocomplete="new-password" placeholder="PDUxxxx"></HiddenInput>
+ </div>
+
+ <i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
+ <a href="http://www.pushdeer.com/" rel="noopener noreferrer" target="_blank">http://www.pushdeer.com/</a>
+ </i18n-t>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/Pushbullet.vue b/src/components/notifications/Pushbullet.vue
new file mode 100644
index 0000000..866576a
--- /dev/null
+++ b/src/components/notifications/Pushbullet.vue
@@ -0,0 +1,20 @@
+<template>
+ <div class="mb-3">
+ <label for="pushbullet-access-token" class="form-label">{{ $t("Access Token") }}</label>
+ <HiddenInput id="pushbullet-access-token" v-model="$parent.notification.pushbulletAccessToken" :required="true" autocomplete="new-password"></HiddenInput>
+ </div>
+
+ <i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
+ <a href="https://docs.pushbullet.com" target="_blank">https://docs.pushbullet.com</a>
+ </i18n-t>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/Pushover.vue b/src/components/notifications/Pushover.vue
new file mode 100644
index 0000000..7ee0eaf
--- /dev/null
+++ b/src/components/notifications/Pushover.vue
@@ -0,0 +1,70 @@
+<template>
+ <div class="mb-3">
+ <label for="pushover-user" class="form-label">{{ $t("User Key") }}<span style="color: red;"><sup>*</sup></span></label>
+ <HiddenInput id="pushover-user" v-model="$parent.notification.pushoveruserkey" :required="true" autocomplete="new-password"></HiddenInput>
+ <label for="pushover-app-token" class="form-label">{{ $t("Application Token") }}<span style="color: red;"><sup>*</sup></span></label>
+ <HiddenInput id="pushover-app-token" v-model="$parent.notification.pushoverapptoken" :required="true" autocomplete="new-password"></HiddenInput>
+ <label for="pushover-device" class="form-label">{{ $t("Device") }}</label>
+ <input id="pushover-device" v-model="$parent.notification.pushoverdevice" type="text" class="form-control">
+ <label for="pushover-device" class="form-label">{{ $t("Message Title") }}</label>
+ <input id="pushover-title" v-model="$parent.notification.pushovertitle" type="text" class="form-control">
+ <label for="pushover-priority" class="form-label">{{ $t("Priority") }}</label>
+ <select id="pushover-priority" v-model="$parent.notification.pushoverpriority" class="form-select">
+ <option>-2</option>
+ <option>-1</option>
+ <option>0</option>
+ <option>1</option>
+ <option>2</option>
+ </select>
+ <label for="pushover-sound" class="form-label">{{ $t("Notification Sound") }}</label>
+ <select id="pushover-sound" v-model="$parent.notification.pushoversounds" class="form-select">
+ <option value="pushover">{{ $t("pushoversounds pushover") }}</option>
+ <option value="bike">{{ $t("pushoversounds bike") }}</option>
+ <option value="bugle">{{ $t("pushoversounds bugle") }}</option>
+ <option value="cashregister">{{ $t("pushoversounds cashregister") }}</option>
+ <option value="classical">{{ $t("pushoversounds classical") }}</option>
+ <option value="cosmic">{{ $t("pushoversounds cosmic") }}</option>
+ <option value="falling">{{ $t("pushoversounds falling") }}</option>
+ <option value="gamelan">{{ $t("pushoversounds gamelan") }}</option>
+ <option value="incoming">{{ $t("pushoversounds incoming") }}</option>
+ <option value="intermission">{{ $t("pushoversounds intermission") }}</option>
+ <option value="magic">{{ $t("pushoversounds magic") }}</option>
+ <option value="mechanical">{{ $t("pushoversounds mechanical") }}</option>
+ <option value="pianobar">{{ $t("pushoversounds pianobar") }}</option>
+ <option value="siren">{{ $t("pushoversounds siren") }}</option>
+ <option value="spacealarm">{{ $t("pushoversounds spacealarm") }}</option>
+ <option value="tugboat">{{ $t("pushoversounds tugboat") }}</option>
+ <option value="alien">{{ $t("pushoversounds alien") }}</option>
+ <option value="climb">{{ $t("pushoversounds climb") }}</option>
+ <option value="persistent">{{ $t("pushoversounds persistent") }}</option>
+ <option value="echo">{{ $t("pushoversounds echo") }}</option>
+ <option value="updown">{{ $t("pushoversounds updown") }}</option>
+ <option value="vibrate">{{ $t("pushoversounds vibrate") }}</option>
+ <option value="none">{{ $t("pushoversounds none") }}</option>
+ </select>
+ <label for="pushover-ttl" class="form-label">{{ $t("pushoverMessageTtl") }}</label>
+ <input id="pushover-ttl" v-model="$parent.notification.pushoverttl" type="number" min="0" step="1" class="form-control">
+ <div class="form-text">
+ <span style="color: red;"><sup>*</sup></span>{{ $t("Required") }}
+ <i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
+ <a href="https://pushover.net/api" target="_blank">https://pushover.net/api</a>
+ </i18n-t>
+ <p style="margin-top: 8px;">
+ {{ $t("pushoverDesc1") }}
+ </p>
+ <p style="margin-top: 8px;">
+ {{ $t("pushoverDesc2") }}
+ </p>
+ </div>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/Pushy.vue b/src/components/notifications/Pushy.vue
new file mode 100644
index 0000000..454f2e2
--- /dev/null
+++ b/src/components/notifications/Pushy.vue
@@ -0,0 +1,24 @@
+<template>
+ <div class="mb-3">
+ <label for="pushy-app-token" class="form-label">{{ $t("pushyAPIKey") }}</label>
+ <HiddenInput id="pushy-app-token" v-model="$parent.notification.pushyAPIKey" :required="true" autocomplete="new-password"></HiddenInput>
+ </div>
+
+ <div class="mb-3">
+ <label for="pushy-user-key" class="form-label">{{ $t("pushyToken") }}</label>
+ <HiddenInput id="pushy-user-key" v-model="$parent.notification.pushyToken" :required="true" autocomplete="new-password"></HiddenInput>
+ </div>
+ <i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
+ <a href="https://pushy.me/docs/api/send-notifications" target="_blank">https://pushy.me/docs/api/send-notifications</a>
+ </i18n-t>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/RocketChat.vue b/src/components/notifications/RocketChat.vue
new file mode 100644
index 0000000..7846606
--- /dev/null
+++ b/src/components/notifications/RocketChat.vue
@@ -0,0 +1,27 @@
+<template>
+ <div class="mb-3">
+ <label for="rocket-webhook-url" class="form-label">{{ $t("Webhook URL") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="rocket-webhook-url" v-model="$parent.notification.rocketwebhookURL" type="text" class="form-control" required>
+ <label for="rocket-username" class="form-label">{{ $t("Username") }}</label>
+ <input id="rocket-username" v-model="$parent.notification.rocketusername" type="text" class="form-control">
+ <label for="rocket-iconemo" class="form-label">{{ $t("Icon Emoji") }}</label>
+ <input id="rocket-iconemo" v-model="$parent.notification.rocketiconemo" type="text" class="form-control">
+ <label for="rocket-channel" class="form-label">{{ $t("Channel Name") }}</label>
+ <input id="rocket-channel-name" v-model="$parent.notification.rocketchannel" type="text" class="form-control">
+ <div class="form-text">
+ <span style="color: red;"><sup>*</sup></span>{{ $t("Required") }}
+ <i18n-t tag="p" keypath="aboutWebhooks" style="margin-top: 8px;">
+ <a href="https://docs.rocket.chat/guides/administration/administration/integrations" target="_blank">https://docs.rocket.chat/guides/administration/administration/integrations</a>
+ </i18n-t>
+ <p style="margin-top: 8px;">
+ {{ $t("aboutChannelName", [$t("rocket.chat")]) }}
+ </p>
+ <p style="margin-top: 8px;">
+ {{ $t("aboutKumaURL") }}
+ </p>
+ <i18n-t tag="p" keypath="emojiCheatSheet" style="margin-top: 8px;">
+ <a href="https://www.webfx.com/tools/emoji-cheat-sheet/" target="_blank">https://www.webfx.com/tools/emoji-cheat-sheet/</a>
+ </i18n-t>
+ </div>
+ </div>
+</template>
diff --git a/src/components/notifications/SIGNL4.vue b/src/components/notifications/SIGNL4.vue
new file mode 100644
index 0000000..d557c2d
--- /dev/null
+++ b/src/components/notifications/SIGNL4.vue
@@ -0,0 +1,16 @@
+<template>
+ <div class="mb-3">
+ <label for="signl4-webhook-url" class="form-label">{{ $t("SIGNL4 Webhook URL") }}</label>
+ <input
+ id="signl4-webhook-url"
+ v-model="$parent.notification.webhookURL"
+ type="url"
+ pattern="https?://.+"
+ class="form-control"
+ required
+ />
+ <i18n-t tag="div" keypath="signl4Docs" class="form-text">
+ <a href="https://docs.signl4.com/integrations/uptime-kuma/uptime-kuma.html" target="_blank">SIGNL4 Docs</a>
+ </i18n-t>
+ </div>
+</template>
diff --git a/src/components/notifications/SMSC.vue b/src/components/notifications/SMSC.vue
new file mode 100644
index 0000000..36a0a89
--- /dev/null
+++ b/src/components/notifications/SMSC.vue
@@ -0,0 +1,43 @@
+<template>
+ <div class="mb-3">
+ <label for="smsc-login" class="form-label">{{ $t("API Username") }}</label>
+ <i18n-t tag="div" class="form-text" keypath="wayToGetClickSendSMSToken">
+ <a href="https://smsc.kz/" target="_blank">{{ $t("here") }}</a>
+ </i18n-t>
+ <input id="smsc-login" v-model="$parent.notification.smscLogin" type="text" class="form-control" required>
+ <label for="smsc-key" class="form-label">{{ $t("API Key") }}</label>
+ <HiddenInput id="smsc-key" v-model="$parent.notification.smscPassword" :required="true" autocomplete="new-password"></HiddenInput>
+ </div>
+ <div class="mb-3">
+ <div class="form-text">
+ {{ $t("checkPrice", ['СМСЦ']) }}
+ <a href="https://smsc.kz/tariffs/" target="_blank">https://smsc.kz/tariffs/</a>
+ </div>
+ </div>
+ <div class="mb-3">
+ <label for="smsc-to-number" class="form-label">{{ $t("Recipient Number") }}</label>
+ <input id="smsc-to-number" v-model="$parent.notification.smscToNumber" type="text" minlength="11" class="form-control" required>
+ </div>
+ <div class="mb-3">
+ <label for="smsc-sender-name" class="form-label">{{ $t("From Name/Number") }}</label>
+ <input id="smsc-sender-name" v-model="$parent.notification.smscSenderName" type="text" minlength="1" maxlength="15" class="form-control">
+ <div class="form-text">{{ $t("Leave blank to use a shared sender number.") }}</div>
+ </div>
+ <div class="mb-3">
+ <label for="smsc-platform" class="form-label">{{ $t("smscTranslit") }}</label><span style="color: red;"><sup>*</sup></span>
+ <select id="smsc-platform" v-model="$parent.notification.smscTranslit" class="form-select">
+ <option value="0">{{ $t("Default") }}</option>
+ <option value="1">Translit</option>
+ <option value="2">MpaHc/Ium</option>
+ </select>
+ </div>
+</template>
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/SMSEagle.vue b/src/components/notifications/SMSEagle.vue
new file mode 100644
index 0000000..ec78131
--- /dev/null
+++ b/src/components/notifications/SMSEagle.vue
@@ -0,0 +1,40 @@
+<template>
+ <div class="mb-3">
+ <label for="smseagle-url" class="form-label">{{ $t("smseagleUrl") }}</label>
+ <input id="smseagle-url" v-model="$parent.notification.smseagleUrl" type="text" minlength="7" class="form-control" placeholder="http://127.0.0.1" required>
+ </div>
+ <div class="mb-3">
+ <label for="smseagle-token" class="form-label">{{ $t("smseagleToken") }}</label>
+ <HiddenInput id="smseagle-token" v-model="$parent.notification.smseagleToken" :required="true"></HiddenInput>
+ </div>
+ <div class="mb-3">
+ <label for="smseagle-recipient-type" class="form-label">{{ $t("smseagleRecipientType") }}</label>
+ <select id="smseagle-recipient-type" v-model="$parent.notification.smseagleRecipientType" class="form-select">
+ <option value="smseagle-to" selected>{{ $t("smseagleTo") }}</option>
+ <option value="smseagle-group">{{ $t("smseagleGroup") }}</option>
+ <option value="smseagle-contact">{{ $t("smseagleContact") }}</option>
+ </select>
+ </div>
+ <div class="mb-3">
+ <label for="smseagle-recipient" class="form-label">{{ $t("smseagleRecipient") }}</label>
+ <input id="smseagle-recipient" v-model="$parent.notification.smseagleRecipient" type="text" class="form-control" required>
+ </div>
+ <div class="mb-3">
+ <label for="smseagle-priority" class="form-label">{{ $t("smseaglePriority") }}</label>
+ <input id="smseagle-priority" v-model="$parent.notification.smseaglePriority" type="number" class="form-control" min="0" max="9" step="1" placeholder="0">
+ </div>
+ <div class="mb-3 form-check form-switch">
+ <label for="smseagle-encoding" class="form-label">{{ $t("smseagleEncoding") }}</label>
+ <input id="smseagle-encoding" v-model="$parent.notification.smseagleEncoding" type="checkbox" class="form-check-input">
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/SMSManager.vue b/src/components/notifications/SMSManager.vue
new file mode 100644
index 0000000..00be2fa
--- /dev/null
+++ b/src/components/notifications/SMSManager.vue
@@ -0,0 +1,31 @@
+<template>
+ <div class="mb-3">
+ <label for="smsmanager-key" class="form-label">{{ $t("API Key") }}</label>
+ <div class="form-text">
+ {{ $t("SMSManager API Docs") }}
+ <a href="https://smsmanager.cz/api/http#send" target="_blank">{{ $t("here") }}</a>
+ </div>
+ <input id="smsmanager-key" v-model="$parent.notification.smsmanagerApiKey" type="text" class="form-control">
+ </div>
+ <div class="mb-3">
+ <label for="smsmanager-numbers" class="form-label"> {{ $t("Recipients") }}</label>
+ <div class="form-text">
+ {{ $t("You can divide numbers with") }} <b>,</b> {{ $t("or") }} <b>;</b>
+ </div>
+ <input id="smsmanager-numbers" v-model="$parent.notification.numbers" type="text" class="form-control">
+ </div>
+ <div class="mb-3">
+ <label for="smsmanager-messageType" class="form-label">{{ $t("Gateway Type") }}</label>
+ <select id="smsmanager-messageType" v-model="$parent.notification.messageType" class="form-select">
+ <option value="economy">{{ $t("Economy") }}</option>
+ <option value="lowcost">{{ $t("Lowcost") }}</option>
+ <option value="high" selected>{{ $t("High") }}</option>
+ </select>
+ </div>
+ <div class="mb-3">
+ <div class="form-text">
+ {{ $t("checkPrice", [$t("SMSManager")]) }}
+ <a href="https://smsmanager.cz/rozesilani-sms/ceny/ceska-republika/" target="_blank">{{ $t("here") }}</a>
+ </div>
+ </div>
+</template>
diff --git a/src/components/notifications/SMSPartner.vue b/src/components/notifications/SMSPartner.vue
new file mode 100644
index 0000000..8de64a8
--- /dev/null
+++ b/src/components/notifications/SMSPartner.vue
@@ -0,0 +1,39 @@
+<template>
+ <div class="mb-3">
+ <label for="smspartner-key" class="form-label">{{ $t("API Key") }}</label>
+ <HiddenInput id="smspartner-key" v-model="$parent.notification.smspartnerApikey" :required="true" autocomplete="new-password"></HiddenInput>
+ <div class="form-text">
+ <i18n-t keypath="smspartnerApiurl" tag="div" class="form-text">
+ <a href="https://my.smspartner.fr/dashboard/api" target="_blank">my.smspartner.fr/dashboard/api</a>
+ </i18n-t>
+ </div>
+ </div>
+ <div class="mb-3">
+ <label for="smspartner-phone-number" class="form-label">{{ $t("smspartnerPhoneNumber") }}</label>
+ <input id="smspartner-phone-number" v-model="$parent.notification.smspartnerPhoneNumber" type="text" minlength="3" maxlength="20" pattern="^[\d+,]+$" class="form-control" required>
+ <div class="form-text">
+ <i18n-t keypath="smspartnerPhoneNumberHelptext" tag="div" class="form-text">
+ <code>+336xxxxxxxx</code>
+ <code>+496xxxxxxxx</code>
+ <code>,</code>
+ </i18n-t>
+ </div>
+ </div>
+ <div class="mb-3">
+ <label for="smspartner-sender-name" class="form-label">{{ $t("smspartnerSenderName") }}</label>
+ <input id="smspartner-sender-name" v-model="$parent.notification.smspartnerSenderName" type="text" minlength="3" maxlength="11" pattern="^[a-zA-Z0-9]*$" class="form-control" required>
+ <div class="form-text">
+ {{ $t("smspartnerSenderNameInfo") }}
+ </div>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/SMTP.vue b/src/components/notifications/SMTP.vue
new file mode 100644
index 0000000..003f905
--- /dev/null
+++ b/src/components/notifications/SMTP.vue
@@ -0,0 +1,149 @@
+<template>
+ <div>
+ <div class="mb-3">
+ <label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
+ <input id="hostname" v-model="$parent.notification.smtpHost" type="text" class="form-control" required>
+ </div>
+
+ <i18n-t tag="div" keypath="Either enter the hostname of the server you want to connect to or localhost if you intend to use a locally configured mail transfer agent" class="form-text">
+ <template #localhost>
+ <code>localhost</code>
+ </template>
+ <template #local_mta>
+ <a href="https://wikipedia.org/wiki/Mail_Transfer_Agent" target="_blank">{{ $t("locally configured mail transfer agent") }}</a>
+ </template>
+ </i18n-t>
+ <div class="mb-3">
+ <label for="port" class="form-label">{{ $t("Port") }}</label>
+ <input id="port" v-model="$parent.notification.smtpPort" type="number" class="form-control" required min="0" max="65535" step="1">
+ </div>
+
+ <div class="mb-3">
+ <label for="secure" class="form-label">{{ $t("Security") }}</label>
+ <select id="secure" v-model="$parent.notification.smtpSecure" class="form-select">
+ <option :value="false">{{ $t("secureOptionNone") }}</option>
+ <option :value="true">{{ $t("secureOptionTLS") }}</option>
+ </select>
+ </div>
+
+ <div class="mb-3">
+ <div class="form-check">
+ <input id="ignore-tls-error" v-model="$parent.notification.smtpIgnoreTLSError" class="form-check-input" type="checkbox" value="">
+ <label class="form-check-label" for="ignore-tls-error">
+ {{ $t("Ignore TLS Error") }}
+ </label>
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <label for="username" class="form-label">{{ $t("Username") }}</label>
+ <input id="username" v-model="$parent.notification.smtpUsername" type="text" class="form-control" autocomplete="false">
+ </div>
+
+ <div class="mb-3">
+ <label for="password" class="form-label">{{ $t("Password") }}</label>
+ <HiddenInput id="password" v-model="$parent.notification.smtpPassword" :required="false" autocomplete="new-password"></HiddenInput>
+ </div>
+
+ <div class="mb-3">
+ <label for="from-email" class="form-label">{{ $t("From Email") }}</label>
+ <input id="from-email" v-model="$parent.notification.smtpFrom" type="text" class="form-control" required autocomplete="false" placeholder="&quot;Uptime Kuma&quot; &lt;example@kuma.pet&gt;">
+ <div class="form-text">
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <label for="to-email" class="form-label">{{ $t("To Email") }}</label>
+ <input id="to-email" v-model="$parent.notification.smtpTo" type="text" class="form-control" autocomplete="false" placeholder="example2@kuma.pet, example3@kuma.pet" :required="!hasRecipient">
+ </div>
+
+ <div class="mb-3">
+ <label for="to-cc" class="form-label">{{ $t("smtpCC") }}</label>
+ <input id="to-cc" v-model="$parent.notification.smtpCC" type="text" class="form-control" autocomplete="false" :required="!hasRecipient">
+ </div>
+
+ <div class="mb-3">
+ <label for="to-bcc" class="form-label">{{ $t("smtpBCC") }}</label>
+ <input id="to-bcc" v-model="$parent.notification.smtpBCC" type="text" class="form-control" autocomplete="false" :required="!hasRecipient">
+ </div>
+
+ <p class="form-text">
+ <i18n-t tag="div" keypath="smtpLiquidIntroduction" class="form-text mb-3">
+ <a href="https://liquidjs.com/" target="_blank">{{ $t("documentation") }}</a>
+ </i18n-t>
+ <code v-pre>{{name}}</code>: {{ $t("emailTemplateServiceName") }}<br />
+ <code v-pre>{{msg}}</code>: {{ $t("emailTemplateMsg") }}<br />
+ <code v-pre>{{status}}</code>: {{ $t("emailTemplateStatus") }}<br />
+ <code v-pre>{{heartbeatJSON}}</code>: {{ $t("emailTemplateHeartbeatJSON") }}<b>{{ $t("emailTemplateLimitedToUpDownNotification") }}</b><br />
+ <code v-pre>{{monitorJSON}}</code>: {{ $t("emailTemplateMonitorJSON") }} <b>{{ $t("emailTemplateLimitedToUpDownNotification") }}</b><br />
+ <code v-pre>{{hostnameOrURL}}</code>: {{ $t("emailTemplateHostnameOrURL") }}<br />
+ </p>
+ <div class="mb-3">
+ <label for="subject-email" class="form-label">{{ $t("emailCustomSubject") }}</label>
+ <input id="subject-email" v-model="$parent.notification.customSubject" type="text" class="form-control" autocomplete="false" placeholder="">
+ <div class="form-text">{{ $t("leave blank for default subject") }}</div>
+ </div>
+ <div class="mb-3">
+ <label for="body-email" class="form-label">{{ $t("emailCustomBody") }}</label>
+ <textarea id="body-email" v-model="$parent.notification.customBody" type="text" class="form-control" autocomplete="false" placeholder=""></textarea>
+ <div class="form-text">{{ $t("leave blank for default body") }}</div>
+ </div>
+
+ <ToggleSection :heading="$t('smtpDkimSettings')">
+ <i18n-t tag="div" keypath="smtpDkimDesc" class="form-text mb-3">
+ <a href="https://nodemailer.com/dkim/" target="_blank">{{ $t("documentation") }}</a>
+ </i18n-t>
+
+ <div class="mb-3">
+ <label for="dkim-domain" class="form-label">{{ $t("smtpDkimDomain") }}</label>
+ <input id="dkim-domain" v-model="$parent.notification.smtpDkimDomain" type="text" class="form-control" autocomplete="false" placeholder="example.com">
+ </div>
+ <div class="mb-3">
+ <label for="dkim-key-selector" class="form-label">{{ $t("smtpDkimKeySelector") }}</label>
+ <input id="dkim-key-selector" v-model="$parent.notification.smtpDkimKeySelector" type="text" class="form-control" autocomplete="false" placeholder="2017">
+ </div>
+ <div class="mb-3">
+ <label for="dkim-private-key" class="form-label">{{ $t("smtpDkimPrivateKey") }}</label>
+ <textarea id="dkim-private-key" v-model="$parent.notification.smtpDkimPrivateKey" rows="5" type="text" class="form-control" autocomplete="false" placeholder="-----BEGIN PRIVATE KEY-----"></textarea>
+ </div>
+ <div class="mb-3">
+ <label for="dkim-hash-algo" class="form-label">{{ $t("smtpDkimHashAlgo") }}</label>
+ <input id="dkim-hash-algo" v-model="$parent.notification.smtpDkimHashAlgo" type="text" class="form-control" autocomplete="false" placeholder="sha256">
+ </div>
+ <div class="mb-3">
+ <label for="dkim-header-fields" class="form-label">{{ $t("smtpDkimheaderFieldNames") }}</label>
+ <input id="dkim-header-fields" v-model="$parent.notification.smtpDkimheaderFieldNames" type="text" class="form-control" autocomplete="false" placeholder="message-id:date:from:to">
+ </div>
+ <div class="mb-3">
+ <label for="dkim-skip-fields" class="form-label">{{ $t("smtpDkimskipFields") }}</label>
+ <input id="dkim-skip-fields" v-model="$parent.notification.smtpDkimskipFields" type="text" class="form-control" autocomplete="false" placeholder="message-id:date">
+ </div>
+ </ToggleSection>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+import ToggleSection from "../ToggleSection.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ ToggleSection,
+ },
+ computed: {
+ hasRecipient() {
+ if (this.$parent.notification.smtpTo || this.$parent.notification.smtpCC || this.$parent.notification.smtpBCC) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+ },
+ mounted() {
+ if (typeof this.$parent.notification.smtpSecure === "undefined") {
+ this.$parent.notification.smtpSecure = false;
+ }
+ }
+};
+</script>
diff --git a/src/components/notifications/SendGrid.vue b/src/components/notifications/SendGrid.vue
new file mode 100644
index 0000000..18118f4
--- /dev/null
+++ b/src/components/notifications/SendGrid.vue
@@ -0,0 +1,47 @@
+<template>
+ <div class="mb-3">
+ <label for="sendgrid-api-key" class="form-label">{{ $t("SendGrid API Key") }}</label>
+ <HiddenInput id="push-api-key" v-model="$parent.notification.sendgridApiKey" :required="true" autocomplete="new-password"></HiddenInput>
+ </div>
+ <div class="mb-3">
+ <label for="sendgrid-from-email" class="form-label">{{ $t("From Email") }}</label>
+ <input id="sendgrid-from-email" v-model="$parent.notification.sendgridFromEmail" type="email" class="form-control" required>
+ </div>
+ <div class="mb-3">
+ <label for="sendgrid-to-email" class="form-label">{{ $t("To Email") }}</label>
+ <input id="sendgrid-to-email" v-model="$parent.notification.sendgridToEmail" type="email" class="form-control" required>
+ </div>
+ <div class="mb-3">
+ <label for="sendgrid-cc-email" class="form-label">{{ $t("smtpCC") }}</label>
+ <input id="sendgrid-cc-email" v-model="$parent.notification.sendgridCcEmail" type="email" class="form-control">
+ <div class="form-text">{{ $t("Separate multiple email addresses with commas") }}</div>
+ </div>
+ <div class="mb-3">
+ <label for="sendgrid-bcc-email" class="form-label">{{ $t("smtpBCC") }}</label>
+ <input id="sendgrid-bcc-email" v-model="$parent.notification.sendgridBccEmail" type="email" class="form-control">
+ <small class="form-text text-muted">{{ $t("Separate multiple email addresses with commas") }}</small>
+ </div>
+ <div class="mb-3">
+ <label for="sendgrid-subject" class="form-label">{{ $t("Subject:") }}</label>
+ <input id="sendgrid-subject" v-model="$parent.notification.sendgridSubject" type="text" class="form-control">
+ <small class="form-text text-muted">{{ $t("leave blank for default subject") }}</small>
+ </div>
+ <i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
+ <a href="https://docs.sendgrid.com/api-reference/mail-send/mail-send" target="_blank">https://docs.sendgrid.com/api-reference/mail-send/mail-send</a>
+ </i18n-t>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+ mounted() {
+ if (typeof this.$parent.notification.sendgridSubject === "undefined") {
+ this.$parent.notification.sendgridSubject = "Notification from Your Uptime Kuma";
+ }
+ },
+};
+</script>
diff --git a/src/components/notifications/ServerChan.vue b/src/components/notifications/ServerChan.vue
new file mode 100644
index 0000000..c7476c2
--- /dev/null
+++ b/src/components/notifications/ServerChan.vue
@@ -0,0 +1,16 @@
+<template>
+ <div class="mb-3">
+ <label for="serverchan-sendkey" class="form-label">{{ $t("SendKey") }}</label>
+ <HiddenInput id="serverchan-sendkey" v-model="$parent.notification.serverChanSendKey" :required="true" autocomplete="new-password"></HiddenInput>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/SerwerSMS.vue b/src/components/notifications/SerwerSMS.vue
new file mode 100644
index 0000000..32a0ff7
--- /dev/null
+++ b/src/components/notifications/SerwerSMS.vue
@@ -0,0 +1,28 @@
+<template>
+ <div class="mb-3">
+ <label for="serwersms-username" class="form-label">{{ $t('serwersmsAPIUser') }}</label>
+ <input id="serwersms-username" v-model="$parent.notification.serwersmsUsername" type="text" class="form-control" required>
+ </div>
+ <div class="mb-3">
+ <label for="serwersms-key" class="form-label">{{ $t('serwersmsAPIPassword') }}</label>
+ <HiddenInput id="serwersms-key" v-model="$parent.notification.serwersmsPassword" :required="true" autocomplete="new-password"></HiddenInput>
+ </div>
+ <div class="mb-3">
+ <label for="serwersms-phone-number" class="form-label">{{ $t("serwersmsPhoneNumber") }}</label>
+ <input id="serwersms-phone-number" v-model="$parent.notification.serwersmsPhoneNumber" type="text" class="form-control" required>
+ </div>
+ <div class="mb-3">
+ <label for="serwersms-sender-name" class="form-label">{{ $t("serwersmsSenderName") }}</label>
+ <input id="serwersms-sender-name" v-model="$parent.notification.serwersmsSenderName" type="text" minlength="3" maxlength="11" class="form-control">
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/SevenIO.vue b/src/components/notifications/SevenIO.vue
new file mode 100644
index 0000000..fcf746e
--- /dev/null
+++ b/src/components/notifications/SevenIO.vue
@@ -0,0 +1,31 @@
+<template>
+ <div class="mb-3">
+ <label for="sevenio-api-key" class="form-label">{{ $t("apiKeySevenIO") }}</label>
+ <HiddenInput id="sevenio-api-key" v-model="$parent.notification.sevenioApiKey" :required="true" autocomplete="new-password"></HiddenInput>
+ <div class="form-text">
+ {{ $t("wayToGetSevenIOApiKey") }}
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <label for="sevenio-sender" class="form-label">{{ $t("senderSevenIO") }}</label>
+ <input id="sevenio-sender" v-model="$parent.notification.sevenioSender" type="text" class="form-control" autocomplete="false" placeholder="Uptime Kuma">
+ </div>
+
+ <div class="mb-3">
+ <label for="sevenio-receiver" class="form-label">{{ $t("receiverSevenIO") }}</label>
+ <input id="sevenio-receiver" v-model="$parent.notification.sevenioReceiver" type="number" class="form-control" required autocomplete="false" placeholder="0123456789">
+ <div class="form-text">
+ {{ $t("receiverInfoSevenIO") }}
+ </div>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/Signal.vue b/src/components/notifications/Signal.vue
new file mode 100644
index 0000000..e3c9e7a
--- /dev/null
+++ b/src/components/notifications/Signal.vue
@@ -0,0 +1,34 @@
+<template>
+ <div class="mb-3">
+ <label for="signal-url" class="form-label">{{ $t("Post URL") }}</label>
+ <input id="signal-url" v-model="$parent.notification.signalURL" type="url" pattern="https?://.+" class="form-control" required>
+ </div>
+
+ <div class="mb-3">
+ <label for="signal-number" class="form-label">{{ $t("Number") }}</label>
+ <input id="signal-number" v-model="$parent.notification.signalNumber" type="text" class="form-control" required>
+ </div>
+
+ <div class="mb-3">
+ <label for="signal-recipients" class="form-label">{{ $t("Recipients") }}</label>
+ <input id="signal-recipients" v-model="$parent.notification.signalRecipients" type="text" class="form-control" required>
+
+ <div class="form-text">
+ <p style="margin-top: 8px;">
+ {{ $t("needSignalAPI") }}
+ </p>
+
+ <p style="margin-top: 8px;">
+ {{ $t("wayToCheckSignalURL") }}
+ </p>
+
+ <p style="margin-top: 8px;">
+ <a href="https://github.com/bbernhard/signal-cli-rest-api" target="_blank">https://github.com/bbernhard/signal-cli-rest-api</a>
+ </p>
+
+ <p style="margin-top: 8px;">
+ {{ $t("signalImportant") }}
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/src/components/notifications/Slack.vue b/src/components/notifications/Slack.vue
new file mode 100644
index 0000000..9fa9f34
--- /dev/null
+++ b/src/components/notifications/Slack.vue
@@ -0,0 +1,45 @@
+<template>
+ <div class="mb-3">
+ <label for="slack-webhook-url" class="form-label">{{ $t("Webhook URL") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="slack-webhook-url" v-model="$parent.notification.slackwebhookURL" type="text" class="form-control" required>
+ <label for="slack-username" class="form-label">{{ $t("Username") }}</label>
+ <input id="slack-username" v-model="$parent.notification.slackusername" type="text" class="form-control">
+ <div class="form-text">
+ {{ $t("aboutSlackUsername") }}
+ </div>
+ <label for="slack-iconemo" class="form-label">{{ $t("Icon Emoji") }}</label>
+ <input id="slack-iconemo" v-model="$parent.notification.slackiconemo" type="text" class="form-control">
+ <label for="slack-channel" class="form-label">{{ $t("Channel Name") }}</label>
+ <input id="slack-channel-name" v-model="$parent.notification.slackchannel" type="text" class="form-control">
+
+ <label class="form-label">{{ $t("Message format") }}</label>
+ <div class="form-check form-switch">
+ <input id="slack-text-message" v-model="$parent.notification.slackrichmessage" type="checkbox" class="form-check-input">
+ <label for="slack-text-message" class="form-label">{{ $t("Send rich messages") }}</label>
+ </div>
+
+ <div class="form-text">
+ <span style="color: red;"><sup>*</sup></span>{{ $t("Required") }}
+ <i18n-t tag="p" keypath="aboutWebhooks" style="margin-top: 8px;">
+ <a href="https://api.slack.com/messaging/webhooks" target="_blank">https://api.slack.com/messaging/webhooks</a>
+ </i18n-t>
+ <p style="margin-top: 8px;">
+ {{ $t("aboutChannelName", [$t("slack")]) }}
+ </p>
+ <p style="margin-top: 8px;">
+ {{ $t("aboutKumaURL") }}
+ </p>
+ <i18n-t tag="p" keypath="emojiCheatSheet" style="margin-top: 8px;">
+ <a href="https://www.webfx.com/tools/emoji-cheat-sheet/" target="_blank">https://www.webfx.com/tools/emoji-cheat-sheet/</a>
+ </i18n-t>
+ </div>
+
+ <div class="form-check form-switch">
+ <input id="slack-channel-notify" v-model="$parent.notification.slackchannelnotify" type="checkbox" class="form-check-input">
+ <label for="slack-channel-notify" class="form-label">{{ $t("Notify Channel") }}</label>
+ </div>
+ <div class="form-text">
+ {{ $t("aboutNotifyChannel") }}
+ </div>
+ </div>
+</template>
diff --git a/src/components/notifications/Splunk.vue b/src/components/notifications/Splunk.vue
new file mode 100644
index 0000000..8644851
--- /dev/null
+++ b/src/components/notifications/Splunk.vue
@@ -0,0 +1,32 @@
+<template>
+ <div class="mb-3">
+ <label for="splunk-rest-url" class="form-label">{{ $t("Splunk Rest URL") }}</label>
+ <HiddenInput id="splunk-rest-url" v-model="$parent.notification.splunkRestURL" :required="true" autocomplete="false"></HiddenInput>
+ </div>
+ <div class="mb-3">
+ <label for="splunk-severity" class="form-label">{{ $t("Severity") }}</label>
+ <select id="splunk-severity" v-model="$parent.notification.splunkSeverity" class="form-select">
+ <option value="INFO">{{ $t("info") }}</option>
+ <option value="WARNING">{{ $t("warning") }}</option>
+ <option value="CRITICAL" selected="selected">{{ $t("critical") }}</option>
+ </select>
+ </div>
+ <div class="mb-3">
+ <label for="splunk-resolve" class="form-label">{{ $t("Auto resolve or acknowledged") }}</label>
+ <select id="splunk-resolve" v-model="$parent.notification.splunkAutoResolve" class="form-select">
+ <option value="0" selected="selected">{{ $t("do nothing") }}</option>
+ <option value="ACKNOWLEDGEMENT">{{ $t("auto acknowledged") }}</option>
+ <option value="RECOVERY">{{ $t("auto resolve") }}</option>
+ </select>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/Squadcast.vue b/src/components/notifications/Squadcast.vue
new file mode 100644
index 0000000..6650c44
--- /dev/null
+++ b/src/components/notifications/Squadcast.vue
@@ -0,0 +1,6 @@
+<template>
+ <div class="mb-3">
+ <label for="webhook-url" class="form-label">{{ $t("Post URL") }}</label>
+ <input id="webhook-url" v-model="$parent.notification.squadcastWebhookURL" type="url" pattern="https?://.+" class="form-control" required>
+ </div>
+</template>
diff --git a/src/components/notifications/Stackfield.vue b/src/components/notifications/Stackfield.vue
new file mode 100644
index 0000000..c8dfb72
--- /dev/null
+++ b/src/components/notifications/Stackfield.vue
@@ -0,0 +1,13 @@
+<template>
+ <div class="mb-3">
+ <label for="stackfield-webhook-url" class="form-label">{{ $t("Webhook URL") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="stackfield-webhook-url" v-model="$parent.notification.stackfieldwebhookURL" type="text" class="form-control" required>
+
+ <div class="form-text">
+ <span style="color: red;"><sup>*</sup></span>{{ $t("Required") }}
+ <i18n-t tag="p" keypath="aboutWebhooks" style="margin-top: 8px;">
+ <a href="https://www.stackfield.com/developer-api#AnchorAPI2" target="_blank">https://www.stackfield.com/developer-api#AnchorAPI2</a>
+ </i18n-t>
+ </div>
+ </div>
+</template>
diff --git a/src/components/notifications/Teams.vue b/src/components/notifications/Teams.vue
new file mode 100644
index 0000000..2bc7648
--- /dev/null
+++ b/src/components/notifications/Teams.vue
@@ -0,0 +1,18 @@
+<template>
+ <div class="mb-3">
+ <label for="teams-webhookurl" class="form-label">{{ $t("Webhook URL") }}</label>
+ <input
+ id="teams-webhookurl"
+ v-model="$parent.notification.webhookUrl"
+ type="text"
+ class="form-control"
+ required
+ />
+ <i18n-t tag="div" keypath="wayToGetTeamsURL" class="form-text">
+ <a
+ href="https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook"
+ target="_blank"
+ >{{ $t("here") }}</a>
+ </i18n-t>
+ </div>
+</template>
diff --git a/src/components/notifications/TechulusPush.vue b/src/components/notifications/TechulusPush.vue
new file mode 100644
index 0000000..bb2c27a
--- /dev/null
+++ b/src/components/notifications/TechulusPush.vue
@@ -0,0 +1,81 @@
+<template>
+ <div class="mb-3">
+ <label for="push-api-key" class="form-label">{{ $t("API Key") }}</label>
+ <HiddenInput id="push-api-key" v-model="$parent.notification.pushAPIKey" :required="true" autocomplete="new-password"></HiddenInput>
+ </div>
+
+ <div class="mb-3">
+ <label for="push-api-title" class="form-label">{{ $t("Title") }}</label>
+ <input id="push-api-title" v-model="$parent.notification.pushTitle" type="text" class="form-control">
+ </div>
+
+ <div class="mb-3">
+ <label for="push-api-channel" class="form-label">{{ $t("Notification Channel") }}</label>
+ <input id="push-api-channel" v-model="$parent.notification.pushChannel" type="text" class="form-control" patttern="[A-Za-z0-9-]+">
+ <div class="form-text">
+ {{ $t("Alphanumerical string and hyphens only") }}
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <label for="push-api-sound" class="form-label">{{ $t("Sound") }}</label>
+ <select id="push-api-sound" v-model="$parent.notification.pushSound" class="form-select">
+ <option value="default">{{ $t("Default") }}</option>
+ <option value="arcade">{{ $t("Arcade") }}</option>
+ <option value="correct">{{ $t("Correct") }}</option>
+ <option value="fail">{{ $t("Fail") }}</option>
+ <option value="harp">{{ $t("Harp") }}</option>
+ <option value="reveal">{{ $t("Reveal") }}</option>
+ <option value="bubble">{{ $t("Bubble") }}</option>
+ <option value="doorbell">{{ $t("Doorbell") }}</option>
+ <option value="flute">{{ $t("Flute") }}</option>
+ <option value="money">{{ $t("Money") }}</option>
+ <option value="scifi">{{ $t("Scifi") }}</option>
+ <option value="clear">{{ $t("Clear") }}</option>
+ <option value="elevator">{{ $t("Elevator") }}</option>
+ <option value="guitar">{{ $t("Guitar") }}</option>
+ <option value="pop">{{ $t("Pop") }}</option>
+ </select>
+ <div class="form-text">
+ {{ $t("Custom sound to override default notification sound") }}
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <div class="form-check form-switch">
+ <input v-model="$parent.notification.pushTimeSensitive" class="form-check-input" type="checkbox">
+ <label class="form-check-label">{{ $t("Time Sensitive (iOS Only)") }}</label>
+ </div>
+ <div class="form-text">
+ {{ $t("Time sensitive notifications will be delivered immediately, even if the device is in do not disturb mode.") }}
+ </div>
+ </div>
+
+ <i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
+ <a href="https://docs.push.techulus.com" target="_blank">https://docs.push.techulus.com</a>
+ </i18n-t>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+ mounted() {
+ if (typeof this.$parent.notification.pushTitle === "undefined") {
+ this.$parent.notification.pushTitle = "Uptime-Kuma";
+ }
+ if (typeof this.$parent.notification.pushChannel === "undefined") {
+ this.$parent.notification.pushChannel = "uptime-kuma";
+ }
+ if (typeof this.$parent.notification.pushSound === "undefined") {
+ this.$parent.notification.pushSound = "default";
+ }
+ if (typeof this.$parent.notification.pushTimeSensitive === "undefined") {
+ this.$parent.notification.pushTimeSensitive = true;
+ }
+ },
+};
+</script>
diff --git a/src/components/notifications/Telegram.vue b/src/components/notifications/Telegram.vue
new file mode 100644
index 0000000..a072c3e
--- /dev/null
+++ b/src/components/notifications/Telegram.vue
@@ -0,0 +1,117 @@
+<template>
+ <div class="mb-3">
+ <label for="telegram-bot-token" class="form-label">{{ $t("Bot Token") }}</label>
+ <HiddenInput id="telegram-bot-token" v-model="$parent.notification.telegramBotToken" :required="true" autocomplete="new-password"></HiddenInput>
+ <i18n-t tag="div" keypath="wayToGetTelegramToken" class="form-text">
+ <a href="https://t.me/BotFather" target="_blank">https://t.me/BotFather</a>
+ </i18n-t>
+ </div>
+
+ <div class="mb-3">
+ <label for="telegram-chat-id" class="form-label">{{ $t("Chat ID") }}</label>
+
+ <div class="input-group mb-3">
+ <input id="telegram-chat-id" v-model="$parent.notification.telegramChatID" type="text" class="form-control" required>
+ <button v-if="$parent.notification.telegramBotToken" class="btn btn-outline-secondary" type="button" @click="autoGetTelegramChatID">
+ {{ $t("Auto Get") }}
+ </button>
+ </div>
+
+ <div class="form-text">
+ {{ $t("supportTelegramChatID") }}
+
+ <p style="margin-top: 8px;">
+ {{ $t("wayToGetTelegramChatID") }}
+ </p>
+
+ <p style="margin-top: 8px;">
+ <a :href="telegramGetUpdatesURL('withToken')" target="_blank" style="word-break: break-word;">{{ telegramGetUpdatesURL("masked") }}</a>
+ </p>
+ </div>
+
+ <label for="message_thread_id" class="form-label">{{ $t("telegramMessageThreadID") }}</label>
+ <input id="message_thread_id" v-model="$parent.notification.telegramMessageThreadID" type="text" class="form-control">
+ <p class="form-text">{{ $t("telegramMessageThreadIDDescription") }}</p>
+
+ <div class="form-check form-switch">
+ <input v-model="$parent.notification.telegramSendSilently" class="form-check-input" type="checkbox">
+ <label class="form-check-label">{{ $t("telegramSendSilently") }}</label>
+ </div>
+
+ <div class="form-text">
+ {{ $t("telegramSendSilentlyDescription") }}
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <div class="form-check form-switch">
+ <input v-model="$parent.notification.telegramProtectContent" class="form-check-input" type="checkbox">
+ <label class="form-check-label">{{ $t("telegramProtectContent") }}</label>
+ </div>
+
+ <div class="form-text">
+ {{ $t("telegramProtectContentDescription") }}
+ </div>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+import axios from "axios";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+ methods: {
+ /**
+ * Get the URL for telegram updates
+ * @param {string} mode Should the token be masked?
+ * @returns {string} formatted URL
+ */
+ telegramGetUpdatesURL(mode = "masked") {
+ let token = `<${this.$t("YOUR BOT TOKEN HERE")}>`;
+
+ if (this.$parent.notification.telegramBotToken) {
+ if (mode === "withToken") {
+ token = this.$parent.notification.telegramBotToken;
+ } else if (mode === "masked") {
+ token = "*".repeat(this.$parent.notification.telegramBotToken.length);
+ }
+ }
+
+ return `https://api.telegram.org/bot${token}/getUpdates`;
+ },
+
+ /**
+ * Get the telegram chat ID
+ * @returns {Promise<void>}
+ * @throws The chat ID could not be found
+ */
+ async autoGetTelegramChatID() {
+ try {
+ let res = await axios.get(this.telegramGetUpdatesURL("withToken"));
+
+ if (res.data.result.length >= 1) {
+ let update = res.data.result[res.data.result.length - 1];
+
+ if (update.channel_post) {
+ this.$parent.notification.telegramChatID = update.channel_post.chat.id;
+ } else if (update.message) {
+ this.$parent.notification.telegramChatID = update.message.chat.id;
+ } else {
+ throw new Error(this.$t("chatIDNotFound"));
+ }
+
+ } else {
+ throw new Error(this.$t("chatIDNotFound"));
+ }
+
+ } catch (error) {
+ this.$root.toastError(error.message);
+ }
+
+ },
+ }
+};
+</script>
diff --git a/src/components/notifications/Threema.vue b/src/components/notifications/Threema.vue
new file mode 100644
index 0000000..9e46459
--- /dev/null
+++ b/src/components/notifications/Threema.vue
@@ -0,0 +1,87 @@
+<template>
+ <div class="mb-3">
+ <label class="form-label" for="threema-recipient">{{ $t("threemaRecipientType") }}</label>
+ <select
+ id="threema-recipient" v-model="$parent.notification.threemaRecipientType" required
+ class="form-select"
+ >
+ <option value="identity">{{ $t("threemaRecipientTypeIdentity") }}</option>
+ <option value="phone">{{ $t("threemaRecipientTypePhone") }}</option>
+ <option value="email">{{ $t("threemaRecipientTypeEmail") }}</option>
+ </select>
+ </div>
+ <div v-if="$parent.notification.threemaRecipientType === 'identity'" class="mb-3">
+ <label class="form-label" for="threema-recipient">{{ $t("threemaRecipient") }} {{ $t("threemaRecipientTypeIdentity") }}</label>
+ <input
+ id="threema-recipient"
+ v-model="$parent.notification.threemaRecipient"
+ class="form-control"
+ minlength="8"
+ maxlength="8"
+ pattern="[A-Z0-9]{8}"
+ required
+ type="text"
+ >
+ <div class="form-text">
+ <p>{{ $t("threemaRecipientTypeIdentityFormat") }}</p>
+ </div>
+ </div>
+ <div v-else-if="$parent.notification.threemaRecipientType === 'phone'" class="mb-3">
+ <label class="form-label" for="threema-recipient">{{ $t("threemaRecipient") }} {{ $t("threemaRecipientTypePhone") }}</label>
+ <input
+ id="threema-recipient"
+ v-model="$parent.notification.threemaRecipient"
+ class="form-control"
+ maxlength="15"
+ pattern="\d{1,15}"
+ required
+ type="text"
+ >
+ <div class="form-text">
+ <p>{{ $t("threemaRecipientTypePhoneFormat") }}</p>
+ </div>
+ </div>
+ <div v-else-if="$parent.notification.threemaRecipientType === 'email'" class="mb-3">
+ <label class="form-label" for="threema-recipient">{{ $t("threemaRecipient") }} {{ $t("threemaRecipientTypeEmail") }}</label>
+ <input
+ id="threema-recipient"
+ v-model="$parent.notification.threemaRecipient"
+ class="form-control"
+ maxlength="254"
+ required
+ type="email"
+ >
+ </div>
+ <div class="mb-3">
+ <label class="form-label" for="threema-sender">{{ $t("threemaSenderIdentity") }}</label>
+ <input
+ id="threema-sender"
+ v-model="$parent.notification.threemaSenderIdentity"
+ class="form-control"
+ minlength="8"
+ maxlength="8"
+ pattern="^\*[A-Z0-9]{7}$"
+ required
+ type="text"
+ >
+ <div class="form-text">
+ <p>{{ $t("threemaSenderIdentityFormat") }}</p>
+ </div>
+ </div>
+ <div class="mb-3">
+ <label class="form-label" for="threema-secret">{{ $t("threemaApiAuthenticationSecret") }}</label>
+ <HiddenInput
+ id="threema-secret" v-model="$parent.notification.threemaSecret" required
+ autocomplete="false"
+ ></HiddenInput>
+ </div>
+ <i18n-t class="form-text" keypath="wayToGetThreemaGateway" tag="div">
+ <a href="https://threema.ch/en/gateway" target="_blank">{{ $t("here") }}</a>
+ </i18n-t>
+ <i18n-t class="form-text" keypath="threemaBasicModeInfo" tag="div">
+ <a href="https://gateway.threema.ch/en/developer/api" target="_blank">{{ $t("here") }}</a>
+ </i18n-t>
+</template>
+<script lang="ts" setup>
+import HiddenInput from "../HiddenInput.vue";
+</script>
diff --git a/src/components/notifications/Twilio.vue b/src/components/notifications/Twilio.vue
new file mode 100644
index 0000000..d923866
--- /dev/null
+++ b/src/components/notifications/Twilio.vue
@@ -0,0 +1,38 @@
+<template>
+ <div class="mb-3">
+ <label for="twilio-account-sid" class="form-label">{{ $t("Account SID") }}</label>
+ <input id="twilio-account-sid" v-model="$parent.notification.twilioAccountSID" type="text" class="form-control" required>
+ </div>
+
+ <div class="mb-3">
+ <label for="twilio-apikey-token" class="form-label">{{ $t("Api Key (optional)") }}</label>
+ <input id="twilio-apikey-token" v-model="$parent.notification.twilioApiKey" type="text" class="form-control">
+ <div class="form-text">
+ <p>
+ The API key is optional but recommended. You can provide either Account SID and AuthToken
+ from the may TwilioConsole page or Account SID and the pair of Api Key and Api Key secret
+ </p>
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <label for="twilio-auth-token" class="form-label">{{ $t("Auth Token / Api Key Secret") }}</label>
+ <input id="twilio-auth-token" v-model="$parent.notification.twilioAuthToken" type="text" class="form-control" required>
+ </div>
+
+ <div class="mb-3">
+ <label for="twilio-from-number" class="form-label">{{ $t("From Number") }}</label>
+ <input id="twilio-from-number" v-model="$parent.notification.twilioFromNumber" type="text" class="form-control" required>
+ </div>
+
+ <div class="mb-3">
+ <label for="twilio-to-number" class="form-label">{{ $t("To Number") }}</label>
+ <input id="twilio-to-number" v-model="$parent.notification.twilioToNumber" type="text" class="form-control" required>
+ </div>
+
+ <div class="mb-3">
+ <i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
+ <a href="https://www.twilio.com/docs/sms" target="_blank">https://www.twilio.com/docs/sms</a>
+ </i18n-t>
+ </div>
+</template>
diff --git a/src/components/notifications/WPush.vue b/src/components/notifications/WPush.vue
new file mode 100644
index 0000000..58499c4
--- /dev/null
+++ b/src/components/notifications/WPush.vue
@@ -0,0 +1,31 @@
+<template>
+ <div class="mb-3">
+ <label for="wpush-apikey" class="form-label">WPush {{ $t("API Key") }}</label>
+ <HiddenInput id="wpush-apikey" v-model="$parent.notification.wpushAPIkey" :required="true" autocomplete="new-password" placeholder="WPushxxxxx"></HiddenInput>
+ </div>
+
+ <div class="mb-3">
+ <label for="wpush-channel" class="form-label">发送通道</label>
+ <select id="wpush-channel" v-model="$parent.notification.wpushChannel" class="form-select" required>
+ <option value="wechat">微信</option>
+ <option value="sms">短信</option>
+ <option value="mail">邮件</option>
+ <option value="feishu">飞书</option>
+ <option value="dingtalk">钉钉</option>
+ <option value="wechat_work">企业微信</option>
+ </select>
+ </div>
+
+ <i18n-t tag="p" keypath="More info on:">
+ <a href="https://wpush.cn/" rel="noopener noreferrer" target="_blank">https://wpush.cn/</a>
+ </i18n-t>
+</template>
+
+<script>
+import HiddenInput from "../HiddenInput.vue";
+export default {
+ components: {
+ HiddenInput,
+ },
+};
+</script>
diff --git a/src/components/notifications/WeCom.vue b/src/components/notifications/WeCom.vue
new file mode 100644
index 0000000..cef3708
--- /dev/null
+++ b/src/components/notifications/WeCom.vue
@@ -0,0 +1,12 @@
+<template>
+ <div class="mb-3">
+ <label for="WeCom Bot Key" class="form-label">{{ $t("WeCom Bot Key") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="WeCom Bot Key" v-model="$parent.notification.weComBotKey" type="text" class="form-control" required>
+ <div class="form-text">
+ <p><span style="color: red;"><sup>*</sup></span>{{ $t("Required") }}</p>
+ </div>
+ <i18n-t tag="p" keypath="Read more:">
+ <a href="https://work.weixin.qq.com/api/doc/90000/90136/91770" target="_blank">https://work.weixin.qq.com/api/doc/90000/90136/91770</a>
+ </i18n-t>
+ </div>
+</template>
diff --git a/src/components/notifications/Webhook.vue b/src/components/notifications/Webhook.vue
new file mode 100644
index 0000000..8c67a27
--- /dev/null
+++ b/src/components/notifications/Webhook.vue
@@ -0,0 +1,100 @@
+<template>
+ <div class="mb-3">
+ <label for="webhook-url" class="form-label">{{ $t("Post URL") }}</label>
+ <input
+ id="webhook-url"
+ v-model="$parent.notification.webhookURL"
+ type="url"
+ pattern="https?://.+"
+ class="form-control"
+ required
+ />
+ </div>
+
+ <div class="mb-3">
+ <label for="webhook-request-body" class="form-label">{{ $t("Request Body") }}</label>
+ <select
+ id="webhook-request-body"
+ v-model="$parent.notification.webhookContentType"
+ class="form-select"
+ required
+ >
+ <option value="json">{{ $t("webhookBodyPresetOption", ["application/json"]) }}</option>
+ <option value="form-data">{{ $t("webhookBodyPresetOption", ["multipart/form-data"]) }}</option>
+ <option value="custom">{{ $t("webhookBodyCustomOption") }}</option>
+ </select>
+
+ <div v-if="$parent.notification.webhookContentType == 'json'" class="form-text">{{ $t("webhookJsonDesc", ['"application/json"']) }}</div>
+ <i18n-t v-else-if="$parent.notification.webhookContentType == 'form-data'" tag="div" keypath="webhookFormDataDesc" class="form-text">
+ <template #multipart>multipart/form-data"</template>
+ <template #decodeFunction>
+ <strong>json_decode($_POST['data'])</strong>
+ </template>
+ </i18n-t>
+ <template v-else-if="$parent.notification.webhookContentType == 'custom'">
+ <i18n-t tag="div" keypath="liquidIntroduction" class="form-text">
+ <a href="https://liquidjs.com/" target="_blank">{{ $t("documentation") }}</a>
+ </i18n-t>
+ <code v-pre>{{msg}}</code>: {{ $t("templateMsg") }}<br />
+ <code v-pre>{{heartbeatJSON}}</code>: {{ $t("templateHeartbeatJSON") }} <b>({{ $t("templateLimitedToUpDownNotifications") }})</b><br />
+ <code v-pre>{{monitorJSON}}</code>: {{ $t("templateMonitorJSON") }} <b>({{ $t("templateLimitedToUpDownCertNotifications") }})</b><br />
+
+ <textarea
+ id="customBody"
+ v-model="$parent.notification.webhookCustomBody"
+ class="form-control"
+ :placeholder="customBodyPlaceholder"
+ required
+ ></textarea>
+ </template>
+ </div>
+
+ <div class="mb-3">
+ <div class="form-check form-switch">
+ <input v-model="showAdditionalHeadersField" class="form-check-input" type="checkbox">
+ <label class="form-check-label">{{ $t("webhookAdditionalHeadersTitle") }}</label>
+ </div>
+ <div class="form-text">{{ $t("webhookAdditionalHeadersDesc") }}</div>
+ <textarea
+ v-if="showAdditionalHeadersField"
+ id="additionalHeaders"
+ v-model="$parent.notification.webhookAdditionalHeaders"
+ class="form-control"
+ :placeholder="headersPlaceholder"
+ :required="showAdditionalHeadersField"
+ ></textarea>
+ </div>
+</template>
+
+<script>
+export default {
+ data() {
+ return {
+ showAdditionalHeadersField: this.$parent.notification.webhookAdditionalHeaders != null,
+ };
+ },
+ computed: {
+ headersPlaceholder() {
+ return this.$t("Example:", [
+`{
+ "Authorization": "Authorization Token"
+}`,
+ ]);
+ },
+ customBodyPlaceholder() {
+ return this.$t("Example:", [
+`{
+ "Title": "Uptime Kuma Alert{% if monitorJSON %} - {{ monitorJSON['name'] }}{% endif %}",
+ "Body": "{{ msg }}"
+}`
+ ]);
+ }
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+textarea {
+ min-height: 200px;
+}
+</style>
diff --git a/src/components/notifications/Whapi.vue b/src/components/notifications/Whapi.vue
new file mode 100644
index 0000000..4c92ad2
--- /dev/null
+++ b/src/components/notifications/Whapi.vue
@@ -0,0 +1,33 @@
+<template>
+ <div class="mb-3">
+ <label for="whapi-api-url" class="form-label">{{ $t("API URL") }}</label>
+ <input id="whapi-api-url" v-model="$parent.notification.whapiApiUrl" placeholder="https://gate.whapi.cloud/" type="text" class="form-control">
+ </div>
+
+ <div class="mb-3">
+ <label for="whapi-auth-token" class="form-label">{{ $t("Token") }}</label>
+ <HiddenInput id="whapi-auth-token" v-model="$parent.notification.whapiAuthToken" :required="true" autocomplete="new-password"></HiddenInput>
+ <i18n-t tag="div" keypath="wayToGetWhapiUrlAndToken" class="form-text">
+ <a href="https://panel.whapi.cloud/dashboard" target="_blank">https://panel.whapi.cloud/dashboard</a>
+ </i18n-t>
+ </div>
+
+ <div class="mb-3">
+ <label for="whapi-recipient" class="form-label">{{ $t("whapiRecipient") }}</label>
+ <input id="whapi-recipient" v-model="$parent.notification.whapiRecipient" type="text" pattern="^[\d-]{10,31}(@[\w\.]{1,})?$" class="form-control" required>
+ <div class="form-text">{{ $t("wayToWriteWhapiRecipient", ["00117612345678", "00117612345678@s.whatsapp.net", "123456789012345678@g.us"]) }}</div>
+ </div>
+
+ <i18n-t tag="div" keypath="More info on:" class="mb-3 form-text">
+ <a href="https://whapi.cloud/" target="_blank">https://whapi.cloud/</a>
+ </i18n-t>
+</template>
+<script>
+import HiddenInput from "../HiddenInput.vue";
+
+export default {
+ components: {
+ HiddenInput,
+ }
+};
+</script>
diff --git a/src/components/notifications/ZohoCliq.vue b/src/components/notifications/ZohoCliq.vue
new file mode 100644
index 0000000..9a9cd73
--- /dev/null
+++ b/src/components/notifications/ZohoCliq.vue
@@ -0,0 +1,18 @@
+<template>
+ <div class="mb-3">
+ <label for="zcliq-webhookurl" class="form-label">{{ $t("Webhook URL") }}</label>
+ <input
+ id="zcliq-webhookurl"
+ v-model="$parent.notification.webhookUrl"
+ type="text"
+ class="form-control"
+ required
+ />
+ <i18n-t tag="div" keypath="wayToGetZohoCliqURL" class="form-text">
+ <a
+ href="https://www.zoho.com/cliq/help/platform/webhook-tokens.html"
+ target="_blank"
+ >{{ $t("here") }}</a>
+ </i18n-t>
+ </div>
+</template>
diff --git a/src/components/notifications/index.js b/src/components/notifications/index.js
new file mode 100644
index 0000000..efa2af5
--- /dev/null
+++ b/src/components/notifications/index.js
@@ -0,0 +1,147 @@
+import Alerta from "./Alerta.vue";
+import AlertNow from "./AlertNow.vue";
+import AliyunSMS from "./AliyunSms.vue";
+import Apprise from "./Apprise.vue";
+import Bark from "./Bark.vue";
+import Bitrix24 from "./Bitrix24.vue";
+import ClickSendSMS from "./ClickSendSMS.vue";
+import CallMeBot from "./CallMeBot.vue";
+import SMSC from "./SMSC.vue";
+import DingDing from "./DingDing.vue";
+import Discord from "./Discord.vue";
+import Elks from "./46elks.vue";
+import Feishu from "./Feishu.vue";
+import FreeMobile from "./FreeMobile.vue";
+import GoogleChat from "./GoogleChat.vue";
+import Gorush from "./Gorush.vue";
+import Gotify from "./Gotify.vue";
+import GrafanaOncall from "./GrafanaOncall.vue";
+import GtxMessaging from "./GtxMessaging.vue";
+import HomeAssistant from "./HomeAssistant.vue";
+import HeiiOnCall from "./HeiiOnCall.vue";
+import Keep from "./Keep.vue";
+import Kook from "./Kook.vue";
+import Line from "./Line.vue";
+import LineNotify from "./LineNotify.vue";
+import LunaSea from "./LunaSea.vue";
+import Matrix from "./Matrix.vue";
+import Mattermost from "./Mattermost.vue";
+import Nostr from "./Nostr.vue";
+import Ntfy from "./Ntfy.vue";
+import Octopush from "./Octopush.vue";
+import OneBot from "./OneBot.vue";
+import Onesender from "./Onesender.vue";
+import Opsgenie from "./Opsgenie.vue";
+import PagerDuty from "./PagerDuty.vue";
+import FlashDuty from "./FlashDuty.vue";
+import PagerTree from "./PagerTree.vue";
+import PromoSMS from "./PromoSMS.vue";
+import Pushbullet from "./Pushbullet.vue";
+import PushDeer from "./PushDeer.vue";
+import Pushover from "./Pushover.vue";
+import Pushy from "./Pushy.vue";
+import RocketChat from "./RocketChat.vue";
+import ServerChan from "./ServerChan.vue";
+import SerwerSMS from "./SerwerSMS.vue";
+import Signal from "./Signal.vue";
+import SMSManager from "./SMSManager.vue";
+import SMSPartner from "./SMSPartner.vue";
+import Slack from "./Slack.vue";
+import Squadcast from "./Squadcast.vue";
+import SMSEagle from "./SMSEagle.vue";
+import Stackfield from "./Stackfield.vue";
+import STMP from "./SMTP.vue";
+import Teams from "./Teams.vue";
+import TechulusPush from "./TechulusPush.vue";
+import Telegram from "./Telegram.vue";
+import Threema from "./Threema.vue";
+import Twilio from "./Twilio.vue";
+import Webhook from "./Webhook.vue";
+import WeCom from "./WeCom.vue";
+import GoAlert from "./GoAlert.vue";
+import ZohoCliq from "./ZohoCliq.vue";
+import Splunk from "./Splunk.vue";
+import SevenIO from "./SevenIO.vue";
+import Whapi from "./Whapi.vue";
+import Cellsynt from "./Cellsynt.vue";
+import WPush from "./WPush.vue";
+import SIGNL4 from "./SIGNL4.vue";
+import SendGrid from "./SendGrid.vue";
+
+/**
+ * Manage all notification form.
+ * @type { Record<string, any> }
+ */
+const NotificationFormList = {
+ "alerta": Alerta,
+ "AlertNow": AlertNow,
+ "AliyunSMS": AliyunSMS,
+ "apprise": Apprise,
+ "Bark": Bark,
+ "Bitrix24": Bitrix24,
+ "clicksendsms": ClickSendSMS,
+ "CallMeBot": CallMeBot,
+ "smsc": SMSC,
+ "DingDing": DingDing,
+ "discord": Discord,
+ "Elks": Elks,
+ "Feishu": Feishu,
+ "FreeMobile": FreeMobile,
+ "GoogleChat": GoogleChat,
+ "gorush": Gorush,
+ "gotify": Gotify,
+ "GrafanaOncall": GrafanaOncall,
+ "HomeAssistant": HomeAssistant,
+ "HeiiOnCall": HeiiOnCall,
+ "Keep": Keep,
+ "Kook": Kook,
+ "line": Line,
+ "LineNotify": LineNotify,
+ "lunasea": LunaSea,
+ "matrix": Matrix,
+ "mattermost": Mattermost,
+ "nostr": Nostr,
+ "ntfy": Ntfy,
+ "octopush": Octopush,
+ "OneBot": OneBot,
+ "Onesender": Onesender,
+ "Opsgenie": Opsgenie,
+ "PagerDuty": PagerDuty,
+ "FlashDuty": FlashDuty,
+ "PagerTree": PagerTree,
+ "promosms": PromoSMS,
+ "pushbullet": Pushbullet,
+ "PushByTechulus": TechulusPush,
+ "PushDeer": PushDeer,
+ "pushover": Pushover,
+ "pushy": Pushy,
+ "rocket.chat": RocketChat,
+ "serwersms": SerwerSMS,
+ "signal": Signal,
+ "SIGNL4": SIGNL4,
+ "SMSManager": SMSManager,
+ "SMSPartner": SMSPartner,
+ "slack": Slack,
+ "squadcast": Squadcast,
+ "SMSEagle": SMSEagle,
+ "smtp": STMP,
+ "stackfield": Stackfield,
+ "teams": Teams,
+ "telegram": Telegram,
+ "threema": Threema,
+ "twilio": Twilio,
+ "Splunk": Splunk,
+ "webhook": Webhook,
+ "WeCom": WeCom,
+ "GoAlert": GoAlert,
+ "ServerChan": ServerChan,
+ "ZohoCliq": ZohoCliq,
+ "SevenIO": SevenIO,
+ "whapi": Whapi,
+ "gtxmessaging": GtxMessaging,
+ "Cellsynt": Cellsynt,
+ "WPush": WPush,
+ "SendGrid": SendGrid,
+};
+
+export default NotificationFormList;
diff --git a/src/components/settings/APIKeys.vue b/src/components/settings/APIKeys.vue
new file mode 100644
index 0000000..d31645b
--- /dev/null
+++ b/src/components/settings/APIKeys.vue
@@ -0,0 +1,273 @@
+<template>
+ <div>
+ <div
+ v-if="settings.disableAuth"
+ class="mt-5 d-flex align-items-center justify-content-center my-3"
+ >
+ {{ $t("apiKeysDisabledMsg") }}
+ </div>
+ <div v-else>
+ <div class="add-btn">
+ <button class="btn btn-primary me-2" type="button" @click="$refs.apiKeyDialog.show()">
+ <font-awesome-icon icon="plus" /> {{ $t("Add API Key") }}
+ </button>
+ </div>
+
+ <div>
+ <span
+ v-if="Object.keys(keyList).length === 0"
+ class="d-flex align-items-center justify-content-center my-3"
+ >
+ {{ $t("No API Keys") }}
+ </span>
+
+ <div
+ v-for="(item, index) in keyList"
+ :key="index"
+ class="item"
+ :class="item.status"
+ >
+ <div class="left-part">
+ <div class="circle"></div>
+ <div class="info">
+ <div class="title">{{ item.name }}</div>
+ <div class="status">
+ {{ $t("apiKey-" + item.status) }}
+ </div>
+ <div class="date">
+ {{ $t("Created") }}: {{ item.createdDate }}
+ </div>
+ <div class="date">
+ {{ $t("Expires") }}:
+ {{ item.expires || $t("Never") }}
+ </div>
+ </div>
+ </div>
+
+ <div class="buttons">
+ <div class="btn-group" role="group">
+ <button v-if="item.active" class="btn btn-normal" @click="disableDialog(item.id)">
+ <font-awesome-icon icon="pause" /> {{ $t("Disable") }}
+ </button>
+
+ <button v-if="!item.active" class="btn btn-primary" @click="enableKey(item.id)">
+ <font-awesome-icon icon="play" /> {{ $t("Enable") }}
+ </button>
+
+ <button class="btn btn-danger" @click="deleteDialog(item.id)">
+ <font-awesome-icon icon="trash" /> {{ $t("Delete") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="text-center mt-3" style="font-size: 13px;">
+ <a href="https://github.com/louislam/uptime-kuma/wiki/API-Keys" target="_blank">{{ $t("Learn More") }}</a>
+ </div>
+
+ <Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="disableKey">
+ {{ $t("disableAPIKeyMsg") }}
+ </Confirm>
+
+ <Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteKey">
+ {{ $t("deleteAPIKeyMsg") }}
+ </Confirm>
+
+ <APIKeyDialog ref="apiKeyDialog" />
+ </div>
+</template>
+
+<script>
+import APIKeyDialog from "../../components/APIKeyDialog.vue";
+import Confirm from "../Confirm.vue";
+
+export default {
+ components: {
+ APIKeyDialog,
+ Confirm,
+ },
+ data() {
+ return {
+ selectedKeyID: null,
+ };
+ },
+ computed: {
+ keyList() {
+ let result = Object.values(this.$root.apiKeyList);
+ return result;
+ },
+ settings() {
+ return this.$parent.$parent.$parent.settings;
+ },
+ },
+
+ methods: {
+ /**
+ * Show dialog to confirm deletion
+ * @param {number} keyID ID of monitor that is being deleted
+ * @returns {void}
+ */
+ deleteDialog(keyID) {
+ this.selectedKeyID = keyID;
+ this.$refs.confirmDelete.show();
+ },
+
+ /**
+ * Delete a key
+ * @returns {void}
+ */
+ deleteKey() {
+ this.$root.deleteAPIKey(this.selectedKeyID, (res) => {
+ this.$root.toastRes(res);
+ });
+ },
+
+ /**
+ * Show dialog to confirm pause
+ * @param {number} keyID ID of key to pause
+ * @returns {void}
+ */
+ disableDialog(keyID) {
+ this.selectedKeyID = keyID;
+ this.$refs.confirmPause.show();
+ },
+
+ /**
+ * Pause API key
+ * @returns {void}
+ */
+ disableKey() {
+ this.$root
+ .getSocket()
+ .emit("disableAPIKey", this.selectedKeyID, (res) => {
+ this.$root.toastRes(res);
+ });
+ },
+
+ /**
+ * Resume API key
+ * @param {number} id Key to resume
+ * @returns {void}
+ */
+ enableKey(id) {
+ this.$root.getSocket().emit("enableAPIKey", id, (res) => {
+ this.$root.toastRes(res);
+ });
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../../assets/vars.scss";
+
+.mobile {
+ .item {
+ flex-direction: column;
+ align-items: flex-start;
+ margin-bottom: 20px;
+ }
+}
+
+.add-btn {
+ padding-top: 20px;
+ padding-bottom: 20px;
+}
+
+.item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ text-decoration: none;
+ border-radius: 10px;
+ transition: all ease-in-out 0.15s;
+ justify-content: space-between;
+ padding: 10px;
+ min-height: 90px;
+ margin-bottom: 5px;
+
+ &:hover {
+ background-color: $highlight-white;
+ }
+
+ &.active {
+ .circle {
+ background-color: $primary;
+ }
+ }
+
+ &.inactive {
+ .circle {
+ background-color: $danger;
+ }
+ }
+
+ &.expired {
+ .left-part {
+ opacity: 0.3;
+ }
+
+ .circle {
+ background-color: $dark-font-color;
+ }
+ }
+
+ .left-part {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+
+ .circle {
+ width: 25px;
+ height: 25px;
+ border-radius: 50rem;
+ }
+
+ .info {
+ .title {
+ font-weight: bold;
+ font-size: 20px;
+ }
+
+ .status {
+ font-size: 14px;
+ }
+ }
+ }
+
+ .buttons {
+ display: flex;
+ gap: 8px;
+ flex-direction: row-reverse;
+
+ .btn-group {
+ width: 310px;
+ }
+ }
+}
+
+.date {
+ margin-top: 5px;
+ display: block;
+ font-size: 14px;
+ background-color: rgba(255, 255, 255, 0.5);
+ border-radius: 20px;
+ padding: 0 10px;
+ width: fit-content;
+
+ .dark & {
+ color: white;
+ background-color: rgba(255, 255, 255, 0.1);
+ }
+}
+
+.dark {
+ .item {
+ &:hover {
+ background-color: $dark-bg2;
+ }
+ }
+}
+</style>
diff --git a/src/components/settings/About.vue b/src/components/settings/About.vue
new file mode 100644
index 0000000..3ef9e6d
--- /dev/null
+++ b/src/components/settings/About.vue
@@ -0,0 +1,66 @@
+<template>
+ <div class="d-flex justify-content-center align-items-center">
+ <div class="logo d-flex flex-column justify-content-center align-items-center">
+ <object class="my-4" width="200" height="200" data="/icon.svg" />
+ <div class="fs-4 fw-bold">Uptime Kuma</div>
+ <div>{{ $t("Version") }}: {{ $root.info.version }}</div>
+ <div class="frontend-version">{{ $t("Frontend Version") }}: {{ $root.frontendVersion }}</div>
+
+ <div v-if="!$root.isFrontendBackendVersionMatched" class="alert alert-warning mt-4" role="alert">
+ ⚠️ {{ $t("Frontend Version do not match backend version!") }}
+ </div>
+
+ <div class="my-3 update-link"><a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">{{ $t("Check Update On GitHub") }}</a></div>
+
+ <div class="mt-1">
+ <div class="form-check">
+ <label><input v-model="settings.checkUpdate" type="checkbox" @change="saveSettings()" /> {{ $t("Show update if available") }}</label>
+ </div>
+
+ <div class="form-check">
+ <label><input v-model="settings.checkBeta" type="checkbox" :disabled="!settings.checkUpdate" @change="saveSettings()" /> {{ $t("Also check beta release") }}</label>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+export default {
+ computed: {
+ settings() {
+ return this.$parent.$parent.$parent.settings;
+ },
+ saveSettings() {
+ return this.$parent.$parent.$parent.saveSettings;
+ },
+ settingsLoaded() {
+ return this.$parent.$parent.$parent.settingsLoaded;
+ },
+ },
+
+ watch: {
+
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+.logo {
+ margin: 4em 1em;
+}
+
+.update-link {
+ font-size: 0.8em;
+}
+
+.frontend-version {
+ font-size: 0.9em;
+ color: #cccccc;
+
+ .dark & {
+ color: #333333;
+ }
+}
+
+</style>
diff --git a/src/components/settings/Appearance.vue b/src/components/settings/Appearance.vue
new file mode 100644
index 0000000..a1391d6
--- /dev/null
+++ b/src/components/settings/Appearance.vue
@@ -0,0 +1,190 @@
+<template>
+ <div>
+ <div class="my-4">
+ <label for="language" class="form-label">
+ {{ $t("Language") }}
+ </label>
+ <select id="language" v-model="$root.language" class="form-select">
+ <option
+ v-for="(lang, i) in $i18n.availableLocales"
+ :key="`Lang${i}`"
+ :value="lang"
+ >
+ {{ $i18n.messages[lang].languageName }}
+ </option>
+ </select>
+ </div>
+ <div class="my-4">
+ <label for="timezone" class="form-label">{{ $t("Theme") }}</label>
+ <div>
+ <div
+ class="btn-group"
+ role="group"
+ aria-label="Basic checkbox toggle button group"
+ >
+ <input
+ id="btncheck1"
+ v-model="$root.userTheme"
+ type="radio"
+ class="btn-check"
+ name="theme"
+ autocomplete="off"
+ value="light"
+ />
+ <label class="btn btn-outline-primary" for="btncheck1">
+ {{ $t("Light") }}
+ </label>
+
+ <input
+ id="btncheck2"
+ v-model="$root.userTheme"
+ type="radio"
+ class="btn-check"
+ name="theme"
+ autocomplete="off"
+ value="dark"
+ />
+ <label class="btn btn-outline-primary" for="btncheck2">
+ {{ $t("Dark") }}
+ </label>
+
+ <input
+ id="btncheck3"
+ v-model="$root.userTheme"
+ type="radio"
+ class="btn-check"
+ name="theme"
+ autocomplete="off"
+ value="auto"
+ />
+ <label class="btn btn-outline-primary" for="btncheck3">
+ {{ $t("Auto") }}
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="my-4">
+ <label class="form-label">{{ $t("Theme - Heartbeat Bar") }}</label>
+ <div>
+ <div
+ class="btn-group"
+ role="group"
+ aria-label="Basic checkbox toggle button group"
+ >
+ <input
+ id="btncheck4"
+ v-model="$root.userHeartbeatBar"
+ type="radio"
+ class="btn-check"
+ name="heartbeatBarTheme"
+ autocomplete="off"
+ value="normal"
+ />
+ <label class="btn btn-outline-primary" for="btncheck4">
+ {{ $t("Normal") }}
+ </label>
+
+ <input
+ id="btncheck5"
+ v-model="$root.userHeartbeatBar"
+ type="radio"
+ class="btn-check"
+ name="heartbeatBarTheme"
+ autocomplete="off"
+ value="bottom"
+ />
+ <label class="btn btn-outline-primary" for="btncheck5">
+ {{ $t("Bottom") }}
+ </label>
+
+ <input
+ id="btncheck6"
+ v-model="$root.userHeartbeatBar"
+ type="radio"
+ class="btn-check"
+ name="heartbeatBarTheme"
+ autocomplete="off"
+ value="none"
+ />
+ <label class="btn btn-outline-primary" for="btncheck6">
+ {{ $t("None") }}
+ </label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Timeline -->
+ <div class="my-4">
+ <label class="form-label">{{ $t("styleElapsedTime") }}</label>
+ <div>
+ <div class="btn-group" role="group">
+ <input
+ id="styleElapsedTimeShowNoLine"
+ v-model="$root.styleElapsedTime"
+ type="radio"
+ class="btn-check"
+ name="styleElapsedTime"
+ autocomplete="off"
+ value="no-line"
+ />
+ <label class="btn btn-outline-primary" for="styleElapsedTimeShowNoLine">
+ {{ $t("styleElapsedTimeShowNoLine") }}
+ </label>
+
+ <input
+ id="styleElapsedTimeShowWithLine"
+ v-model="$root.styleElapsedTime"
+ type="radio"
+ class="btn-check"
+ name="styleElapsedTime"
+ autocomplete="off"
+ value="with-line"
+ />
+ <label class="btn btn-outline-primary" for="styleElapsedTimeShowWithLine">
+ {{ $t("styleElapsedTimeShowWithLine") }}
+ </label>
+
+ <input
+ id="styleElapsedTimeNone"
+ v-model="$root.styleElapsedTime"
+ type="radio"
+ class="btn-check"
+ name="styleElapsedTime"
+ autocomplete="off"
+ value="none"
+ />
+ <label class="btn btn-outline-primary" for="styleElapsedTimeNone">
+ {{ $t("None") }}
+ </label>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+export default {
+
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../../assets/vars.scss";
+
+.btn-check:active + .btn-outline-primary,
+.btn-check:checked + .btn-outline-primary,
+.btn-check:hover + .btn-outline-primary {
+ color: #fff;
+
+ .dark & {
+ color: #000;
+ }
+}
+
+.dark {
+ .list-group-item {
+ background-color: $dark-bg2;
+ color: $dark-font-color;
+ }
+}
+</style>
diff --git a/src/components/settings/Docker.vue b/src/components/settings/Docker.vue
new file mode 100644
index 0000000..c411c30
--- /dev/null
+++ b/src/components/settings/Docker.vue
@@ -0,0 +1,48 @@
+<template>
+ <div>
+ <div class="dockerHost-list my-4">
+ <p v-if="$root.dockerHostList.length === 0">
+ {{ $t("Not available, please setup.") }}
+ </p>
+
+ <ul class="list-group mb-3" style="border-radius: 1rem;">
+ <li v-for="(dockerHost, index) in $root.dockerHostList" :key="index" class="list-group-item">
+ {{ dockerHost.name }}<br>
+ <a href="#" @click="$refs.dockerHostDialog.show(dockerHost.id)">{{ $t("Edit") }}</a>
+ </li>
+ </ul>
+
+ <button class="btn btn-primary me-2" type="button" @click="$refs.dockerHostDialog.show()">
+ {{ $t("Setup Docker Host") }}
+ </button>
+ </div>
+
+ <DockerHostDialog ref="dockerHostDialog" />
+ </div>
+</template>
+
+<script>
+import DockerHostDialog from "../../components/DockerHostDialog.vue";
+
+export default {
+ components: {
+ DockerHostDialog,
+ },
+
+ data() {
+ return {};
+ },
+
+ computed: {
+ settings() {
+ return this.$parent.$parent.$parent.settings;
+ },
+ saveSettings() {
+ return this.$parent.$parent.$parent.saveSettings;
+ },
+ settingsLoaded() {
+ return this.$parent.$parent.$parent.settingsLoaded;
+ },
+ }
+};
+</script>
diff --git a/src/components/settings/General.vue b/src/components/settings/General.vue
new file mode 100644
index 0000000..487c3ba
--- /dev/null
+++ b/src/components/settings/General.vue
@@ -0,0 +1,283 @@
+<template>
+ <div>
+ <form class="my-4" autocomplete="off" @submit.prevent="saveGeneral">
+ <!-- Client side Timezone -->
+ <div class="mb-4">
+ <label for="timezone" class="form-label">
+ {{ $t("Display Timezone") }}
+ </label>
+ <select id="timezone" v-model="$root.userTimezone" class="form-select">
+ <option value="auto">
+ {{ $t("Auto") }}: {{ guessTimezone }}
+ </option>
+ <option
+ v-for="(timezone, index) in timezoneList"
+ :key="index"
+ :value="timezone.value"
+ >
+ {{ timezone.name }}
+ </option>
+ </select>
+ </div>
+
+ <!-- Server Timezone -->
+ <div class="mb-4">
+ <label for="timezone" class="form-label">
+ {{ $t("Server Timezone") }}
+ </label>
+ <select id="timezone" v-model="settings.serverTimezone" class="form-select">
+ <option value="UTC">UTC</option>
+ <option
+ v-for="(timezone, index) in timezoneList"
+ :key="index"
+ :value="timezone.value"
+ >
+ {{ timezone.name }}
+ </option>
+ </select>
+ </div>
+
+ <!-- Search Engine -->
+ <div class="mb-4">
+ <label class="form-label">
+ {{ $t("Search Engine Visibility") }}
+ </label>
+
+ <div class="form-check">
+ <input
+ id="searchEngineIndexYes"
+ v-model="settings.searchEngineIndex"
+ class="form-check-input"
+ type="radio"
+ name="searchEngineIndex"
+ :value="true"
+ required
+ />
+ <label class="form-check-label" for="searchEngineIndexYes">
+ {{ $t("Allow indexing") }}
+ </label>
+ </div>
+ <div class="form-check">
+ <input
+ id="searchEngineIndexNo"
+ v-model="settings.searchEngineIndex"
+ class="form-check-input"
+ type="radio"
+ name="searchEngineIndex"
+ :value="false"
+ required
+ />
+ <label class="form-check-label" for="searchEngineIndexNo">
+ {{ $t("Discourage search engines from indexing site") }}
+ </label>
+ </div>
+ </div>
+
+ <!-- Entry Page -->
+ <div class="mb-4">
+ <label class="form-label">{{ $t("Entry Page") }}</label>
+
+ <div class="form-check">
+ <input
+ id="entryPageDashboard"
+ v-model="settings.entryPage"
+ class="form-check-input"
+ type="radio"
+ name="entryPage"
+ value="dashboard"
+ required
+ />
+ <label class="form-check-label" for="entryPageDashboard">
+ {{ $t("Dashboard") }}
+ </label>
+ </div>
+
+ <div v-for="statusPage in $root.statusPageList" :key="statusPage.id" class="form-check">
+ <input
+ :id="'status-page-' + statusPage.id"
+ v-model="settings.entryPage"
+ class="form-check-input"
+ type="radio"
+ name="entryPage"
+ :value="'statusPage-' + statusPage.slug"
+ required
+ />
+ <label class="form-check-label" :for="'status-page-' + statusPage.id">
+ {{ $t("Status Page") }} - {{ statusPage.title }}
+ </label>
+ </div>
+ </div>
+
+ <!-- Primary Base URL -->
+ <div class="mb-4">
+ <label class="form-label" for="primaryBaseURL">
+ {{ $t("Primary Base URL") }}
+ </label>
+
+ <div class="input-group mb-3">
+ <input
+ id="primaryBaseURL"
+ v-model="settings.primaryBaseURL"
+ class="form-control"
+ name="primaryBaseURL"
+ placeholder="https://"
+ pattern="https?://.+"
+ autocomplete="new-password"
+ />
+ <button class="btn btn-outline-primary" type="button" @click="autoGetPrimaryBaseURL">
+ {{ $t("Auto Get") }}
+ </button>
+ </div>
+
+ <div class="form-text"></div>
+ </div>
+
+ <!-- Steam API Key -->
+ <div class="mb-4">
+ <label class="form-label" for="steamAPIKey">
+ {{ $t("Steam API Key") }}
+ </label>
+ <HiddenInput
+ id="steamAPIKey"
+ v-model="settings.steamAPIKey"
+ autocomplete="new-password"
+ />
+ <div class="form-text">
+ {{ $t("steamApiKeyDescription") }}
+ <a href="https://steamcommunity.com/dev" target="_blank">
+ https://steamcommunity.com/dev
+ </a>
+ </div>
+ </div>
+
+ <!-- DNS Cache (nscd) -->
+ <div v-if="$root.info.isContainer" class="mb-4">
+ <label class="form-label">
+ {{ $t("enableNSCD") }}
+ </label>
+
+ <div class="form-check">
+ <input
+ id="nscdEnable"
+ v-model="settings.nscd"
+ class="form-check-input"
+ type="radio"
+ name="nscd"
+ :value="true"
+ required
+ />
+ <label class="form-check-label" for="nscdEnable">
+ {{ $t("Enable") }}
+ </label>
+ </div>
+
+ <div class="form-check">
+ <input
+ id="nscdDisable"
+ v-model="settings.nscd"
+ class="form-check-input"
+ type="radio"
+ name="nscd"
+ :value="false"
+ required
+ />
+ <label class="form-check-label" for="nscdDisable">
+ {{ $t("Disable") }}
+ </label>
+ </div>
+ </div>
+
+ <!-- Chrome Executable -->
+ <div class="mb-4">
+ <label class="form-label" for="primaryBaseURL">
+ {{ $t("chromeExecutable") }}
+ </label>
+
+ <div class="input-group mb-3">
+ <input
+ id="primaryBaseURL"
+ v-model="settings.chromeExecutable"
+ class="form-control"
+ name="primaryBaseURL"
+ :placeholder="$t('chromeExecutableAutoDetect')"
+ />
+ <button class="btn btn-outline-primary" type="button" @click="testChrome">
+ {{ $t("Test") }}
+ </button>
+ </div>
+
+ <div class="form-text">
+ {{ $t("chromeExecutableDescription") }}
+ </div>
+ </div>
+
+ <!-- Save Button -->
+ <div>
+ <button class="btn btn-primary" type="submit">
+ {{ $t("Save") }}
+ </button>
+ </div>
+ </form>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../../components/HiddenInput.vue";
+import dayjs from "dayjs";
+import { timezoneList } from "../../util-frontend";
+
+export default {
+ components: {
+ HiddenInput,
+ },
+
+ data() {
+ return {
+ timezoneList: timezoneList(),
+ };
+ },
+
+ computed: {
+ settings() {
+ return this.$parent.$parent.$parent.settings;
+ },
+ saveSettings() {
+ return this.$parent.$parent.$parent.saveSettings;
+ },
+ settingsLoaded() {
+ return this.$parent.$parent.$parent.settingsLoaded;
+ },
+ guessTimezone() {
+ return dayjs.tz.guess();
+ }
+ },
+
+ methods: {
+ /**
+ * Save the settings
+ * @returns {void}
+ */
+ saveGeneral() {
+ localStorage.timezone = this.$root.userTimezone;
+ this.saveSettings();
+ },
+ /**
+ * Get the base URL of the application
+ * @returns {void}
+ */
+ autoGetPrimaryBaseURL() {
+ this.settings.primaryBaseURL = location.protocol + "//" + location.host;
+ },
+ /**
+ * Test the chrome executable
+ * @returns {void}
+ */
+ testChrome() {
+ this.$root.getSocket().emit("testChrome", this.settings.chromeExecutable, (res) => {
+ this.$root.toastRes(res);
+ });
+ },
+ },
+};
+</script>
+
diff --git a/src/components/settings/MonitorHistory.vue b/src/components/settings/MonitorHistory.vue
new file mode 100644
index 0000000..25e3e15
--- /dev/null
+++ b/src/components/settings/MonitorHistory.vue
@@ -0,0 +1,155 @@
+<template>
+ <div>
+ <div class="my-4">
+ <label for="keepDataPeriodDays" class="form-label">
+ {{
+ $t("clearDataOlderThan", [
+ settings.keepDataPeriodDays,
+ ])
+ }}
+ {{ $t("infiniteRetention") }}
+ </label>
+ <input
+ id="keepDataPeriodDays"
+ v-model="settings.keepDataPeriodDays"
+ type="number"
+ class="form-control"
+ required
+ min="0"
+ step="1"
+ />
+ <div v-if="settings.keepDataPeriodDays < 0" class="form-text">
+ {{ $t("dataRetentionTimeError") }}
+ </div>
+ </div>
+ <div class="my-4">
+ <button class="btn btn-primary" type="button" @click="saveSettings()">
+ {{ $t("Save") }}
+ </button>
+ </div>
+ <div class="my-4">
+ <div v-if="$root.info.dbType === 'sqlite'" class="my-3">
+ <button class="btn btn-outline-info me-2" @click="shrinkDatabase">
+ {{ $t("Shrink Database") }} ({{ databaseSizeDisplay }})
+ </button>
+ <i18n-t tag="div" keypath="shrinkDatabaseDescriptionSqlite" class="form-text mt-2 mb-4 ms-2">
+ <template #vacuum>
+ <code>VACUUM</code>
+ </template>
+ <template #auto_vacuum>
+ <code>AUTO_VACUUM</code>
+ </template>
+ </i18n-t>
+ </div>
+ <button
+ id="clearAllStats-btn"
+ class="btn btn-outline-danger me-2 mb-2"
+ @click="confirmClearStatistics"
+ >
+ {{ $t("Clear all statistics") }}
+ </button>
+ </div>
+ <Confirm
+ ref="confirmClearStatistics"
+ btn-style="btn-danger"
+ :yes-text="$t('Yes')"
+ :no-text="$t('No')"
+ @yes="clearStatistics"
+ >
+ {{ $t("confirmClearStatisticsMsg") }}
+ </Confirm>
+ </div>
+</template>
+
+<script>
+import Confirm from "../../components/Confirm.vue";
+import { log } from "../../util.ts";
+
+export default {
+ components: {
+ Confirm,
+ },
+
+ data() {
+ return {
+ databaseSize: 0,
+ };
+ },
+
+ computed: {
+ settings() {
+ return this.$parent.$parent.$parent.settings;
+ },
+ saveSettings() {
+ return this.$parent.$parent.$parent.saveSettings;
+ },
+ settingsLoaded() {
+ return this.$parent.$parent.$parent.settingsLoaded;
+ },
+ databaseSizeDisplay() {
+ return (
+ Math.round((this.databaseSize / 1024 / 1024) * 10) / 10 + " MB"
+ );
+ },
+ },
+
+ mounted() {
+ this.loadDatabaseSize();
+ },
+
+ methods: {
+ /**
+ * Get the current size of the database
+ * @returns {void}
+ */
+ loadDatabaseSize() {
+ log.debug("monitorhistory", "load database size");
+ this.$root.getSocket().emit("getDatabaseSize", (res) => {
+ if (res.ok) {
+ this.databaseSize = res.size;
+ log.debug("monitorhistory", "database size: " + res.size);
+ } else {
+ log.debug("monitorhistory", res);
+ }
+ });
+ },
+
+ /**
+ * Request that the database is shrunk
+ * @returns {void}
+ */
+ shrinkDatabase() {
+ this.$root.getSocket().emit("shrinkDatabase", (res) => {
+ if (res.ok) {
+ this.loadDatabaseSize();
+ this.$root.toastSuccess("Done");
+ } else {
+ log.debug("monitorhistory", res);
+ }
+ });
+ },
+
+ /**
+ * Show the dialog to confirm clearing stats
+ * @returns {void}
+ */
+ confirmClearStatistics() {
+ this.$refs.confirmClearStatistics.show();
+ },
+
+ /**
+ * Send the request to clear stats
+ * @returns {void}
+ */
+ clearStatistics() {
+ this.$root.clearStatistics((res) => {
+ if (res.ok) {
+ this.$router.go();
+ } else {
+ this.$root.toastError(res.msg);
+ }
+ });
+ },
+ },
+};
+</script>
diff --git a/src/components/settings/Notifications.vue b/src/components/settings/Notifications.vue
new file mode 100644
index 0000000..2a65d79
--- /dev/null
+++ b/src/components/settings/Notifications.vue
@@ -0,0 +1,218 @@
+<template>
+ <div>
+ <div class="notification-list my-4">
+ <p v-if="$root.notificationList.length === 0">
+ {{ $t("Not available, please setup.") }}
+ </p>
+ <p v-else>
+ {{ $t("notificationDescription") }}
+ </p>
+
+ <ul class="list-group mb-3" style="border-radius: 1rem;">
+ <li v-for="(notification, index) in $root.notificationList" :key="index" class="list-group-item">
+ {{ notification.name }}<br>
+ <a href="#" @click="$refs.notificationDialog.show(notification.id)">{{ $t("Edit") }}</a>
+ </li>
+ </ul>
+
+ <button class="btn btn-primary me-2" type="button" @click="$refs.notificationDialog.show()">
+ {{ $t("Setup Notification") }}
+ </button>
+ </div>
+
+ <div class="my-4 pt-4">
+ <h5 class="my-4 settings-subheading">{{ $t("monitorToastMessagesLabel") }}</h5>
+ <p>{{ $t("monitorToastMessagesDescription") }}</p>
+
+ <div class="my-4">
+ <label for="toastErrorTimeoutSecs" class="form-label">
+ {{ $t("toastErrorTimeout") }}
+ </label>
+ <input
+ id="toastErrorTimeoutSecs"
+ v-model="toastErrorTimeoutSecs"
+ type="number"
+ class="form-control"
+ min="-1"
+ step="1"
+ />
+ </div>
+
+ <div class="my-4">
+ <label for="toastSuccessTimeoutSecs" class="form-label">
+ {{ $t("toastSuccessTimeout") }}
+ </label>
+ <input
+ id="toastSuccessTimeoutSecs"
+ v-model="toastSuccessTimeoutSecs"
+ type="number"
+ class="form-control"
+ min="-1"
+ step="1"
+ />
+ </div>
+ </div>
+
+ <div class="my-4 pt-4">
+ <h5 class="my-4 settings-subheading">{{ $t("settingsCertificateExpiry") }}</h5>
+ <p>{{ $t("certificationExpiryDescription") }}</p>
+ <p>{{ $t("notificationDescription") }}</p>
+ <div class="mt-1 mb-3 ps-2 cert-exp-days col-12 col-xl-6">
+ <div v-for="day in settings.tlsExpiryNotifyDays" :key="day" class="d-flex align-items-center justify-content-between cert-exp-day-row py-2">
+ <span>{{ day }} {{ $tc("day", day) }}</span>
+ <button type="button" class="btn-rm-expiry btn btn-outline-danger ms-2 py-1" :aria-label="$t('Remove the expiry notification')" @click="removeExpiryNotifDay(day)">
+ <font-awesome-icon icon="times" />
+ </button>
+ </div>
+ </div>
+ <div class="col-12 col-xl-6">
+ <ActionInput v-model="expiryNotifInput" :type="'number'" :placeholder="$t('day')" :icon="'plus'" :action="() => addExpiryNotifDay(expiryNotifInput)" :action-aria-label="$t('Add a new expiry notification day')" />
+ </div>
+ <div>
+ <button class="btn btn-primary" type="button" @click="saveSettings()">
+ {{ $t("Save") }}
+ </button>
+ </div>
+ </div>
+
+ <NotificationDialog ref="notificationDialog" />
+ </div>
+</template>
+
+<script>
+import NotificationDialog from "../../components/NotificationDialog.vue";
+import ActionInput from "../ActionInput.vue";
+
+export default {
+ components: {
+ NotificationDialog,
+ ActionInput,
+ },
+
+ data() {
+ return {
+ toastSuccessTimeoutSecs: 20,
+ toastErrorTimeoutSecs: -1,
+ /**
+ * Variable to store the input for new certificate expiry day.
+ */
+ expiryNotifInput: null,
+ };
+ },
+
+ computed: {
+ settings() {
+ return this.$parent.$parent.$parent.settings;
+ },
+ saveSettings() {
+ return this.$parent.$parent.$parent.saveSettings;
+ },
+ settingsLoaded() {
+ return this.$parent.$parent.$parent.settingsLoaded;
+ },
+ },
+
+ watch: {
+ // Parse, store and apply new timeout settings.
+ toastSuccessTimeoutSecs(newTimeout) {
+ const parsedTimeout = parseInt(newTimeout);
+ if (parsedTimeout != null && !Number.isNaN(parsedTimeout)) {
+ localStorage.toastSuccessTimeout = newTimeout > 0 ? newTimeout * 1000 : newTimeout;
+ }
+ },
+ toastErrorTimeoutSecs(newTimeout) {
+ const parsedTimeout = parseInt(newTimeout);
+ if (parsedTimeout != null && !Number.isNaN(parsedTimeout)) {
+ localStorage.toastErrorTimeout = newTimeout > 0 ? newTimeout * 1000 : newTimeout;
+ }
+ }
+ },
+
+ mounted() {
+ this.loadToastTimeoutSettings();
+ },
+
+ methods: {
+ /**
+ * Remove a day from expiry notification days.
+ * @param {number} day The day to remove.
+ * @returns {void}
+ */
+ removeExpiryNotifDay(day) {
+ this.settings.tlsExpiryNotifyDays = this.settings.tlsExpiryNotifyDays.filter(d => d !== day);
+ },
+ /**
+ * Add a new expiry notification day.
+ * Will verify:
+ * - day is not null or empty string.
+ * - day is a number.
+ * - day is > 0.
+ * - The day is not already in the list.
+ * @param {number} day The day number to add.
+ * @returns {void}
+ */
+ addExpiryNotifDay(day) {
+ if (day != null && day !== "") {
+ const parsedDay = parseInt(day);
+ if (parsedDay != null && !isNaN(parsedDay) && parsedDay > 0) {
+ if (!this.settings.tlsExpiryNotifyDays.includes(parsedDay)) {
+ this.settings.tlsExpiryNotifyDays.push(parseInt(day));
+ this.settings.tlsExpiryNotifyDays.sort((a, b) => a - b);
+ this.expiryNotifInput = null;
+ }
+ }
+ }
+ },
+
+ /**
+ * Loads toast timeout settings from storage to component data.
+ * @returns {void}
+ */
+ loadToastTimeoutSettings() {
+ const successTimeout = localStorage.toastSuccessTimeout;
+ if (successTimeout !== undefined) {
+ const parsedTimeout = parseInt(successTimeout);
+ if (parsedTimeout != null && !Number.isNaN(parsedTimeout)) {
+ this.toastSuccessTimeoutSecs = parsedTimeout > 0 ? parsedTimeout / 1000 : parsedTimeout;
+ }
+ }
+
+ const errorTimeout = localStorage.toastErrorTimeout;
+ if (errorTimeout !== undefined) {
+ const parsedTimeout = parseInt(errorTimeout);
+ if (parsedTimeout != null && !Number.isNaN(parsedTimeout)) {
+ this.toastErrorTimeoutSecs = parsedTimeout > 0 ? parsedTimeout / 1000 : parsedTimeout;
+ }
+ }
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../../assets/vars.scss";
+
+.btn-rm-expiry {
+ padding-left: 11px;
+ padding-right: 11px;
+}
+
+.dark {
+ .list-group-item {
+ background-color: $dark-bg2;
+ color: $dark-font-color;
+ }
+}
+
+.cert-exp-days .cert-exp-day-row {
+ border-bottom: 1px solid rgba(0, 0, 0, 0.125);
+
+ .dark & {
+ border-bottom: 1px solid $dark-border-color;
+ }
+}
+
+.cert-exp-days .cert-exp-day-row:last-child {
+ border: none;
+}
+</style>
diff --git a/src/components/settings/Proxies.vue b/src/components/settings/Proxies.vue
new file mode 100644
index 0000000..4608f3a
--- /dev/null
+++ b/src/components/settings/Proxies.vue
@@ -0,0 +1,48 @@
+<template>
+ <div>
+ <!-- Proxies -->
+ <div class="proxy-list my-4">
+ <p v-if="$root.proxyList.length === 0">
+ {{ $t("Not available, please setup.") }}
+ </p>
+ <p v-else>
+ {{ $t("proxyDescription") }}
+ </p>
+
+ <ul class="list-group mb-3" style="border-radius: 1rem;">
+ <li v-for="(proxy, index) in $root.proxyList" :key="index" class="list-group-item">
+ {{ proxy.host }}:{{ proxy.port }} ({{ proxy.protocol }})
+ <span v-if="proxy.default === true" class="badge bg-primary ms-2">{{ $t("Default") }}</span><br>
+ <a href="#" @click="$refs.proxyDialog.show(proxy.id)">{{ $t("Edit") }}</a>
+ </li>
+ </ul>
+
+ <button class="btn btn-primary me-2" type="button" @click="$refs.proxyDialog.show()">
+ {{ $t("Setup Proxy") }}
+ </button>
+ </div>
+
+ <ProxyDialog ref="proxyDialog" />
+ </div>
+</template>
+
+<script>
+import ProxyDialog from "../../components/ProxyDialog.vue";
+
+export default {
+ components: {
+ ProxyDialog
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../../assets/vars.scss";
+
+.dark {
+ .list-group-item {
+ background-color: $dark-bg2;
+ color: $dark-font-color;
+ }
+}
+</style>
diff --git a/src/components/settings/RemoteBrowsers.vue b/src/components/settings/RemoteBrowsers.vue
new file mode 100644
index 0000000..b449ac6
--- /dev/null
+++ b/src/components/settings/RemoteBrowsers.vue
@@ -0,0 +1,53 @@
+<template>
+ <div>
+ <div class="dockerHost-list my-4">
+ <p v-if="$root.remoteBrowserList.length === 0">
+ {{ $t("Not available, please setup.") }}
+ </p>
+
+ <ul class="list-group mb-3" style="border-radius: 1rem;">
+ <li v-for="(remoteBrowser, index) in $root.remoteBrowserList" :key="index" class="list-group-item">
+ {{ remoteBrowser.name }}<br>
+ <a href="#" @click="$refs.remoteBrowserDialog.show(remoteBrowser.id)">{{ $t("Edit") }}</a>
+ </li>
+ </ul>
+
+ <button class="btn btn-primary me-2" type="button" @click="$refs.remoteBrowserDialog.show()">
+ <font-awesome-icon icon="plus" /> {{ $t("Add Remote Browser") }}
+ </button>
+ </div>
+
+ <div class="my-4 pt-4">
+ <h5 class="my-4 settings-subheading">{{ $t("What is a Remote Browser?") }}</h5>
+ <p>{{ $t("remoteBrowsersDescription") }} <a href="https://hub.docker.com/r/browserless/chrome">{{ $t("self-hosted container") }}</a></p>
+ </div>
+
+ <RemoteBrowserDialog ref="remoteBrowserDialog" />
+ </div>
+</template>
+
+<script>
+import RemoteBrowserDialog from "../../components/RemoteBrowserDialog.vue";
+
+export default {
+ components: {
+ RemoteBrowserDialog,
+ },
+
+ data() {
+ return {};
+ },
+
+ computed: {
+ settings() {
+ return this.$parent.$parent.$parent.settings;
+ },
+ saveSettings() {
+ return this.$parent.$parent.$parent.saveSettings;
+ },
+ settingsLoaded() {
+ return this.$parent.$parent.$parent.settingsLoaded;
+ },
+ }
+};
+</script>
diff --git a/src/components/settings/ReverseProxy.vue b/src/components/settings/ReverseProxy.vue
new file mode 100644
index 0000000..0f0d493
--- /dev/null
+++ b/src/components/settings/ReverseProxy.vue
@@ -0,0 +1,211 @@
+<template>
+ <div>
+ <h4 class="mt-4">Cloudflare Tunnel</h4>
+
+ <div class="my-3">
+ <div>
+ cloudflared:
+ <span v-if="installed === true" class="text-primary">{{ $t("Installed") }}</span>
+ <span v-else-if="installed === false" class="text-danger">{{ $t("Not installed") }}</span>
+ </div>
+
+ <div>
+ {{ $t("Status") }}:
+ <span v-if="running" class="text-primary">{{ $t("Running") }}</span>
+ <span v-else-if="!running" class="text-danger">{{ $t("Not running") }}</span>
+ </div>
+
+ <div v-if="false">
+ {{ message }}
+ </div>
+
+ <div v-if="errorMessage" class="mt-3">
+ {{ $t("Message:") }}
+ <textarea v-model="errorMessage" class="form-control" readonly></textarea>
+ </div>
+
+ <i18n-t v-if="installed === false" tag="p" keypath="wayToGetCloudflaredURL">
+ <a
+ href="https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/"
+ target="_blank"
+ >{{ $t("cloudflareWebsite") }}</a>
+ </i18n-t>
+ </div>
+
+ <!-- If installed show token input -->
+ <div v-if="installed" class="mb-2">
+ <div class="mb-4">
+ <label class="form-label" for="cloudflareTunnelToken">
+ Cloudflare Tunnel {{ $t("Token") }}
+ </label>
+ <HiddenInput
+ id="cloudflareTunnelToken"
+ v-model="cloudflareTunnelToken"
+ autocomplete="new-password"
+ :readonly="running"
+ />
+ <div class="form-text">
+ <div v-if="cloudflareTunnelToken" class="mb-3">
+ <span v-if="!running" class="remove-token" @click="removeToken">{{ $t("Remove Token") }}</span>
+ </div>
+
+ {{ $t("Don't know how to get the token? Please read the guide:") }}<br />
+ <a href="https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy-with-Cloudflare-Tunnel" target="_blank">
+ https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy-with-Cloudflare-Tunnel
+ </a>
+ </div>
+ </div>
+
+ <div>
+ <button v-if="!running" class="btn btn-primary" type="submit" @click="start">
+ {{ $t("Start") }} cloudflared
+ </button>
+
+ <button v-if="running" class="btn btn-danger" type="submit" @click="$refs.confirmStop.show();">
+ {{ $t("Stop") }} cloudflared
+ </button>
+
+ <Confirm ref="confirmStop" btn-style="btn-danger" :yes-text="$t('Stop') + ' cloudflared'" :no-text="$t('Cancel')" @yes="stop">
+ {{ $t("The current connection may be lost if you are currently connecting via Cloudflare Tunnel. Are you sure want to stop it? Type your current password to confirm it.") }}
+
+ <p class="mt-2">{{ $t("disableCloudflaredNoAuthMsg") }}</p>
+
+ <div v-if="!settings.disableAuth" class="mt-3">
+ <label for="current-password2" class="form-label">
+ {{ $t("Current Password") }}
+ </label>
+ <input
+ id="current-password2"
+ v-model="currentPassword"
+ type="password"
+ class="form-control"
+ required
+ />
+ </div>
+ </Confirm>
+ </div>
+ </div>
+
+ <h4 class="mt-4">{{ $t("Other Software") }}</h4>
+ <div>
+ {{ $t("For example: nginx, Apache and Traefik.") }} <br />
+ {{ $t("Please read") }} <a href="https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy" target="_blank">https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy</a>.
+ </div>
+
+ <h4 class="my-4">{{ $t("HTTP Headers") }}</h4>
+ <div class="my-3">
+ <label class="form-label">
+ {{ $t("Trust Proxy") }}
+ </label>
+ <div class="form-check">
+ <input
+ id="trustProxyYes"
+ v-model="settings.trustProxy"
+ class="form-check-input"
+ type="radio"
+ name="trustProxyYes"
+ :value="true"
+ required
+ />
+ <label class="form-check-label" for="trustProxyYes">
+ {{ $t("Yes") }}
+ </label>
+ </div>
+ <div class="form-check">
+ <input
+ id="trustProxyNo"
+ v-model="settings.trustProxy"
+ class="form-check-input"
+ type="radio"
+ name="flexRadioDefault"
+ :value="false"
+ required
+ />
+ <label class="form-check-label" for="trustProxyNo">
+ {{ $t("No") }}
+ </label>
+ </div>
+
+ <div class="form-text">
+ {{ $t("trustProxyDescription") }}
+ </div>
+ </div>
+
+ <div>
+ <button class="btn btn-primary" type="submit" @click="saveSettings()">
+ {{ $t("Save") }}
+ </button>
+ </div>
+ </div>
+</template>
+
+<script>
+import HiddenInput from "../../components/HiddenInput.vue";
+import Confirm from "../Confirm.vue";
+
+const prefix = "cloudflared_";
+
+export default {
+ components: {
+ HiddenInput,
+ Confirm
+ },
+ data() {
+ // See /src/mixins/socket.js
+ return this.$root.cloudflared;
+ },
+ computed: {
+ settings() {
+ return this.$parent.$parent.$parent.settings;
+ },
+ saveSettings() {
+ return this.$parent.$parent.$parent.saveSettings;
+ },
+ settingsLoaded() {
+ return this.$parent.$parent.$parent.settingsLoaded;
+ },
+ },
+ watch: {
+
+ },
+ created() {
+ this.$root.getSocket().emit(prefix + "join");
+ },
+ unmounted() {
+ this.$root.getSocket().emit(prefix + "leave");
+ },
+ methods: {
+ /**
+ * Start the Cloudflare tunnel
+ * @returns {void}
+ */
+ start() {
+ this.$root.getSocket().emit(prefix + "start", this.cloudflareTunnelToken);
+ },
+ /**
+ * Stop the Cloudflare tunnel
+ * @returns {void}
+ */
+ stop() {
+ this.$root.getSocket().emit(prefix + "stop", this.currentPassword, (res) => {
+ this.$root.toastRes(res);
+ });
+ },
+ /**
+ * Remove the token for the Cloudflare tunnel
+ * @returns {void}
+ */
+ removeToken() {
+ this.$root.getSocket().emit(prefix + "removeToken");
+ this.cloudflareTunnelToken = "";
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+.remove-token {
+ text-decoration: underline;
+ cursor: pointer;
+}
+</style>
diff --git a/src/components/settings/Security.vue b/src/components/settings/Security.vue
new file mode 100644
index 0000000..5d8aed8
--- /dev/null
+++ b/src/components/settings/Security.vue
@@ -0,0 +1,228 @@
+<template>
+ <div>
+ <div v-if="settingsLoaded" class="my-4">
+ <!-- Change Password -->
+ <template v-if="!settings.disableAuth">
+ <p>
+ {{ $t("Current User") }}: <strong>{{ $root.username }}</strong>
+ <button v-if="! settings.disableAuth" id="logout-btn" class="btn btn-danger ms-4 me-2 mb-2" @click="$root.logout">{{ $t("Logout") }}</button>
+ </p>
+
+ <h5 class="my-4 settings-subheading">{{ $t("Change Password") }}</h5>
+ <form class="mb-3" @submit.prevent="savePassword">
+ <div class="mb-3">
+ <label for="current-password" class="form-label">
+ {{ $t("Current Password") }}
+ </label>
+ <input
+ id="current-password"
+ v-model="password.currentPassword"
+ type="password"
+ class="form-control"
+ autocomplete="current-password"
+ required
+ />
+ </div>
+
+ <div class="mb-3">
+ <label for="new-password" class="form-label">
+ {{ $t("New Password") }}
+ </label>
+ <input
+ id="new-password"
+ v-model="password.newPassword"
+ type="password"
+ class="form-control"
+ autocomplete="new-password"
+ required
+ />
+ </div>
+
+ <div class="mb-3">
+ <label for="repeat-new-password" class="form-label">
+ {{ $t("Repeat New Password") }}
+ </label>
+ <input
+ id="repeat-new-password"
+ v-model="password.repeatNewPassword"
+ type="password"
+ class="form-control"
+ :class="{ 'is-invalid': invalidPassword }"
+ autocomplete="new-password"
+ required
+ />
+ <div class="invalid-feedback">
+ {{ $t("passwordNotMatchMsg") }}
+ </div>
+ </div>
+
+ <div>
+ <button class="btn btn-primary" type="submit">
+ {{ $t("Update Password") }}
+ </button>
+ </div>
+ </form>
+ </template>
+
+ <div v-if="! settings.disableAuth" class="mt-5 mb-3">
+ <h5 class="my-4 settings-subheading">
+ {{ $t("Two Factor Authentication") }}
+ </h5>
+ <div class="mb-4">
+ <button
+ class="btn btn-primary me-2"
+ type="button"
+ @click="$refs.TwoFADialog.show()"
+ >
+ {{ $t("2FA Settings") }}
+ </button>
+ </div>
+ </div>
+
+ <div class="my-4">
+ <!-- Advanced -->
+ <h5 class="my-4 settings-subheading">{{ $t("Advanced") }}</h5>
+
+ <div class="mb-4">
+ <button v-if="settings.disableAuth" id="enableAuth-btn" class="btn btn-outline-primary me-2 mb-2" @click="enableAuth">{{ $t("Enable Auth") }}</button>
+ <button v-if="! settings.disableAuth" id="disableAuth-btn" class="btn btn-primary me-2 mb-2" @click="confirmDisableAuth">{{ $t("Disable Auth") }}</button>
+ </div>
+ </div>
+ </div>
+
+ <TwoFADialog ref="TwoFADialog" />
+
+ <Confirm ref="confirmDisableAuth" btn-style="btn-danger" :yes-text="$t('I understand, please disable')" :no-text="$t('Leave')" @yes="disableAuth">
+ <i18n-t tag="p" keypath="disableauth.message1">
+ <template #disableAuth>
+ <strong>{{ $t('disable authentication') }}</strong>
+ </template>
+ </i18n-t>
+ <i18n-t tag="p" keypath="disableauth.message2">
+ <template #intendThirdPartyAuth>
+ <strong>{{ $t('intend to implement third-party authentication') }}</strong>
+ </template>
+ </i18n-t>
+ <p>{{ $t("Please use this option carefully!") }}</p>
+
+ <div class="mb-3">
+ <label for="current-password2" class="form-label">
+ {{ $t("Current Password") }}
+ </label>
+ <input
+ id="current-password2"
+ v-model="password.currentPassword"
+ type="password"
+ class="form-control"
+ required
+ />
+ </div>
+ </Confirm>
+ </div>
+</template>
+
+<script>
+import Confirm from "../../components/Confirm.vue";
+import TwoFADialog from "../../components/TwoFADialog.vue";
+
+export default {
+ components: {
+ Confirm,
+ TwoFADialog
+ },
+
+ data() {
+ return {
+ invalidPassword: false,
+ password: {
+ currentPassword: "",
+ newPassword: "",
+ repeatNewPassword: "",
+ }
+ };
+ },
+
+ computed: {
+ settings() {
+ return this.$parent.$parent.$parent.settings;
+ },
+ saveSettings() {
+ return this.$parent.$parent.$parent.saveSettings;
+ },
+ settingsLoaded() {
+ return this.$parent.$parent.$parent.settingsLoaded;
+ }
+ },
+
+ watch: {
+ "password.repeatNewPassword"() {
+ this.invalidPassword = false;
+ },
+ },
+
+ methods: {
+ /**
+ * Check new passwords match before saving them
+ * @returns {void}
+ */
+ 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 = "";
+
+ // Update token of the current session
+ if (res.token) {
+ this.$root.storage().token = res.token;
+ this.$root.socket.token = res.token;
+ }
+ }
+ });
+ }
+ },
+
+ /**
+ * Disable authentication for web app access
+ * @returns {void}
+ */
+ disableAuth() {
+ this.settings.disableAuth = true;
+
+ // Need current password to disable auth
+ // Set it to empty if done
+ this.saveSettings(() => {
+ this.password.currentPassword = "";
+ this.$root.username = null;
+ this.$root.socket.token = "autoLogin";
+ }, this.password.currentPassword);
+ },
+
+ /**
+ * Enable authentication for web app access
+ * @returns {void}
+ */
+ enableAuth() {
+ this.settings.disableAuth = false;
+ this.saveSettings();
+ this.$root.storage().removeItem("token");
+ location.reload();
+ },
+
+ /**
+ * Show confirmation dialog for disable auth
+ * @returns {void}
+ */
+ confirmDisableAuth() {
+ this.$refs.confirmDisableAuth.show();
+ },
+
+ },
+};
+</script>
diff --git a/src/components/settings/Tags.vue b/src/components/settings/Tags.vue
new file mode 100644
index 0000000..75ac37c
--- /dev/null
+++ b/src/components/settings/Tags.vue
@@ -0,0 +1,175 @@
+<template>
+ <div class="my-4">
+ <div class="mx-0 mx-lg-4 pt-1 mb-4">
+ <button class="btn btn-primary" @click.stop="addTag"><font-awesome-icon icon="plus" /> {{ $t("Add New Tag") }}</button>
+ </div>
+
+ <div class="tags-list my-3">
+ <div v-for="(tag, index) in tagsList" :key="tag.id" class="d-flex align-items-center mx-0 mx-lg-4 py-1 tags-list-row" :disabled="processing" @click="editTag(index)">
+ <div class="col-10 col-sm-5">
+ <Tag :item="tag" />
+ </div>
+ <div class="col-5 px-1 d-none d-sm-block">
+ <div>{{ monitorsByTag(tag.id).length }} {{ $tc("Monitor", monitorsByTag(tag.id).length) }}</div>
+ </div>
+ <div class="col-2 pe-2 pe-lg-3 d-flex justify-content-end">
+ <button type="button" class="btn-rm-tag btn btn-outline-danger ms-2 py-1" :disabled="processing" @click.stop="deleteConfirm(index)">
+ <font-awesome-icon class="" icon="trash" />
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <TagEditDialog ref="tagEditDialog" :updated="tagsUpdated" :existing-tags="tagsList" />
+ <Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteTag">
+ {{ $t("confirmDeleteTagMsg") }}
+ </Confirm>
+ </div>
+</template>
+
+<script>
+import TagEditDialog from "../../components/TagEditDialog.vue";
+import Tag from "../Tag.vue";
+import Confirm from "../Confirm.vue";
+
+export default {
+ components: {
+ Confirm,
+ TagEditDialog,
+ Tag,
+ },
+
+ data() {
+ return {
+ processing: false,
+ tagsList: null,
+ deletingTag: null,
+ };
+ },
+
+ computed: {
+ settings() {
+ return this.$parent.$parent.$parent.settings;
+ },
+ saveSettings() {
+ return this.$parent.$parent.$parent.saveSettings;
+ },
+ settingsLoaded() {
+ return this.$parent.$parent.$parent.settingsLoaded;
+ },
+ },
+
+ mounted() {
+ this.getExistingTags();
+ },
+
+ methods: {
+ /**
+ * Reflect tag changes in the UI by fetching data. Callback for the edit tag dialog.
+ * @returns {void}
+ */
+ tagsUpdated() {
+ this.getExistingTags();
+ this.$root.getMonitorList();
+ },
+
+ /**
+ * Get list of tags from server
+ * @returns {void}
+ */
+ getExistingTags() {
+ this.processing = true;
+ this.$root.getSocket().emit("getTags", (res) => {
+ this.processing = false;
+ if (res.ok) {
+ this.tagsList = res.tags;
+ } else {
+ this.$root.toastError(res.msg);
+ }
+ });
+ },
+
+ /**
+ * Show confirmation for deleting a tag
+ * @param {number} index index of the tag to delete in the local tagsList
+ * @returns {void}
+ */
+ deleteConfirm(index) {
+ this.deletingTag = this.tagsList[index];
+ this.$refs.confirmDelete.show();
+ },
+
+ /**
+ * Show dialog for adding a new tag
+ * @returns {void}
+ */
+ addTag() {
+ this.$refs.tagEditDialog.reset();
+ this.$refs.tagEditDialog.show();
+ },
+
+ /**
+ * Show dialog for editing a tag
+ * @param {number} index index of the tag to edit in the local tagsList
+ * @returns {void}
+ */
+ editTag(index) {
+ this.$refs.tagEditDialog.show(this.tagsList[index]);
+ },
+
+ /**
+ * Delete the tag "deletingTag" from server
+ * @returns {void}
+ */
+ deleteTag() {
+ this.processing = true;
+ this.$root.getSocket().emit("deleteTag", this.deletingTag.id, (res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+
+ if (res.ok) {
+ this.tagsUpdated();
+ }
+ });
+ },
+
+ /**
+ * Get monitors which has a specific tag locally
+ * @param {number} tagId id of the tag to filter
+ * @returns {object[]} list of monitors which has a specific tag
+ */
+ monitorsByTag(tagId) {
+ return Object.values(this.$root.monitorList).filter((monitor) => {
+ return monitor.tags.find(monitorTag => monitorTag.tag_id === tagId);
+ });
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../../assets/vars.scss";
+
+.btn-rm-tag {
+ padding-left: 9px;
+ padding-right: 9px;
+}
+
+.tags-list .tags-list-row {
+ cursor: pointer;
+ border-top: 1px solid rgba(0, 0, 0, 0.125);
+
+ .dark & {
+ border-top: 1px solid $dark-border-color;
+ }
+
+ &:hover {
+ background-color: $highlight-white;
+ }
+
+ .dark &:hover {
+ background-color: $dark-bg2;
+ }
+}
+
+</style>