summaryrefslogtreecommitdiffstats
path: root/src/components/settings
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/settings')
-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
12 files changed, 1948 insertions, 0 deletions
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>