summaryrefslogtreecommitdiffstats
path: root/src/pages
diff options
context:
space:
mode:
Diffstat (limited to 'src/pages')
-rw-r--r--src/pages/AddStatusPage.vue88
-rw-r--r--src/pages/Dashboard.vue42
-rw-r--r--src/pages/DashboardHome.vue248
-rw-r--r--src/pages/Details.vue826
-rw-r--r--src/pages/EditMaintenance.vue611
-rw-r--r--src/pages/EditMonitor.vue1858
-rw-r--r--src/pages/Entry.vue54
-rw-r--r--src/pages/List.vue24
-rw-r--r--src/pages/MaintenanceDetails.vue169
-rw-r--r--src/pages/ManageMaintenance.vue317
-rw-r--r--src/pages/ManageStatusPage.vue123
-rw-r--r--src/pages/NotFound.vue104
-rw-r--r--src/pages/Settings.vue317
-rw-r--r--src/pages/Setup.vue138
-rw-r--r--src/pages/SetupDatabase.vue238
-rw-r--r--src/pages/StatusPage.vue1271
16 files changed, 6428 insertions, 0 deletions
diff --git a/src/pages/AddStatusPage.vue b/src/pages/AddStatusPage.vue
new file mode 100644
index 0000000..bae6144
--- /dev/null
+++ b/src/pages/AddStatusPage.vue
@@ -0,0 +1,88 @@
+<template>
+ <transition name="slide-fade" appear>
+ <div>
+ <h1 class="mb-3">
+ {{ $t("Add New Status Page") }}
+ </h1>
+
+ <form @submit.prevent="submit">
+ <div class="shadow-box">
+ <div class="mb-3">
+ <label for="name" class="form-label">{{ $t("Name") }}</label>
+ <input id="name" v-model="title" type="text" class="form-control" required data-testid="name-input">
+ </div>
+
+ <div class="mb-4">
+ <label for="slug" class="form-label">{{ $t("Slug") }}</label>
+ <div class="input-group">
+ <span id="basic-addon3" class="input-group-text">/status/</span>
+ <input id="slug" v-model="slug" type="text" class="form-control" required data-testid="slug-input">
+ </div>
+ <div class="form-text">
+ <ul>
+ <li>{{ $t("Accept characters:") }} <mark>a-z</mark> <mark>0-9</mark> <mark>-</mark></li>
+ <i18n-t tag="li" keypath="startOrEndWithOnly">
+ <mark>a-z</mark> <mark>0-9</mark>
+ </i18n-t>
+ <li>{{ $t("No consecutive dashes") }} <mark>--</mark></li>
+ <i18n-t tag="li" keypath="statusPageSpecialSlugDesc">
+ <mark class="me-1">default</mark>
+ </i18n-t>
+ </ul>
+ </div>
+ </div>
+
+ <div class="mt-2 mb-1">
+ <button id="monitor-submit-btn" class="btn btn-primary w-100" type="submit" :disabled="processing" data-testid="submit-button">{{ $t("Next") }}</button>
+ </div>
+ </div>
+ </form>
+ </div>
+ </transition>
+</template>
+
+<script>
+export default {
+ components: {
+
+ },
+ data() {
+ return {
+ title: "",
+ slug: "",
+ processing: false,
+ };
+ },
+ methods: {
+ /**
+ * Submit form data to add new status page
+ * @returns {Promise<void>}
+ */
+ async submit() {
+ this.processing = true;
+
+ this.$root.getSocket().emit("addStatusPage", this.title, this.slug, (res) => {
+ this.processing = false;
+
+ if (res.ok) {
+ location.href = "/status/" + this.slug + "?edit";
+ } else {
+
+ if (res.msg.includes("UNIQUE constraint")) {
+ this.$root.toastError("The slug is already taken. Please choose another slug.");
+ } else {
+ this.$root.toastRes(res);
+ }
+
+ }
+ });
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+.shadow-box {
+ padding: 20px;
+}
+</style>
diff --git a/src/pages/Dashboard.vue b/src/pages/Dashboard.vue
new file mode 100644
index 0000000..9a65711
--- /dev/null
+++ b/src/pages/Dashboard.vue
@@ -0,0 +1,42 @@
+<template>
+ <div class="container-fluid">
+ <div class="row">
+ <div v-if="!$root.isMobile" class="col-12 col-md-5 col-xl-4">
+ <div>
+ <router-link to="/add" class="btn btn-primary mb-3"><font-awesome-icon icon="plus" /> {{ $t("Add New Monitor") }}</router-link>
+ </div>
+ <MonitorList :scrollbar="true" />
+ </div>
+
+ <div ref="container" class="col-12 col-md-7 col-xl-8 mb-3">
+ <!-- Add :key to disable vue router re-use the same component -->
+ <router-view :key="$route.fullPath" :calculatedHeight="height" />
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+
+import MonitorList from "../components/MonitorList.vue";
+
+export default {
+ components: {
+ MonitorList,
+ },
+ data() {
+ return {
+ height: 0
+ };
+ },
+ mounted() {
+ this.height = this.$refs.container.offsetHeight;
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+.container-fluid {
+ width: 98%;
+}
+</style>
diff --git a/src/pages/DashboardHome.vue b/src/pages/DashboardHome.vue
new file mode 100644
index 0000000..a00dedb
--- /dev/null
+++ b/src/pages/DashboardHome.vue
@@ -0,0 +1,248 @@
+<template>
+ <transition ref="tableContainer" name="slide-fade" appear>
+ <div v-if="$route.name === 'DashboardHome'">
+ <h1 class="mb-3">
+ {{ $t("Quick Stats") }}
+ </h1>
+
+ <div class="shadow-box big-padding text-center mb-4">
+ <div class="row">
+ <div class="col">
+ <h3>{{ $t("Up") }}</h3>
+ <span
+ class="num"
+ :class="$root.stats.up === 0 && 'text-secondary'"
+ >
+ {{ $root.stats.up }}
+ </span>
+ </div>
+ <div class="col">
+ <h3>{{ $t("Down") }}</h3>
+ <span
+ class="num"
+ :class="$root.stats.down > 0 ? 'text-danger' : 'text-secondary'"
+ >
+ {{ $root.stats.down }}
+ </span>
+ </div>
+ <div class="col">
+ <h3>{{ $t("Maintenance") }}</h3>
+ <span
+ class="num"
+ :class="$root.stats.maintenance > 0 ? 'text-maintenance' : 'text-secondary'"
+ >
+ {{ $root.stats.maintenance }}
+ </span>
+ </div>
+ <div class="col">
+ <h3>{{ $t("Unknown") }}</h3>
+ <span class="num text-secondary">{{ $root.stats.unknown }}</span>
+ </div>
+ <div class="col">
+ <h3>{{ $t("pauseDashboardHome") }}</h3>
+ <span class="num text-secondary">{{ $root.stats.pause }}</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="shadow-box table-shadow-box" style="overflow-x: hidden;">
+ <table class="table table-borderless table-hover">
+ <thead>
+ <tr>
+ <th>{{ $t("Name") }}</th>
+ <th>{{ $t("Status") }}</th>
+ <th>{{ $t("DateTime") }}</th>
+ <th>{{ $t("Message") }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="(beat, index) in displayedRecords" :key="index" :class="{ 'shadow-box': $root.windowWidth <= 550}">
+ <td class="name-column"><router-link :to="`/dashboard/${beat.monitorID}`">{{ $root.monitorList[beat.monitorID]?.name }}</router-link></td>
+ <td><Status :status="beat.status" /></td>
+ <td :class="{ 'border-0':! beat.msg}"><Datetime :value="beat.time" /></td>
+ <td class="border-0">{{ beat.msg }}</td>
+ </tr>
+
+ <tr v-if="importantHeartBeatListLength === 0">
+ <td colspan="4">
+ {{ $t("No important events") }}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <div class="d-flex justify-content-center kuma_pagination">
+ <pagination
+ v-model="page"
+ :records="importantHeartBeatListLength"
+ :per-page="perPage"
+ :options="paginationConfig"
+ />
+ </div>
+ </div>
+ </div>
+ </transition>
+ <router-view ref="child" />
+</template>
+
+<script>
+import Status from "../components/Status.vue";
+import Datetime from "../components/Datetime.vue";
+import Pagination from "v-pagination-3";
+
+export default {
+ components: {
+ Datetime,
+ Status,
+ Pagination,
+ },
+ props: {
+ calculatedHeight: {
+ type: Number,
+ default: 0
+ }
+ },
+ data() {
+ return {
+ page: 1,
+ perPage: 25,
+ initialPerPage: 25,
+ paginationConfig: {
+ hideCount: true,
+ chunksNavigation: "scroll",
+ },
+ importantHeartBeatListLength: 0,
+ displayedRecords: [],
+ };
+ },
+ watch: {
+ perPage() {
+ this.$nextTick(() => {
+ this.getImportantHeartbeatListPaged();
+ });
+ },
+
+ page() {
+ this.getImportantHeartbeatListPaged();
+ },
+ },
+
+ mounted() {
+ this.getImportantHeartbeatListLength();
+
+ this.$root.emitter.on("newImportantHeartbeat", this.onNewImportantHeartbeat);
+
+ this.initialPerPage = this.perPage;
+
+ window.addEventListener("resize", this.updatePerPage);
+ this.updatePerPage();
+ },
+
+ beforeUnmount() {
+ this.$root.emitter.off("newImportantHeartbeat", this.onNewImportantHeartbeat);
+
+ window.removeEventListener("resize", this.updatePerPage);
+ },
+
+ methods: {
+ /**
+ * Updates the displayed records when a new important heartbeat arrives.
+ * @param {object} heartbeat - The heartbeat object received.
+ * @returns {void}
+ */
+ onNewImportantHeartbeat(heartbeat) {
+ if (this.page === 1) {
+ this.displayedRecords.unshift(heartbeat);
+ if (this.displayedRecords.length > this.perPage) {
+ this.displayedRecords.pop();
+ }
+ this.importantHeartBeatListLength += 1;
+ }
+ },
+
+ /**
+ * Retrieves the length of the important heartbeat list for all monitors.
+ * @returns {void}
+ */
+ getImportantHeartbeatListLength() {
+ this.$root.getSocket().emit("monitorImportantHeartbeatListCount", null, (res) => {
+ if (res.ok) {
+ this.importantHeartBeatListLength = res.count;
+ this.getImportantHeartbeatListPaged();
+ }
+ });
+ },
+
+ /**
+ * Retrieves the important heartbeat list for the current page.
+ * @returns {void}
+ */
+ getImportantHeartbeatListPaged() {
+ const offset = (this.page - 1) * this.perPage;
+ this.$root.getSocket().emit("monitorImportantHeartbeatListPaged", null, offset, this.perPage, (res) => {
+ if (res.ok) {
+ this.displayedRecords = res.data;
+ }
+ });
+ },
+
+ /**
+ * Updates the number of items shown per page based on the available height.
+ * @returns {void}
+ */
+ updatePerPage() {
+ const tableContainer = this.$refs.tableContainer;
+ const tableContainerHeight = tableContainer.offsetHeight;
+ const availableHeight = window.innerHeight - tableContainerHeight;
+ const additionalPerPage = Math.floor(availableHeight / 58);
+
+ if (additionalPerPage > 0) {
+ this.perPage = Math.max(this.initialPerPage, this.perPage + additionalPerPage);
+ } else {
+ this.perPage = this.initialPerPage;
+ }
+
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars";
+
+.num {
+ font-size: 30px;
+ color: $primary;
+ font-weight: bold;
+ display: block;
+}
+
+.shadow-box {
+ padding: 20px;
+}
+
+table {
+ font-size: 14px;
+
+ tr {
+ transition: all ease-in-out 0.2ms;
+ }
+
+ @media (max-width: 550px) {
+ table-layout: fixed;
+ overflow-wrap: break-word;
+ }
+}
+
+@media screen and (max-width: 1280px) {
+ .name-column {
+ min-width: 150px;
+ }
+}
+
+@media screen and (min-aspect-ratio: 4/3) {
+ .name-column {
+ min-width: 200px;
+ }
+}
+</style>
diff --git a/src/pages/Details.vue b/src/pages/Details.vue
new file mode 100644
index 0000000..17d3236
--- /dev/null
+++ b/src/pages/Details.vue
@@ -0,0 +1,826 @@
+<template>
+ <transition name="slide-fade" appear>
+ <div v-if="monitor">
+ <router-link v-if="group !== ''" :to="monitorURL(monitor.parent)"> {{ group }}</router-link>
+ <h1>
+ {{ monitor.name }}
+ <div class="monitor-id">
+ <div class="hash">#</div>
+ <div>{{ monitor.id }}</div>
+ </div>
+ </h1>
+ <p v-if="monitor.description">{{ monitor.description }}</p>
+ <div class="d-flex">
+ <div class="tags">
+ <Tag v-for="tag in monitor.tags" :key="tag.id" :item="tag" :size="'sm'" />
+ </div>
+ </div>
+ <p class="url">
+ <a v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'mp-health' || monitor.type === 'real-browser' " :href="monitor.url" target="_blank" rel="noopener noreferrer">{{ filterPassword(monitor.url) }}</a>
+ <span v-if="monitor.type === 'port'">TCP Port {{ monitor.hostname }}:{{ monitor.port }}</span>
+ <span v-if="monitor.type === 'ping'">Ping: {{ monitor.hostname }}</span>
+ <span v-if="monitor.type === 'keyword'">
+ <br>
+ <span>{{ $t("Keyword") }}: </span>
+ <span class="keyword">{{ monitor.keyword }}</span>
+ <span v-if="monitor.invertKeyword" alt="Inverted keyword" class="keyword-inverted"> ↧</span>
+ </span>
+ <span v-if="monitor.type === 'json-query'">
+ <br>
+ <span>{{ $t("Json Query") }}:</span> <span class="keyword">{{ monitor.jsonPath }}</span>
+ <br>
+ <span>{{ $t("Expected Value") }}:</span> <span class="keyword">{{ monitor.expectedValue }}</span>
+ </span>
+ <span v-if="monitor.type === 'dns'">[{{ monitor.dns_resolve_type }}] {{ monitor.hostname }}
+ <br>
+ <span>{{ $t("Last Result") }}:</span> <span class="keyword">{{ monitor.dns_last_result }}</span>
+ </span>
+ <span v-if="monitor.type === 'docker'">Docker container: {{ monitor.docker_container }}</span>
+ <span v-if="monitor.type === 'gamedig'">Gamedig - {{ monitor.game }}: {{ monitor.hostname }}:{{ monitor.port }}</span>
+ <span v-if="monitor.type === 'grpc-keyword'">gRPC - {{ filterPassword(monitor.grpcUrl) }}
+ <br>
+ <span>{{ $t("Keyword") }}:</span> <span class="keyword">{{ monitor.keyword }}</span>
+ </span>
+ <span v-if="monitor.type === 'mongodb'">{{ filterPassword(monitor.databaseConnectionString) }}</span>
+ <span v-if="monitor.type === 'mqtt'">MQTT: {{ monitor.hostname }}:{{ monitor.port }}/{{ monitor.mqttTopic }}</span>
+ <span v-if="monitor.type === 'mysql'">{{ filterPassword(monitor.databaseConnectionString) }}</span>
+ <span v-if="monitor.type === 'postgres'">{{ filterPassword(monitor.databaseConnectionString) }}</span>
+ <span v-if="monitor.type === 'push'">Push: <a :href="pushURL" target="_blank" rel="noopener noreferrer">{{ pushURL }}</a></span>
+ <span v-if="monitor.type === 'radius'">Radius: {{ filterPassword(monitor.hostname) }}</span>
+ <span v-if="monitor.type === 'redis'">{{ filterPassword(monitor.databaseConnectionString) }}</span>
+ <span v-if="monitor.type === 'sqlserver'">SQL Server: {{ filterPassword(monitor.databaseConnectionString) }}</span>
+ <span v-if="monitor.type === 'steam'">Steam Game Server: {{ monitor.hostname }}:{{ monitor.port }}</span>
+ </p>
+
+ <div class="functions">
+ <div class="btn-group" role="group">
+ <button v-if="monitor.active" class="btn btn-normal" @click="pauseDialog">
+ <font-awesome-icon icon="pause" /> {{ $t("Pause") }}
+ </button>
+ <button v-if="! monitor.active" class="btn btn-primary" :disabled="monitor.forceInactive" @click="resumeMonitor">
+ <font-awesome-icon icon="play" /> {{ $t("Resume") }}
+ </button>
+ <router-link :to=" '/edit/' + monitor.id " class="btn btn-normal">
+ <font-awesome-icon icon="edit" /> {{ $t("Edit") }}
+ </router-link>
+ <router-link :to=" '/clone/' + monitor.id " class="btn btn-normal">
+ <font-awesome-icon icon="clone" /> {{ $t("Clone") }}
+ </router-link>
+ <button class="btn btn-normal text-danger" @click="deleteDialog">
+ <font-awesome-icon icon="trash" /> {{ $t("Delete") }}
+ </button>
+ </div>
+ </div>
+
+ <div class="shadow-box">
+ <div class="row">
+ <div class="col-md-8">
+ <HeartbeatBar :monitor-id="monitor.id" />
+ <span class="word">{{ $t("checkEverySecond", [ monitor.interval ]) }}</span>
+ </div>
+ <div class="col-md-4 text-center">
+ <span class="badge rounded-pill" :class=" 'bg-' + status.color " style="font-size: 30px;" data-testid="monitor-status">{{ status.text }}</span>
+ </div>
+ </div>
+ </div>
+
+ <!-- Push Examples -->
+ <div v-if="monitor.type === 'push'" class="shadow-box big-padding">
+ <a href="#" @click="pushMonitor.showPushExamples = !pushMonitor.showPushExamples">{{ $t("pushViewCode") }}</a>
+
+ <transition name="slide-fade" appear>
+ <div v-if="pushMonitor.showPushExamples" class="mt-3">
+ <select id="push-current-example" v-model="pushMonitor.currentExample" class="form-select">
+ <optgroup :label="$t('programmingLanguages')">
+ <option value="csharp">C#</option>
+ <option value="go">Go</option>
+ <option value="java">Java</option>
+ <option value="javascript-fetch">JavaScript (fetch)</option>
+ <option value="php">PHP</option>
+ <option value="python">Python</option>
+ <option value="typescript-fetch">TypeScript (fetch)</option>
+ </optgroup>
+ <optgroup :label="$t('pushOthers')">
+ <option value="bash-curl">Bash (curl)</option>
+ <option value="powershell">PowerShell</option>
+ <option value="docker">Docker</option>
+ </optgroup>
+ </select>
+
+ <prism-editor v-model="pushMonitor.code" class="css-editor mt-3" :highlight="pushExampleHighlighter" line-numbers readonly></prism-editor>
+ </div>
+ </transition>
+ </div>
+
+ <!-- Stats -->
+ <div class="shadow-box big-padding text-center stats">
+ <div class="row">
+ <div v-if="monitor.type !== 'group'" class="col-12 col-sm col row d-flex align-items-center d-sm-block">
+ <h4 class="col-4 col-sm-12">{{ pingTitle() }}</h4>
+ <p class="col-4 col-sm-12 mb-0 mb-sm-2">({{ $t("Current") }})</p>
+ <span class="col-4 col-sm-12 num">
+ <a href="#" @click.prevent="showPingChartBox = !showPingChartBox">
+ <CountUp :value="ping" />
+ </a>
+ </span>
+ </div>
+ <div v-if="monitor.type !== 'group'" class="col-12 col-sm col row d-flex align-items-center d-sm-block">
+ <h4 class="col-4 col-sm-12">{{ pingTitle(true) }}</h4>
+ <p class="col-4 col-sm-12 mb-0 mb-sm-2">(24{{ $t("-hour") }})</p>
+ <span class="col-4 col-sm-12 num">
+ <CountUp :value="avgPing" />
+ </span>
+ </div>
+
+ <!-- Uptime (24-hour) -->
+ <div class="col-12 col-sm col row d-flex align-items-center d-sm-block">
+ <h4 class="col-4 col-sm-12">{{ $t("Uptime") }}</h4>
+ <p class="col-4 col-sm-12 mb-0 mb-sm-2">(24{{ $t("-hour") }})</p>
+ <span class="col-4 col-sm-12 num">
+ <Uptime :monitor="monitor" type="24" />
+ </span>
+ </div>
+
+ <!-- Uptime (30-day) -->
+ <div class="col-12 col-sm col row d-flex align-items-center d-sm-block">
+ <h4 class="col-4 col-sm-12">{{ $t("Uptime") }}</h4>
+ <p class="col-4 col-sm-12 mb-0 mb-sm-2">(30{{ $t("-day") }})</p>
+ <span class="col-4 col-sm-12 num">
+ <Uptime :monitor="monitor" type="720" />
+ </span>
+ </div>
+
+ <!-- Uptime (1-year) -->
+ <div class="col-12 col-sm col row d-flex align-items-center d-sm-block">
+ <h4 class="col-4 col-sm-12">{{ $t("Uptime") }}</h4>
+ <p class="col-4 col-sm-12 mb-0 mb-sm-2">(1{{ $t("-year") }})</p>
+ <span class="col-4 col-sm-12 num">
+ <Uptime :monitor="monitor" type="1y" />
+ </span>
+ </div>
+
+ <div v-if="tlsInfo" class="col-12 col-sm col row d-flex align-items-center d-sm-block">
+ <h4 class="col-4 col-sm-12">{{ $t("Cert Exp.") }}</h4>
+ <p class="col-4 col-sm-12 mb-0 mb-sm-2">(<Datetime :value="tlsInfo.certInfo.validTo" date-only />)</p>
+ <span class="col-4 col-sm-12 num">
+ <a href="#" @click.prevent="toggleCertInfoBox = !toggleCertInfoBox">{{ tlsInfo.certInfo.daysRemaining }} {{ $tc("day", tlsInfo.certInfo.daysRemaining) }}</a>
+ </span>
+ </div>
+ </div>
+ </div>
+
+ <!-- Cert Info Box -->
+ <transition name="slide-fade" appear>
+ <div v-if="showCertInfoBox" class="shadow-box big-padding text-center">
+ <div class="row">
+ <div class="col">
+ <certificate-info :certInfo="tlsInfo.certInfo" :valid="tlsInfo.valid" />
+ </div>
+ </div>
+ </div>
+ </transition>
+
+ <!-- Ping Chart -->
+ <div v-if="showPingChartBox" class="shadow-box big-padding text-center ping-chart-wrapper">
+ <div class="row">
+ <div class="col">
+ <PingChart :monitor-id="monitor.id" />
+ </div>
+ </div>
+ </div>
+
+ <!-- Screenshot -->
+ <div v-if="monitor.type === 'real-browser'" class="shadow-box">
+ <div class="row">
+ <div class="col-md-6 zoom-cursor">
+ <img :src="screenshotURL" style="width: 100%;" alt="screenshot of the website" @click="showScreenshotDialog">
+ </div>
+ <ScreenshotDialog ref="screenshotDialog" :imageURL="screenshotURL" />
+ </div>
+ </div>
+
+ <div class="shadow-box table-shadow-box">
+ <div class="dropdown dropdown-clear-data">
+ <button class="btn btn-sm btn-outline-danger dropdown-toggle" type="button" data-bs-toggle="dropdown">
+ <font-awesome-icon icon="trash" /> {{ $t("Clear Data") }}
+ </button>
+ <ul class="dropdown-menu dropdown-menu-end">
+ <li>
+ <button type="button" class="dropdown-item" @click="clearEventsDialog">
+ {{ $t("Events") }}
+ </button>
+ </li>
+ <li>
+ <button type="button" class="dropdown-item" @click="clearHeartbeatsDialog">
+ {{ $t("Heartbeats") }}
+ </button>
+ </li>
+ </ul>
+ </div>
+ <table class="table table-borderless table-hover">
+ <thead>
+ <tr>
+ <th>{{ $t("Status") }}</th>
+ <th>{{ $t("DateTime") }}</th>
+ <th>{{ $t("Message") }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="(beat, index) in displayedRecords" :key="index" style="padding: 10px;">
+ <td><Status :status="beat.status" /></td>
+ <td :class="{ 'border-0':! beat.msg}"><Datetime :value="beat.time" /></td>
+ <td class="border-0">{{ beat.msg }}</td>
+ </tr>
+
+ <tr v-if="importantHeartBeatListLength === 0">
+ <td colspan="3">
+ {{ $t("No important events") }}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <div class="d-flex justify-content-center kuma_pagination">
+ <pagination
+ v-model="page"
+ :records="importantHeartBeatListLength"
+ :per-page="perPage"
+ :options="paginationConfig"
+ />
+ </div>
+ </div>
+
+ <Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseMonitor">
+ {{ $t("pauseMonitorMsg") }}
+ </Confirm>
+
+ <Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteMonitor">
+ {{ $t("deleteMonitorMsg") }}
+ </Confirm>
+
+ <Confirm ref="confirmClearEvents" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="clearEvents">
+ {{ $t("clearEventsMsg") }}
+ </Confirm>
+
+ <Confirm ref="confirmClearHeartbeats" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="clearHeartbeats">
+ {{ $t("clearHeartbeatsMsg") }}
+ </Confirm>
+ </div>
+ </transition>
+</template>
+
+<script>
+import { defineAsyncComponent } from "vue";
+import { useToast } from "vue-toastification";
+const toast = useToast();
+import Confirm from "../components/Confirm.vue";
+import HeartbeatBar from "../components/HeartbeatBar.vue";
+import Status from "../components/Status.vue";
+import Datetime from "../components/Datetime.vue";
+import CountUp from "../components/CountUp.vue";
+import Uptime from "../components/Uptime.vue";
+import Pagination from "v-pagination-3";
+const PingChart = defineAsyncComponent(() => import("../components/PingChart.vue"));
+import Tag from "../components/Tag.vue";
+import CertificateInfo from "../components/CertificateInfo.vue";
+import { getMonitorRelativeURL } from "../util.ts";
+import { URL } from "whatwg-url";
+import { getResBaseURL } from "../util-frontend";
+import { highlight, languages } from "prismjs/components/prism-core";
+import "prismjs/components/prism-clike";
+import "prismjs/components/prism-javascript";
+import "prismjs/components/prism-css";
+import { PrismEditor } from "vue-prism-editor";
+import "vue-prism-editor/dist/prismeditor.min.css";
+import ScreenshotDialog from "../components/ScreenshotDialog.vue";
+
+export default {
+ components: {
+ Uptime,
+ CountUp,
+ Datetime,
+ HeartbeatBar,
+ Confirm,
+ Status,
+ Pagination,
+ PingChart,
+ Tag,
+ CertificateInfo,
+ PrismEditor,
+ ScreenshotDialog
+ },
+ data() {
+ return {
+ page: 1,
+ perPage: 25,
+ heartBeatList: [],
+ toggleCertInfoBox: false,
+ showPingChartBox: true,
+ paginationConfig: {
+ hideCount: true,
+ chunksNavigation: "scroll",
+ },
+ cacheTime: Date.now(),
+ importantHeartBeatListLength: 0,
+ displayedRecords: [],
+ pushMonitor: {
+ showPushExamples: false,
+ currentExample: "javascript-fetch",
+ code: "",
+ },
+ };
+ },
+ computed: {
+ monitor() {
+ let id = this.$route.params.id;
+ return this.$root.monitorList[id];
+ },
+
+ lastHeartBeat() {
+ // Also trigger screenshot refresh here
+ // eslint-disable-next-line vue/no-side-effects-in-computed-properties
+ this.cacheTime = Date.now();
+
+ if (this.monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[this.monitor.id]) {
+ return this.$root.lastHeartbeatList[this.monitor.id];
+ }
+
+ return {
+ status: -1,
+ };
+ },
+
+ ping() {
+ if (this.lastHeartBeat.ping || this.lastHeartBeat.ping === 0) {
+ return this.lastHeartBeat.ping;
+ }
+
+ return this.$t("notAvailableShort");
+ },
+
+ avgPing() {
+ if (this.$root.avgPingList[this.monitor.id] || this.$root.avgPingList[this.monitor.id] === 0) {
+ return this.$root.avgPingList[this.monitor.id];
+ }
+
+ return this.$t("notAvailableShort");
+ },
+
+ status() {
+ if (this.$root.statusList[this.monitor.id]) {
+ return this.$root.statusList[this.monitor.id];
+ }
+
+ return { };
+ },
+
+ tlsInfo() {
+ // Add: this.$root.tlsInfoList[this.monitor.id].certInfo
+ // Fix: TypeError: Cannot read properties of undefined (reading 'validTo')
+ // Reason: TLS Info object format is changed in 1.8.0, if for some reason, it cannot connect to the site after update to 1.8.0, the object is still in the old format.
+ if (this.$root.tlsInfoList[this.monitor.id] && this.$root.tlsInfoList[this.monitor.id].certInfo) {
+ return this.$root.tlsInfoList[this.monitor.id];
+ }
+
+ return null;
+ },
+
+ showCertInfoBox() {
+ return this.tlsInfo != null && this.toggleCertInfoBox;
+ },
+
+ group() {
+ return this.monitor.path.slice(0, -1).join(" / ");
+ },
+
+ pushURL() {
+ return this.$root.baseURL + "/api/push/" + this.monitor.pushToken + "?status=up&msg=OK&ping=";
+ },
+
+ screenshotURL() {
+ return getResBaseURL() + this.monitor.screenshot + "?time=" + this.cacheTime;
+ }
+ },
+
+ watch: {
+ page(to) {
+ this.getImportantHeartbeatListPaged();
+ },
+
+ monitor(to) {
+ this.getImportantHeartbeatListLength();
+ },
+ "monitor.type"() {
+ if (this.monitor && this.monitor.type === "push") {
+ this.loadPushExample();
+ }
+ },
+ "pushMonitor.currentExample"() {
+ this.loadPushExample();
+ },
+ },
+
+ mounted() {
+ this.getImportantHeartbeatListLength();
+
+ this.$root.emitter.on("newImportantHeartbeat", this.onNewImportantHeartbeat);
+
+ if (this.monitor && this.monitor.type === "push") {
+ if (this.lastHeartBeat.status === -1) {
+ this.pushMonitor.showPushExamples = true;
+ }
+ this.loadPushExample();
+ }
+ },
+
+ beforeUnmount() {
+ this.$root.emitter.off("newImportantHeartbeat", this.onNewImportantHeartbeat);
+ },
+
+ methods: {
+ getResBaseURL,
+ /**
+ * Request a test notification be sent for this monitor
+ * @returns {void}
+ */
+ testNotification() {
+ this.$root.getSocket().emit("testNotification", this.monitor.id);
+ this.$root.toastSuccess("Test notification is requested.");
+ },
+
+ /**
+ * Show dialog to confirm pause
+ * @returns {void}
+ */
+ pauseDialog() {
+ this.$refs.confirmPause.show();
+ },
+
+ /**
+ * Resume this monitor
+ * @returns {void}
+ */
+ resumeMonitor() {
+ this.$root.getSocket().emit("resumeMonitor", this.monitor.id, (res) => {
+ this.$root.toastRes(res);
+ });
+ },
+
+ /**
+ * Request that this monitor is paused
+ * @returns {void}
+ */
+ pauseMonitor() {
+ this.$root.getSocket().emit("pauseMonitor", this.monitor.id, (res) => {
+ this.$root.toastRes(res);
+ });
+ },
+
+ /**
+ * Show dialog to confirm deletion
+ * @returns {void}
+ */
+ deleteDialog() {
+ this.$refs.confirmDelete.show();
+ },
+
+ /**
+ * Show Screenshot Dialog
+ * @returns {void}
+ */
+ showScreenshotDialog() {
+ this.$refs.screenshotDialog.show();
+ },
+
+ /**
+ * Show dialog to confirm clearing events
+ * @returns {void}
+ */
+ clearEventsDialog() {
+ this.$refs.confirmClearEvents.show();
+ },
+
+ /**
+ * Show dialog to confirm clearing heartbeats
+ * @returns {void}
+ */
+ clearHeartbeatsDialog() {
+ this.$refs.confirmClearHeartbeats.show();
+ },
+
+ /**
+ * Request that this monitor is deleted
+ * @returns {void}
+ */
+ deleteMonitor() {
+ this.$root.deleteMonitor(this.monitor.id, (res) => {
+ this.$root.toastRes(res);
+ if (res.ok) {
+ this.$router.push("/dashboard");
+ }
+ });
+ },
+
+ /**
+ * Request that this monitors events are cleared
+ * @returns {void}
+ */
+ clearEvents() {
+ this.$root.clearEvents(this.monitor.id, (res) => {
+ if (res.ok) {
+ this.getImportantHeartbeatListLength();
+ } else {
+ toast.error(res.msg);
+ }
+ });
+ },
+
+ /**
+ * Request that this monitors heartbeats are cleared
+ * @returns {void}
+ */
+ clearHeartbeats() {
+ this.$root.clearHeartbeats(this.monitor.id, (res) => {
+ if (! res.ok) {
+ toast.error(res.msg);
+ }
+ });
+ },
+
+ /**
+ * Return the correct title for the ping stat
+ * @param {boolean} average Is the statistic an average?
+ * @returns {string} Title formatted dependent on monitor type
+ */
+ pingTitle(average = false) {
+ let translationPrefix = "";
+ if (average) {
+ translationPrefix = "Avg. ";
+ }
+
+ if (this.monitor.type === "http" || this.monitor.type === "keyword" || this.monitor.type === "json-query") {
+ return this.$t(translationPrefix + "Response");
+ }
+
+ return this.$t(translationPrefix + "Ping");
+ },
+
+ /**
+ * Get URL of monitor
+ * @param {number} id ID of monitor
+ * @returns {string} Relative URL of monitor
+ */
+ monitorURL(id) {
+ return getMonitorRelativeURL(id);
+ },
+
+ /**
+ * Filter and hide password in URL for display
+ * @param {string} urlString URL to censor
+ * @returns {string} Censored URL
+ */
+ filterPassword(urlString) {
+ try {
+ let parsedUrl = new URL(urlString);
+ if (parsedUrl.password !== "") {
+ parsedUrl.password = "******";
+ }
+ return parsedUrl.toString();
+ } catch (e) {
+ // Handle SQL Server
+ return urlString.replaceAll(/Password=(.+);/ig, "Password=******;");
+ }
+ },
+
+ /**
+ * Retrieves the length of the important heartbeat list for this monitor.
+ * @returns {void}
+ */
+ getImportantHeartbeatListLength() {
+ if (this.monitor) {
+ this.$root.getSocket().emit("monitorImportantHeartbeatListCount", this.monitor.id, (res) => {
+ if (res.ok) {
+ this.importantHeartBeatListLength = res.count;
+ this.getImportantHeartbeatListPaged();
+ }
+ });
+ }
+ },
+
+ /**
+ * Retrieves the important heartbeat list for the current page.
+ * @returns {void}
+ */
+ getImportantHeartbeatListPaged() {
+ if (this.monitor) {
+ const offset = (this.page - 1) * this.perPage;
+ this.$root.getSocket().emit("monitorImportantHeartbeatListPaged", this.monitor.id, offset, this.perPage, (res) => {
+ if (res.ok) {
+ this.displayedRecords = res.data;
+ }
+ });
+ }
+ },
+
+ /**
+ * Updates the displayed records when a new important heartbeat arrives.
+ * @param {object} heartbeat - The heartbeat object received.
+ * @returns {void}
+ */
+ onNewImportantHeartbeat(heartbeat) {
+ if (heartbeat.monitorID === this.monitor?.id) {
+ if (this.page === 1) {
+ this.displayedRecords.unshift(heartbeat);
+ if (this.displayedRecords.length > this.perPage) {
+ this.displayedRecords.pop();
+ }
+ this.importantHeartBeatListLength += 1;
+ }
+ }
+ },
+
+ /**
+ * Highlight the example code
+ * @param {string} code Code
+ * @returns {string} Highlighted code
+ */
+ pushExampleHighlighter(code) {
+ return highlight(code, languages.js);
+ },
+
+ loadPushExample() {
+ this.pushMonitor.code = "";
+ this.$root.getSocket().emit("getPushExample", this.pushMonitor.currentExample, (res) => {
+ let code = res.code
+ .replace("60", this.monitor.interval)
+ .replace("https://example.com/api/push/key?status=up&msg=OK&ping=", this.pushURL);
+ this.pushMonitor.code = code;
+ });
+ }
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+@media (max-width: 767px) {
+ .badge {
+ margin-top: 14px;
+ }
+}
+
+@media (max-width: 550px) {
+ .functions {
+ text-align: center;
+ }
+
+ .ping-chart-wrapper {
+ padding: 10px !important;
+ }
+
+ .dropdown-clear-data {
+ margin-bottom: 10px;
+ }
+}
+
+@media (max-width: 400px) {
+ .btn {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ padding-top: 10px;
+ font-size: 0.9em;
+ }
+
+ a.btn {
+ padding-left: 25px;
+ padding-right: 25px;
+ }
+
+ .dropdown-clear-data {
+ button {
+ display: block;
+ padding-top: 4px;
+ }
+ }
+}
+
+.url {
+ color: $primary;
+ margin-bottom: 20px;
+ font-weight: bold;
+
+ a {
+ color: $primary;
+ }
+}
+
+.shadow-box {
+ padding: 20px;
+ margin-top: 25px;
+}
+
+.word {
+ color: $secondary-text;
+ font-size: 14px;
+}
+
+table {
+ font-size: 14px;
+
+ tr {
+ transition: all ease-in-out 0.2ms;
+ }
+}
+
+.stats p {
+ font-size: 13px;
+ color: $secondary-text;
+}
+
+.stats {
+ padding: 10px;
+
+ .col {
+ margin: 20px 0;
+ }
+}
+
+@media (max-width: 550px) {
+ .stats {
+ .col {
+ margin: 10px 0 !important;
+ }
+
+ h4 {
+ font-size: 1.1rem;
+ }
+ }
+}
+
+.keyword {
+ color: black;
+}
+
+.dropdown-clear-data {
+ float: right;
+
+ ul {
+ width: 100%;
+ min-width: unset;
+ padding-left: 0;
+ }
+}
+
+.dark {
+ .keyword {
+ color: $dark-font-color;
+ }
+
+ .keyword-inverted {
+ color: $dark-font-color;
+ }
+
+ .dropdown-clear-data {
+ ul {
+ background-color: $dark-bg;
+ border-color: $dark-bg2;
+ border-width: 2px;
+
+ li button {
+ color: $dark-font-color;
+ }
+
+ li button:hover {
+ background-color: $dark-bg2;
+ }
+ }
+ }
+}
+
+.tags {
+ margin-bottom: 0.5rem;
+}
+
+.tags > div:first-child {
+ margin-left: 0 !important;
+}
+
+.monitor-id {
+ display: inline-flex;
+ font-size: 0.7em;
+ margin-left: 0.3em;
+ color: $secondary-text;
+ flex-direction: row;
+ flex-wrap: nowrap;
+
+ .hash {
+ user-select: none;
+ }
+
+ .dark & {
+ opacity: 0.7;
+ }
+}
+</style>
diff --git a/src/pages/EditMaintenance.vue b/src/pages/EditMaintenance.vue
new file mode 100644
index 0000000..953fe33
--- /dev/null
+++ b/src/pages/EditMaintenance.vue
@@ -0,0 +1,611 @@
+<template>
+ <transition name="slide-fade" appear>
+ <div>
+ <h1 class="mb-3">{{ pageName }}</h1>
+ <form @submit.prevent="submit">
+ <div class="shadow-box shadow-box-with-fixed-bottom-bar">
+ <div class="row">
+ <div class="col-xl-10">
+ <!-- Title -->
+ <div class="mb-3">
+ <label for="name" class="form-label">{{ $t("Title") }}</label>
+ <input
+ id="name" v-model="maintenance.title" type="text" class="form-control"
+ required
+ >
+ </div>
+
+ <!-- Description -->
+ <div class="my-3">
+ <label for="description" class="form-label">{{ $t("Description") }}</label>
+ <textarea
+ id="description" v-model="maintenance.description" class="form-control"
+ ></textarea>
+ <div class="form-text">
+ {{ $t("markdownSupported") }}
+ </div>
+ </div>
+
+ <!-- Affected Monitors -->
+ <h2 class="mt-5">{{ $t("Affected Monitors") }}</h2>
+ {{ $t("affectedMonitorsDescription") }}
+
+ <div class="my-3">
+ <VueMultiselect
+ id="affected_monitors"
+ v-model="affectedMonitors"
+ :options="affectedMonitorsOptions"
+ track-by="id"
+ label="pathName"
+ :multiple="true"
+ :close-on-select="false"
+ :clear-on-select="false"
+ :preserve-search="true"
+ :placeholder="$t('Pick Affected Monitors...')"
+ :preselect-first="false"
+ :max-height="600"
+ :taggable="false"
+ ></VueMultiselect>
+ </div>
+
+ <!-- Status pages to display maintenance info on -->
+ <h2 class="mt-5">{{ $t("Status Pages") }}</h2>
+ {{ $t("affectedStatusPages") }}
+
+ <div class="my-3">
+ <!-- Show on all pages -->
+ <div class="form-check mb-2">
+ <input
+ id="show-on-all-pages" v-model="showOnAllPages" class="form-check-input"
+ type="checkbox"
+ >
+ <label class="form-check-label" for="show-powered-by">{{
+ $t("All Status Pages")
+ }}</label>
+ </div>
+
+ <div v-if="!showOnAllPages">
+ <VueMultiselect
+ id="selected_status_pages"
+ v-model="selectedStatusPages"
+ :options="selectedStatusPagesOptions"
+ track-by="id"
+ label="name"
+ :multiple="true"
+ :close-on-select="false"
+ :clear-on-select="false"
+ :preserve-search="true"
+ :placeholder="$t('Select status pages...')"
+ :preselect-first="false"
+ :max-height="600"
+ :taggable="false"
+ ></VueMultiselect>
+ </div>
+ </div>
+
+ <h2 class="mt-5">{{ $t("Date and Time") }}</h2>
+
+ <!-- Strategy -->
+ <div class="my-3">
+ <label for="strategy" class="form-label">{{ $t("Strategy") }}</label>
+ <select id="strategy" v-model="maintenance.strategy" class="form-select">
+ <option value="manual">{{ $t("strategyManual") }}</option>
+ <option value="single">{{ $t("Single Maintenance Window") }}</option>
+ <option value="cron">{{ $t("cronExpression") }}</option>
+ <option value="recurring-interval">{{ $t("Recurring") }} - {{ $t("recurringInterval") }}</option>
+ <option value="recurring-weekday">{{ $t("Recurring") }} - {{ $t("dayOfWeek") }}</option>
+ <option value="recurring-day-of-month">{{ $t("Recurring") }} - {{ $t("dayOfMonth") }}</option>
+ </select>
+ </div>
+
+ <!-- Single Maintenance Window -->
+ <template v-if="maintenance.strategy === 'single'">
+ </template>
+
+ <template v-if="maintenance.strategy === 'cron'">
+ <!-- Cron -->
+ <div class="my-3">
+ <label for="cron" class="form-label">
+ {{ $t("cronExpression") }}
+ </label>
+ <p>{{ $t("cronSchedule") }}{{ cronDescription }}</p>
+ <input id="cron" v-model="maintenance.cron" type="text" class="form-control" required>
+ </div>
+
+ <div class="my-3">
+ <!-- Duration -->
+ <label for="duration" class="form-label">
+ {{ $t("Duration (Minutes)") }}
+ </label>
+ <input id="duration" v-model="maintenance.durationMinutes" type="number" class="form-control" required min="1" step="1">
+ </div>
+ </template>
+
+ <!-- Recurring - Interval -->
+ <template v-if="maintenance.strategy === 'recurring-interval'">
+ <div class="my-3">
+ <label for="interval-day" class="form-label">
+ {{ $t("recurringInterval") }}
+
+ <template v-if="maintenance.intervalDay >= 1">
+ ({{
+ $tc("recurringIntervalMessage", maintenance.intervalDay, [
+ maintenance.intervalDay
+ ])
+ }})
+ </template>
+ </label>
+ <input id="interval-day" v-model="maintenance.intervalDay" type="number" class="form-control" required min="1" max="3650" step="1">
+ </div>
+ </template>
+
+ <!-- Recurring - Weekday -->
+ <template v-if="maintenance.strategy === 'recurring-weekday'">
+ <div class="my-3">
+ <label for="interval-day" class="form-label">
+ {{ $t("dayOfWeek") }}
+ </label>
+
+ <!-- Weekday Picker -->
+ <div class="weekday-picker">
+ <div v-for="(weekday, index) in weekdays" :key="index">
+ <label class="form-check-label" :for="weekday.id">{{ $t(weekday.langKey) }}</label>
+ <div class="form-check-inline"><input :id="weekday.id" v-model="maintenance.weekdays" type="checkbox" :value="weekday.value" class="form-check-input"></div>
+ </div>
+ </div>
+ </div>
+ </template>
+
+ <!-- Recurring - Day of month -->
+ <template v-if="maintenance.strategy === 'recurring-day-of-month'">
+ <div class="my-3">
+ <label for="interval-day" class="form-label">
+ {{ $t("dayOfMonth") }}
+ </label>
+
+ <!-- Day Picker -->
+ <div class="day-picker">
+ <div v-for="index in 31" :key="index">
+ <label class="form-check-label" :for="'day' + index">{{ index }}</label>
+ <div class="form-check-inline">
+ <input :id="'day' + index" v-model="maintenance.daysOfMonth" type="checkbox" :value="index" class="form-check-input">
+ </div>
+ </div>
+ </div>
+
+ <div class="mt-3 mb-2">{{ $t("lastDay") }}</div>
+
+ <div v-for="(lastDay, index) in lastDays" :key="index" class="form-check">
+ <input :id="lastDay.langKey" v-model="maintenance.daysOfMonth" type="checkbox" :value="lastDay.value" class="form-check-input">
+ <label class="form-check-label" :for="lastDay.langKey">
+ {{ $t(lastDay.langKey) }}
+ </label>
+ </div>
+ </div>
+ </template>
+
+ <template v-if="maintenance.strategy === 'recurring-interval' || maintenance.strategy === 'recurring-weekday' || maintenance.strategy === 'recurring-day-of-month'">
+ <!-- Maintenance Time Window of a Day -->
+ <div class="my-3">
+ <label class="form-label">{{ $t("Maintenance Time Window of a Day") }}</label>
+ <Datepicker
+ v-model="maintenance.timeRange"
+ :dark="$root.isDark"
+ timePicker
+ disableTimeRangeValidation range
+ />
+ </div>
+ </template>
+
+ <template v-if="maintenance.strategy === 'recurring-interval' || maintenance.strategy === 'recurring-weekday' || maintenance.strategy === 'recurring-day-of-month' || maintenance.strategy === 'cron' || maintenance.strategy === 'single'">
+ <!-- Timezone -->
+ <div class="mb-4">
+ <label for="timezone" class="form-label">
+ {{ $t("Timezone") }}
+ </label>
+ <select id="timezone" v-model="maintenance.timezoneOption" class="form-select">
+ <option value="SAME_AS_SERVER">{{ $t("sameAsServerTimezone") }}</option>
+ <option value="UTC">UTC</option>
+ <option
+ v-for="(timezone, index) in timezoneList"
+ :key="index"
+ :value="timezone.value"
+ >
+ {{ timezone.name }}
+ </option>
+ </select>
+ </div>
+
+ <!-- Date Range -->
+ <div class="my-3">
+ <label v-if="maintenance.strategy !== 'single'" class="form-label">{{ $t("Effective Date Range") }}</label>
+
+ <div class="row">
+ <div class="col">
+ <div class="mb-2">{{ $t("startDateTime") }}</div>
+ <input v-model="maintenance.dateRange[0]" type="datetime-local" class="form-control" :required="maintenance.strategy === 'single'">
+ </div>
+
+ <div class="col">
+ <div class="mb-2">{{ $t("endDateTime") }}</div>
+ <input v-model="maintenance.dateRange[1]" type="datetime-local" class="form-control" :required="maintenance.strategy === 'single'">
+ </div>
+ </div>
+ </div>
+ </template>
+ </div>
+ </div>
+
+ <div class="fixed-bottom-bar p-3">
+ <button id="monitor-submit-btn" class="btn btn-primary" type="submit" :disabled="processing">{{ $t("Save") }}</button>
+ </div>
+ </div>
+ </form>
+ </div>
+ </transition>
+</template>
+
+<script>
+import VueMultiselect from "vue-multiselect";
+import Datepicker from "@vuepic/vue-datepicker";
+import { timezoneList } from "../util-frontend";
+import cronstrue from "cronstrue/i18n";
+
+export default {
+ components: {
+ VueMultiselect,
+ Datepicker
+ },
+
+ data() {
+ return {
+ timezoneList: timezoneList(),
+ processing: false,
+ maintenance: {},
+ affectedMonitors: [],
+ affectedMonitorsOptions: [],
+ showOnAllPages: false,
+ selectedStatusPages: [],
+ dark: (this.$root.theme === "dark"),
+ neverEnd: false,
+ lastDays: [
+ {
+ langKey: "lastDay1",
+ value: "lastDay1",
+ },
+ ],
+ weekdays: [
+ {
+ id: "weekday1",
+ langKey: "weekdayShortMon",
+ value: 1,
+ },
+ {
+ id: "weekday2",
+ langKey: "weekdayShortTue",
+ value: 2,
+ },
+ {
+ id: "weekday3",
+ langKey: "weekdayShortWed",
+ value: 3,
+ },
+ {
+ id: "weekday4",
+ langKey: "weekdayShortThu",
+ value: 4,
+ },
+ {
+ id: "weekday5",
+ langKey: "weekdayShortFri",
+ value: 5,
+ },
+ {
+ id: "weekday6",
+ langKey: "weekdayShortSat",
+ value: 6,
+ },
+ {
+ id: "weekday0",
+ langKey: "weekdayShortSun",
+ value: 0,
+ },
+ ],
+ };
+ },
+
+ computed: {
+
+ cronDescription() {
+ if (! this.maintenance.cron) {
+ return "";
+ }
+
+ let locale = "";
+
+ if (this.$root.language) {
+ locale = this.$root.language.replace("-", "_");
+ }
+
+ // Special handling
+ // If locale is also not working in your language, you can map it here
+ // https://github.com/bradymholt/cRonstrue/tree/master/src/i18n/locales
+ if (locale === "zh_HK") {
+ locale = "zh_TW";
+ }
+
+ try {
+ return cronstrue.toString(this.maintenance.cron, {
+ locale,
+ });
+ } catch (e) {
+ return this.$t("invalidCronExpression", e.message);
+ }
+
+ },
+
+ selectedStatusPagesOptions() {
+ return Object.values(this.$root.statusPageList).map(statusPage => {
+ return {
+ id: statusPage.id,
+ name: statusPage.title
+ };
+ });
+ },
+
+ pageName() {
+ return this.$t((this.isAdd) ? "Schedule Maintenance" : "Edit Maintenance");
+ },
+
+ isAdd() {
+ return this.$route.path === "/add-maintenance";
+ },
+
+ isEdit() {
+ return this.$route.path.startsWith("/maintenance/edit");
+ },
+
+ },
+ watch: {
+ "$route.fullPath"() {
+ this.init();
+ },
+
+ neverEnd(value) {
+ if (value) {
+ this.maintenance.recurringEndDate = "";
+ }
+ },
+ },
+ mounted() {
+ this.$root.getMonitorList((res) => {
+ if (res.ok) {
+ Object.values(this.$root.monitorList).sort((m1, m2) => {
+
+ if (m1.active !== m2.active) {
+ if (m1.active === 0) {
+ return 1;
+ }
+
+ if (m2.active === 0) {
+ return -1;
+ }
+ }
+
+ if (m1.weight !== m2.weight) {
+ if (m1.weight > m2.weight) {
+ return -1;
+ }
+
+ if (m1.weight < m2.weight) {
+ return 1;
+ }
+ }
+
+ return m1.pathName.localeCompare(m2.pathName);
+ }).map(monitor => {
+ this.affectedMonitorsOptions.push({
+ id: monitor.id,
+ pathName: monitor.pathName,
+ });
+ });
+ }
+ this.init();
+ });
+ },
+ methods: {
+ /**
+ * Initialise page
+ * @returns {void}
+ */
+ init() {
+ this.affectedMonitors = [];
+ this.selectedStatusPages = [];
+
+ if (this.isAdd) {
+ this.maintenance = {
+ title: "",
+ description: "",
+ strategy: "single",
+ active: 1,
+ cron: "30 3 * * *",
+ durationMinutes: 60,
+ intervalDay: 1,
+ dateRange: [],
+ timeRange: [{
+ hours: 2,
+ minutes: 0,
+ }, {
+ hours: 3,
+ minutes: 0,
+ }],
+ weekdays: [],
+ daysOfMonth: [],
+ timezoneOption: null,
+ };
+ } else if (this.isEdit) {
+ this.$root.getSocket().emit("getMaintenance", this.$route.params.id, (res) => {
+ if (res.ok) {
+ this.maintenance = res.maintenance;
+
+ this.$root.getSocket().emit("getMonitorMaintenance", this.$route.params.id, (res) => {
+ if (res.ok) {
+ Object.values(res.monitors).map(monitor => {
+ this.affectedMonitors.push(this.affectedMonitorsOptions.find(item => item.id === monitor.id));
+ });
+ } else {
+ this.$root.toastError(res.msg);
+ }
+ });
+
+ this.$root.getSocket().emit("getMaintenanceStatusPage", this.$route.params.id, (res) => {
+ if (res.ok) {
+ Object.values(res.statusPages).map(statusPage => {
+ this.selectedStatusPages.push({
+ id: statusPage.id,
+ name: statusPage.title
+ });
+ });
+
+ this.showOnAllPages = Object.values(res.statusPages).length === this.selectedStatusPagesOptions.length;
+ } else {
+ this.$root.toastError(res.msg);
+ }
+ });
+ } else {
+ this.$root.toastError(res.msg);
+ }
+ });
+ }
+ },
+
+ /**
+ * Create new maintenance
+ * @returns {Promise<void>}
+ */
+ async submit() {
+ this.processing = true;
+
+ if (this.affectedMonitors.length === 0) {
+ this.$root.toastError(this.$t("atLeastOneMonitor"));
+ return this.processing = false;
+ }
+
+ if (this.isAdd) {
+ this.$root.addMaintenance(this.maintenance, async (res) => {
+ if (res.ok) {
+ await this.addMonitorMaintenance(res.maintenanceID, async () => {
+ await this.addMaintenanceStatusPage(res.maintenanceID, () => {
+ this.$root.toastRes(res);
+ this.processing = false;
+ this.$root.getMaintenanceList();
+ this.$router.push("/maintenance");
+ });
+ });
+ } else {
+ this.$root.toastRes(res);
+ this.processing = false;
+ }
+
+ });
+ } else {
+ this.$root.getSocket().emit("editMaintenance", this.maintenance, async (res) => {
+ if (res.ok) {
+ await this.addMonitorMaintenance(res.maintenanceID, async () => {
+ await this.addMaintenanceStatusPage(res.maintenanceID, () => {
+ this.processing = false;
+ this.$root.toastRes(res);
+ this.init();
+ this.$router.push("/maintenance");
+ });
+ });
+ } else {
+ this.processing = false;
+ this.$root.toastError(res.msg);
+ }
+ });
+ }
+ },
+
+ /**
+ * Add monitor to maintenance
+ * @param {number} maintenanceID ID of maintenance to modify
+ * @param {socketCB} callback Callback for socket response
+ * @returns {Promise<void>}
+ */
+ async addMonitorMaintenance(maintenanceID, callback) {
+ await this.$root.addMonitorMaintenance(maintenanceID, this.affectedMonitors, async (res) => {
+ if (!res.ok) {
+ this.$root.toastError(res.msg);
+ } else {
+ this.$root.getMonitorList();
+ }
+
+ callback();
+ });
+ },
+
+ /**
+ * Add status page to maintenance
+ * @param {number} maintenanceID ID of maintenance to modify
+ * @param {socketCB} callback Callback for socket response
+ * @returns {void}
+ */
+ async addMaintenanceStatusPage(maintenanceID, callback) {
+ await this.$root.addMaintenanceStatusPage(maintenanceID, (this.showOnAllPages) ? this.selectedStatusPagesOptions : this.selectedStatusPages, async (res) => {
+ if (!res.ok) {
+ this.$root.toastError(res.msg);
+ } else {
+ this.$root.getMaintenanceList();
+ }
+
+ callback();
+ });
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+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/pages/EditMonitor.vue b/src/pages/EditMonitor.vue
new file mode 100644
index 0000000..4763f87
--- /dev/null
+++ b/src/pages/EditMonitor.vue
@@ -0,0 +1,1858 @@
+<template>
+ <transition name="slide-fade" appear>
+ <div>
+ <h1 class="mb-3">{{ pageName }}</h1>
+ <form @submit.prevent="submit">
+ <div class="shadow-box shadow-box-with-fixed-bottom-bar">
+ <div class="row">
+ <div class="col-md-6">
+ <h2 class="mb-2">{{ $t("General") }}</h2>
+
+ <div class="my-3">
+ <label for="type" class="form-label">{{ $t("Monitor Type") }}</label>
+ <select id="type" v-model="monitor.type" class="form-select" data-testid="monitor-type-select">
+ <optgroup :label="$t('General Monitor Type')">
+ <option value="group">
+ {{ $t("Group") }}
+ </option>
+ <option value="http">
+ HTTP(s)
+ </option>
+ <option value="port">
+ TCP Port
+ </option>
+ <option value="ping">
+ Ping
+ </option>
+ <option value="snmp">
+ SNMP
+ </option>
+ <option value="keyword">
+ HTTP(s) - {{ $t("Keyword") }}
+ </option>
+ <option value="json-query">
+ HTTP(s) - {{ $t("Json Query") }}
+ </option>
+ <option value="grpc-keyword">
+ gRPC(s) - {{ $t("Keyword") }}
+ </option>
+ <option value="dns">
+ DNS
+ </option>
+ <option value="docker">
+ {{ $t("Docker Container") }}
+ </option>
+
+ <option value="real-browser">
+ HTTP(s) - Browser Engine (Chrome/Chromium) (Beta)
+ </option>
+ </optgroup>
+
+ <optgroup :label="$t('Passive Monitor Type')">
+ <option value="push">
+ Push
+ </option>
+ </optgroup>
+
+ <optgroup :label="$t('Specific Monitor Type')">
+ <option value="steam">
+ {{ $t("Steam Game Server") }}
+ </option>
+ <option value="gamedig">
+ GameDig
+ </option>
+ <option value="mqtt">
+ MQTT
+ </option>
+ <option value="rabbitmq">
+ RabbitMQ
+ </option>
+ <option value="kafka-producer">
+ Kafka Producer
+ </option>
+ <option value="sqlserver">
+ Microsoft SQL Server
+ </option>
+ <option value="postgres">
+ PostgreSQL
+ </option>
+ <option value="mysql">
+ MySQL/MariaDB
+ </option>
+ <option value="mongodb">
+ MongoDB
+ </option>
+ <option value="radius">
+ Radius
+ </option>
+ <option value="redis">
+ Redis
+ </option>
+ <option v-if="!$root.info.isContainer" value="tailscale-ping">
+ Tailscale Ping
+ </option>
+ </optgroup>
+ </select>
+ <i18n-t v-if="monitor.type === 'rabbitmq'" keypath="rabbitmqHelpText" tag="div" class="form-text">
+ <template #rabitmq_documentation>
+ <a href="https://www.rabbitmq.com/management" target="_blank" rel="noopener noreferrer">
+ RabbitMQ documentation
+ </a>
+ </template>
+ </i18n-t>
+ </div>
+
+ <div v-if="monitor.type === 'tailscale-ping'" class="alert alert-warning" role="alert">
+ {{ $t("tailscalePingWarning") }}
+ </div>
+
+ <!-- Friendly Name -->
+ <div class="my-3">
+ <label for="name" class="form-label">{{ $t("Friendly Name") }}</label>
+ <input id="name" v-model="monitor.name" type="text" class="form-control" required data-testid="friendly-name-input">
+ </div>
+
+ <!-- URL -->
+ <div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'real-browser' " class="my-3">
+ <label for="url" class="form-label">{{ $t("URL") }}</label>
+ <input id="url" v-model="monitor.url" type="url" class="form-control" pattern="https?://.+" required data-testid="url-input">
+ </div>
+
+ <!-- gRPC URL -->
+ <div v-if="monitor.type === 'grpc-keyword' " class="my-3">
+ <label for="grpc-url" class="form-label">{{ $t("URL") }}</label>
+ <input id="grpc-url" v-model="monitor.grpcUrl" type="text" class="form-control" required>
+ </div>
+
+ <!-- Push URL -->
+ <div v-if="monitor.type === 'push' " class="my-3">
+ <label for="push-url" class="form-label">{{ $t("PushUrl") }}</label>
+ <CopyableInput id="push-url" v-model="pushURL" type="url" disabled="disabled" />
+ <div class="form-text">
+ {{ $t("needPushEvery", [monitor.interval]) }}<br />
+ {{ $t("pushOptionalParams", ["status, msg, ping"]) }}
+ </div>
+ <button class="btn btn-primary" type="button" @click="resetToken">
+ {{ $t("Reset Token") }}
+ </button>
+ </div>
+
+ <!-- Keyword -->
+ <div v-if="monitor.type === 'keyword' || monitor.type === 'grpc-keyword'" class="my-3">
+ <label for="keyword" class="form-label">{{ $t("Keyword") }}</label>
+ <input id="keyword" v-model="monitor.keyword" type="text" class="form-control" required>
+ <div class="form-text">
+ {{ $t("keywordDescription") }}
+ </div>
+ </div>
+
+ <!-- Invert keyword -->
+ <div v-if="monitor.type === 'keyword' || monitor.type === 'grpc-keyword'" class="my-3 form-check">
+ <input id="invert-keyword" v-model="monitor.invertKeyword" class="form-check-input" type="checkbox">
+ <label class="form-check-label" for="invert-keyword">
+ {{ $t("Invert Keyword") }}
+ </label>
+ <div class="form-text">
+ {{ $t("invertKeywordDescription") }}
+ </div>
+ </div>
+
+ <!-- Remote Browser -->
+ <div v-if="monitor.type === 'real-browser'" class="my-3">
+ <!-- Toggle -->
+ <div class="my-3 form-check">
+ <input id="toggle" v-model="remoteBrowsersToggle" class="form-check-input" type="checkbox">
+ <label class="form-check-label" for="toggle">
+ {{ $t("useRemoteBrowser") }}
+ </label>
+ <div class="form-text">
+ {{ $t("remoteBrowserToggle") }}
+ </div>
+ </div>
+
+ <div v-if="remoteBrowsersToggle">
+ <label for="remote-browser" class="form-label">{{ $t("Remote Browser") }}</label>
+ <ActionSelect
+ v-model="monitor.remote_browser"
+ :options="remoteBrowsersOptions"
+ icon="plus"
+ :action="() => $refs.remoteBrowserDialog.show()"
+ />
+ </div>
+ </div>
+
+ <!-- Game -->
+ <!-- GameDig only -->
+ <div v-if="monitor.type === 'gamedig'" class="my-3">
+ <label for="game" class="form-label"> {{ $t("Game") }} </label>
+ <select id="game" v-model="monitor.game" class="form-select" required>
+ <option v-for="game in gameList" :key="game.keys[0]" :value="game.keys[0]">
+ {{ game.pretty }}
+ </option>
+ </select>
+ </div>
+
+ <template v-if="monitor.type === 'kafka-producer'">
+ <!-- Kafka Brokers List -->
+ <div class="my-3">
+ <label for="kafkaProducerBrokers" class="form-label">{{ $t("Kafka Brokers") }}</label>
+ <VueMultiselect
+ id="kafkaProducerBrokers"
+ v-model="monitor.kafkaProducerBrokers"
+ :multiple="true"
+ :options="[]"
+ :placeholder="$t('Enter the list of brokers')"
+ :tag-placeholder="$t('Press Enter to add broker')"
+ :max-height="500"
+ :taggable="true"
+ :show-no-options="false"
+ :close-on-select="false"
+ :clear-on-select="false"
+ :preserve-search="false"
+ :preselect-first="false"
+ @tag="addKafkaProducerBroker"
+ ></VueMultiselect>
+ </div>
+
+ <!-- Kafka Topic Name -->
+ <div class="my-3">
+ <label for="kafkaProducerTopic" class="form-label">{{ $t("Kafka Topic Name") }}</label>
+ <input id="kafkaProducerTopic" v-model="monitor.kafkaProducerTopic" type="text" class="form-control" required>
+ </div>
+
+ <!-- Kafka Producer Message -->
+ <div class="my-3">
+ <label for="kafkaProducerMessage" class="form-label">{{ $t("Kafka Producer Message") }}</label>
+ <input id="kafkaProducerMessage" v-model="monitor.kafkaProducerMessage" type="text" class="form-control" required>
+ </div>
+
+ <!-- Kafka SSL -->
+ <div class="my-3 form-check">
+ <input id="kafkaProducerSsl" v-model="monitor.kafkaProducerSsl" class="form-check-input" type="checkbox">
+ <label class="form-check-label" for="kafkaProducerSsl">
+ {{ $t("Enable Kafka SSL") }}
+ </label>
+ </div>
+
+ <!-- Kafka SSL -->
+ <div class="my-3 form-check">
+ <input id="kafkaProducerAllowAutoTopicCreation" v-model="monitor.kafkaProducerAllowAutoTopicCreation" class="form-check-input" type="checkbox">
+ <label class="form-check-label" for="kafkaProducerAllowAutoTopicCreation">
+ {{ $t("Enable Kafka Producer Auto Topic Creation") }}
+ </label>
+ </div>
+ </template>
+
+ <template v-if="monitor.type === 'rabbitmq'">
+ <!-- RabbitMQ Nodes List -->
+ <div class="my-3">
+ <label for="rabbitmqNodes" class="form-label">{{ $t("RabbitMQ Nodes") }}</label>
+ <VueMultiselect
+ id="rabbitmqNodes"
+ v-model="monitor.rabbitmqNodes"
+ :required="true"
+ :multiple="true"
+ :options="[]"
+ :placeholder="$t('Enter the list of nodes')"
+ :tag-placeholder="$t('Press Enter to add node')"
+ :max-height="500"
+ :taggable="true"
+ :show-no-options="false"
+ :close-on-select="false"
+ :clear-on-select="false"
+ :preserve-search="false"
+ :preselect-first="false"
+ @tag="addRabbitmqNode"
+ ></VueMultiselect>
+ <div class="form-text">
+ {{ $t("rabbitmqNodesDescription", ["https://node1.rabbitmq.com:15672"]) }}
+ </div>
+ </div>
+
+ <div class="my-3">
+ <label for="rabbitmqUsername" class="form-label">RabbitMQ {{ $t("RabbitMQ Username") }}</label>
+ <input id="rabbitmqUsername" v-model="monitor.rabbitmqUsername" type="text" required class="form-control">
+ </div>
+
+ <div class="my-3">
+ <label for="rabbitmqPassword" class="form-label">{{ $t("RabbitMQ Password") }}</label>
+ <HiddenInput id="rabbitmqPassword" v-model="monitor.rabbitmqPassword" autocomplete="false" required="true"></HiddenInput>
+ </div>
+ </template>
+
+ <!-- Hostname -->
+ <!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping / SNMP only -->
+ <div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'tailscale-ping' || monitor.type === 'snmp'" class="my-3">
+ <label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
+ <input
+ id="hostname"
+ v-model="monitor.hostname"
+ type="text"
+ class="form-control"
+ :pattern="`${monitor.type === 'mqtt' ? mqttIpOrHostnameRegexPattern : ipOrHostnameRegexPattern}`"
+ required
+ data-testid="hostname-input"
+ >
+ </div>
+
+ <!-- Port -->
+ <!-- For TCP Port / Steam / MQTT / Radius Type / SNMP -->
+ <div v-if="monitor.type === 'port' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'snmp'" class="my-3">
+ <label for="port" class="form-label">{{ $t("Port") }}</label>
+ <input id="port" v-model="monitor.port" type="number" class="form-control" required min="0" max="65535" step="1">
+ </div>
+
+ <!-- SNMP Monitor Type -->
+ <div v-if="monitor.type === 'snmp'" class="my-3">
+ <label for="snmp_community_string" class="form-label">{{ $t("Community String") }}</label>
+ <!-- TODO: Rename monitor.radiusPassword to monitor.password for general use -->
+ <HiddenInput id="snmp_community_string" v-model="monitor.radiusPassword" autocomplete="false" required="true" placeholder="public"></HiddenInput>
+
+ <div class="form-text">{{ $t('snmpCommunityStringHelptext') }}</div>
+ </div>
+
+ <div v-if="monitor.type === 'snmp'" class="my-3">
+ <label for="snmp_oid" class="form-label">{{ $t("OID (Object Identifier)") }}</label>
+ <input id="snmp_oid" v-model="monitor.snmpOid" :title="$t('Please enter a valid OID.') + ' ' + $t('Example:', ['1.3.6.1.4.1.9.6.1.101'])" type="text" class="form-control" pattern="^([0-2])((\.0)|(\.[1-9][0-9]*))*$" placeholder="1.3.6.1.4.1.9.6.1.101" required>
+ <div class="form-text">{{ $t('snmpOIDHelptext') }} </div>
+ </div>
+
+ <div v-if="monitor.type === 'snmp'" class="my-3">
+ <label for="snmp_version" class="form-label">{{ $t("SNMP Version") }}</label>
+ <select id="snmp_version" v-model="monitor.snmpVersion" class="form-select">
+ <option value="1">
+ SNMPv1
+ </option>
+ <option value="2c">
+ SNMPv2c
+ </option>
+ </select>
+ </div>
+
+ <!-- Json Query -->
+ <!-- For Json Query / SNMP -->
+ <div v-if="monitor.type === 'json-query' || monitor.type === 'snmp'" class="my-3">
+ <div class="my-2">
+ <label for="jsonPath" class="form-label mb-0">{{ $t("Json Query Expression") }}</label>
+ <i18n-t tag="div" class="form-text mb-2" keypath="jsonQueryDescription">
+ <a href="https://jsonata.org/">jsonata.org</a>
+ <a href="https://try.jsonata.org/">{{ $t('playground') }}</a>
+ </i18n-t>
+ <input id="jsonPath" v-model="monitor.jsonPath" type="text" class="form-control" placeholder="$" required>
+ </div>
+
+ <div class="d-flex align-items-start">
+ <div class="me-2">
+ <label for="json_path_operator" class="form-label">{{ $t("Condition") }}</label>
+ <select id="json_path_operator" v-model="monitor.jsonPathOperator" class="form-select me-3" required>
+ <option value=">">&gt;</option>
+ <option value=">=">&gt;=</option>
+ <option value="<">&lt;</option>
+ <option value="<=">&lt;=</option>
+ <option value="!=">&#33;=</option>
+ <option value="==">==</option>
+ <option value="contains">contains</option>
+ </select>
+ </div>
+ <div class="flex-grow-1">
+ <label for="expectedValue" class="form-label">{{ $t("Expected Value") }}</label>
+ <input v-if="monitor.jsonPathOperator !== 'contains' && monitor.jsonPathOperator !== '==' && monitor.jsonPathOperator !== '!='" id="expectedValue" v-model="monitor.expectedValue" type="number" class="form-control" required step=".01">
+ <input v-else id="expectedValue" v-model="monitor.expectedValue" type="text" class="form-control" required>
+ </div>
+ </div>
+ </div>
+
+ <!-- DNS Resolver Server -->
+ <!-- For DNS Type -->
+ <template v-if="monitor.type === 'dns'">
+ <div class="my-3">
+ <label for="dns_resolve_server" class="form-label">{{ $t("Resolver Server") }}</label>
+ <input id="dns_resolve_server" v-model="monitor.dns_resolve_server" type="text" class="form-control" :pattern="ipRegex" required>
+ <div class="form-text">
+ {{ $t("resolverserverDescription") }}
+ </div>
+ </div>
+
+ <!-- Port -->
+ <div class="my-3">
+ <label for="port" class="form-label">{{ $t("Port") }}</label>
+ <input id="port" v-model="monitor.port" type="number" class="form-control" required min="0" max="65535" step="1">
+ <div class="form-text">
+ {{ $t("dnsPortDescription") }}
+ </div>
+ </div>
+
+ <div class="my-3">
+ <label for="dns_resolve_type" class="form-label">{{ $t("Resource Record Type") }}</label>
+
+ <!-- :allow-empty="false" is not working, set a default value instead https://github.com/shentao/vue-multiselect/issues/336 -->
+ <VueMultiselect
+ id="dns_resolve_type"
+ v-model="monitor.dns_resolve_type"
+ :options="dnsresolvetypeOptions"
+ :multiple="false"
+ :close-on-select="true"
+ :clear-on-select="false"
+ :preserve-search="false"
+ :placeholder="$t('Pick a RR-Type...')"
+ :preselect-first="false"
+ :max-height="500"
+ :taggable="false"
+ data-testid="resolve-type-select"
+ ></VueMultiselect>
+
+ <div class="form-text">
+ {{ $t("rrtypeDescription") }}
+ </div>
+ </div>
+ </template>
+
+ <!-- Docker Container Name / ID -->
+ <!-- For Docker Type -->
+ <div v-if="monitor.type === 'docker'" class="my-3">
+ <label for="docker_container" class="form-label">{{ $t("Container Name / ID") }}</label>
+ <input id="docker_container" v-model="monitor.docker_container" type="text" class="form-control" required>
+ </div>
+
+ <!-- Docker Host -->
+ <!-- For Docker Type -->
+ <div v-if="monitor.type === 'docker'" class="my-3">
+ <div class="mb-3">
+ <label for="docker-host" class="form-label">{{ $t("Docker Host") }}</label>
+ <ActionSelect
+ id="docker-host"
+ v-model="monitor.docker_host"
+ :action-aria-label="$t('openModalTo', $t('Setup Docker Host'))"
+ :options="dockerHostOptionsList"
+ :disabled="$root.dockerHostList == null || $root.dockerHostList.length === 0"
+ :icon="'plus'"
+ :action="() => $refs.dockerHostDialog.show()"
+ :required="true"
+ />
+ </div>
+ </div>
+
+ <!-- MQTT -->
+ <!-- For MQTT Type -->
+ <template v-if="monitor.type === 'mqtt'">
+ <div class="my-3">
+ <label for="mqttUsername" class="form-label">MQTT {{ $t("Username") }}</label>
+ <input id="mqttUsername" v-model="monitor.mqttUsername" type="text" class="form-control">
+ </div>
+
+ <div class="my-3">
+ <label for="mqttPassword" class="form-label">MQTT {{ $t("Password") }}</label>
+ <input id="mqttPassword" v-model="monitor.mqttPassword" type="password" class="form-control">
+ </div>
+
+ <div class="my-3">
+ <label for="mqttTopic" class="form-label">MQTT {{ $t("Topic") }}</label>
+ <input id="mqttTopic" v-model="monitor.mqttTopic" type="text" class="form-control" required>
+ <div class="form-text">
+ {{ $t("topicExplanation") }}
+ </div>
+ </div>
+
+ <div class="my-3">
+ <label for="mqttCheckType" class="form-label">MQTT {{ $t("Check Type") }}</label>
+ <select id="mqttCheckType" v-model="monitor.mqttCheckType" class="form-select" required>
+ <option value="keyword">{{ $t("Keyword") }}</option>
+ <option value="json-query">{{ $t("Json Query") }}</option>
+ </select>
+ </div>
+
+ <div v-if="monitor.mqttCheckType === 'keyword'" class="my-3">
+ <label for="mqttSuccessKeyword" class="form-label">MQTT {{ $t("successKeyword") }}</label>
+ <input id="mqttSuccessKeyword" v-model="monitor.mqttSuccessMessage" type="text" class="form-control">
+ <div class="form-text">
+ {{ $t("successKeywordExplanation") }}
+ </div>
+ </div>
+
+ <!-- Json Query -->
+ <div v-if="monitor.mqttCheckType === 'json-query'" class="my-3">
+ <label for="jsonPath" class="form-label">{{ $t("Json Query") }}</label>
+ <input id="jsonPath" v-model="monitor.jsonPath" type="text" class="form-control" required>
+
+ <i18n-t tag="div" class="form-text" keypath="jsonQueryDescription">
+ <a href="https://jsonata.org/">jsonata.org</a>
+ <a href="https://try.jsonata.org/">{{ $t('here') }}</a>
+ </i18n-t>
+ <br>
+
+ <label for="expectedValue" class="form-label">{{ $t("Expected Value") }}</label>
+ <input id="expectedValue" v-model="monitor.expectedValue" type="text" class="form-control" required>
+ </div>
+ </template>
+
+ <template v-if="monitor.type === 'radius'">
+ <div class="my-3">
+ <label for="radius_username" class="form-label">Radius {{ $t("Username") }}</label>
+ <input id="radius_username" v-model="monitor.radiusUsername" type="text" class="form-control" required />
+ </div>
+
+ <div class="my-3">
+ <label for="radius_password" class="form-label">Radius {{ $t("Password") }}</label>
+ <input id="radius_password" v-model="monitor.radiusPassword" type="password" class="form-control" required />
+ </div>
+
+ <div class="my-3">
+ <label for="radius_secret" class="form-label">{{ $t("RadiusSecret") }}</label>
+ <input id="radius_secret" v-model="monitor.radiusSecret" type="password" class="form-control" required />
+ <div class="form-text"> {{ $t( "RadiusSecretDescription") }} </div>
+ </div>
+
+ <div class="my-3">
+ <label for="radius_called_station_id" class="form-label">{{ $t("RadiusCalledStationId") }}</label>
+ <input id="radius_called_station_id" v-model="monitor.radiusCalledStationId" type="text" class="form-control" required />
+ <div class="form-text"> {{ $t( "RadiusCalledStationIdDescription") }} </div>
+ </div>
+
+ <div class="my-3">
+ <label for="radius_calling_station_id" class="form-label">{{ $t("RadiusCallingStationId") }}</label>
+ <input id="radius_calling_station_id" v-model="monitor.radiusCallingStationId" type="text" class="form-control" required />
+ <div class="form-text"> {{ $t( "RadiusCallingStationIdDescription") }} </div>
+ </div>
+ </template>
+
+ <!-- SQL Server / PostgreSQL / MySQL / Redis / MongoDB -->
+ <template v-if="monitor.type === 'sqlserver' || monitor.type === 'postgres' || monitor.type === 'mysql' || monitor.type === 'redis' || monitor.type === 'mongodb'">
+ <div class="my-3">
+ <label for="connectionString" class="form-label">{{ $t("Connection String") }}</label>
+ <input id="connectionString" v-model="monitor.databaseConnectionString" type="text" class="form-control" required>
+ </div>
+ </template>
+
+ <template v-if="monitor.type === 'mysql'">
+ <div class="my-3">
+ <label for="mysql-password" class="form-label">{{ $t("Password") }}</label>
+ <!-- TODO: Rename monitor.radiusPassword to monitor.password for general use -->
+ <HiddenInput id="mysql-password" v-model="monitor.radiusPassword" autocomplete="false"></HiddenInput>
+ </div>
+ </template>
+
+ <!-- SQL Server / PostgreSQL / MySQL -->
+ <template v-if="monitor.type === 'sqlserver' || monitor.type === 'postgres' || monitor.type === 'mysql'">
+ <div class="my-3">
+ <label for="sqlQuery" class="form-label">{{ $t("Query") }}</label>
+ <textarea id="sqlQuery" v-model="monitor.databaseQuery" class="form-control" :placeholder="$t('Example:', [ 'SELECT 1' ])"></textarea>
+ </div>
+ </template>
+
+ <!-- MongoDB -->
+ <template v-if="monitor.type === 'mongodb'">
+ <div class="my-3">
+ <label for="mongodbCommand" class="form-label">{{ $t("Command") }}</label>
+ <textarea id="mongodbCommand" v-model="monitor.databaseQuery" class="form-control" :placeholder="$t('Example:', [ '{ &quot;ping&quot;: 1 }' ])"></textarea>
+ <i18n-t tag="div" class="form-text" keypath="mongodbCommandDescription">
+ <template #documentation>
+ <a href="https://www.mongodb.com/docs/manual/reference/command/">{{ $t('documentationOf', ['MongoDB']) }}</a>
+ </template>
+ </i18n-t>
+ </div>
+ <div class="my-3">
+ <label for="jsonPath" class="form-label">{{ $t("Json Query") }}</label>
+ <input id="jsonPath" v-model="monitor.jsonPath" type="text" class="form-control">
+
+ <i18n-t tag="div" class="form-text" keypath="jsonQueryDescription">
+ <a href="https://jsonata.org/">jsonata.org</a>
+ <a href="https://try.jsonata.org/">{{ $t('here') }}</a>
+ </i18n-t>
+ </div>
+ <div class="my-3">
+ <label for="expectedValue" class="form-label">{{ $t("Expected Value") }}</label>
+ <input id="expectedValue" v-model="monitor.expectedValue" type="text" class="form-control">
+ </div>
+ </template>
+
+ <!-- Conditions -->
+ <EditMonitorConditions
+ v-if="supportsConditions && conditionVariables.length > 0"
+ v-model="monitor.conditions"
+ :condition-variables="conditionVariables"
+ class="my-3"
+ />
+
+ <!-- Interval -->
+ <div class="my-3">
+ <label for="interval" class="form-label">{{ $t("Heartbeat Interval") }} ({{ $t("checkEverySecond", [ monitor.interval ]) }})</label>
+ <input id="interval" v-model="monitor.interval" type="number" class="form-control" required :min="minInterval" step="1" :max="maxInterval" @blur="finishUpdateInterval">
+ </div>
+
+ <div class="my-3">
+ <label for="maxRetries" class="form-label">{{ $t("Retries") }}</label>
+ <input id="maxRetries" v-model="monitor.maxretries" type="number" class="form-control" required min="0" step="1">
+ <div class="form-text">
+ {{ $t("retriesDescription") }}
+ </div>
+ </div>
+
+ <div class="my-3">
+ <label for="retry-interval" class="form-label">
+ {{ $t("Heartbeat Retry Interval") }}
+ <span>({{ $t("retryCheckEverySecond", [ monitor.retryInterval ]) }})</span>
+ </label>
+ <input id="retry-interval" v-model="monitor.retryInterval" type="number" class="form-control" required :min="minInterval" step="1">
+ </div>
+
+ <!-- Timeout: HTTP / Keyword / SNMP only -->
+ <div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'snmp' || monitor.type === 'rabbitmq'" class="my-3">
+ <label for="timeout" class="form-label">{{ $t("Request Timeout") }} ({{ $t("timeoutAfter", [ monitor.timeout || clampTimeout(monitor.interval) ]) }})</label>
+ <input id="timeout" v-model="monitor.timeout" type="number" class="form-control" required min="0" step="0.1">
+ </div>
+
+ <div class="my-3">
+ <label for="resend-interval" class="form-label">
+ {{ $t("Resend Notification if Down X times consecutively") }}
+ <span v-if="monitor.resendInterval > 0">({{ $t("resendEveryXTimes", [ monitor.resendInterval ]) }})</span>
+ <span v-else>({{ $t("resendDisabled") }})</span>
+ </label>
+ <input id="resend-interval" v-model="monitor.resendInterval" type="number" class="form-control" required min="0" step="1">
+ </div>
+
+ <h2 v-if="monitor.type !== 'push'" class="mt-5 mb-2">{{ $t("Advanced") }}</h2>
+
+ <div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' " class="my-3 form-check" :title="monitor.ignoreTls ? $t('ignoredTLSError') : ''">
+ <input id="expiry-notification" v-model="monitor.expiryNotification" class="form-check-input" type="checkbox" :disabled="monitor.ignoreTls">
+ <label class="form-check-label" for="expiry-notification">
+ {{ $t("Certificate Expiry Notification") }}
+ </label>
+ <div class="form-text">
+ </div>
+ </div>
+
+ <div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'redis' " class="my-3 form-check">
+ <input id="ignore-tls" v-model="monitor.ignoreTls" class="form-check-input" type="checkbox" value="">
+ <label class="form-check-label" for="ignore-tls">
+ {{ monitor.type === "redis" ? $t("ignoreTLSErrorGeneral") : $t("ignoreTLSError") }}
+ </label>
+ </div>
+
+ <div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' " class="my-3 form-check">
+ <input id="cache-bust" v-model="monitor.cacheBust" class="form-check-input" type="checkbox" value="">
+ <label class="form-check-label" for="cache-bust">
+ <i18n-t tag="label" keypath="cacheBusterParam" class="form-check-label" for="cache-bust">
+ <code>uptime_kuma_cachebuster</code>
+ </i18n-t>
+ </label>
+ <div class="form-text">
+ {{ $t("cacheBusterParamDescription") }}
+ </div>
+ </div>
+
+ <div class="my-3 form-check">
+ <input id="upside-down" v-model="monitor.upsideDown" class="form-check-input" type="checkbox">
+ <label class="form-check-label" for="upside-down">
+ {{ $t("Upside Down Mode") }}
+ </label>
+ <div class="form-text">
+ {{ $t("upsideDownModeDescription") }}
+ </div>
+ </div>
+
+ <div v-if="monitor.type === 'gamedig'" class="my-3 form-check">
+ <input id="gamedig-guess-port" v-model="monitor.gamedigGivenPortOnly" :true-value="false" :false-value="true" class="form-check-input" type="checkbox">
+ <label class="form-check-label" for="gamedig-guess-port">
+ {{ $t("gamedigGuessPort") }}
+ </label>
+ <div class="form-text">
+ {{ $t("gamedigGuessPortDescription") }}
+ </div>
+ </div>
+
+ <!-- Ping packet size -->
+ <div v-if="monitor.type === 'ping'" class="my-3">
+ <label for="packet-size" class="form-label">{{ $t("Packet Size") }}</label>
+ <input id="packet-size" v-model="monitor.packetSize" type="number" class="form-control" required min="1" max="65500" step="1">
+ </div>
+
+ <!-- HTTP / Keyword only -->
+ <template v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'grpc-keyword' ">
+ <div class="my-3">
+ <label for="maxRedirects" class="form-label">{{ $t("Max. Redirects") }}</label>
+ <input id="maxRedirects" v-model="monitor.maxredirects" type="number" class="form-control" required min="0" step="1">
+ <div class="form-text">
+ {{ $t("maxRedirectDescription") }}
+ </div>
+ </div>
+
+ <div class="my-3">
+ <label for="acceptedStatusCodes" class="form-label">{{ $t("Accepted Status Codes") }}</label>
+
+ <VueMultiselect
+ id="acceptedStatusCodes"
+ v-model="monitor.accepted_statuscodes"
+ :options="acceptedStatusCodeOptions"
+ :multiple="true"
+ :close-on-select="false"
+ :clear-on-select="false"
+ :preserve-search="true"
+ :placeholder="$t('Pick Accepted Status Codes...')"
+ :preselect-first="false"
+ :max-height="600"
+ :taggable="true"
+ ></VueMultiselect>
+
+ <div class="form-text">
+ {{ $t("acceptedStatusCodesDescription") }}
+ </div>
+ </div>
+ </template>
+
+ <!-- Parent Monitor -->
+ <div class="my-3">
+ <label for="monitorGroupSelector" class="form-label">{{ $t("Monitor Group") }}</label>
+ <ActionSelect
+ id="monitorGroupSelector"
+ v-model="monitor.parent"
+ :action-aria-label="$t('openModalTo', 'setup a new monitor group')"
+ :options="parentMonitorOptionsList"
+ :disabled="sortedGroupMonitorList.length === 0 && draftGroupName == null"
+ :icon="'plus'"
+ :action="() => $refs.createGroupDialog.show()"
+ />
+ </div>
+
+ <!-- Description -->
+ <div class="my-3">
+ <label for="description" class="form-label">{{ $t("Description") }}</label>
+ <input id="description" v-model="monitor.description" type="text" class="form-control">
+ </div>
+
+ <div class="my-3">
+ <tags-manager ref="tagsManager" :pre-selected-tags="monitor.tags"></tags-manager>
+ </div>
+ </div>
+
+ <div class="col-md-6">
+ <div v-if="$root.isMobile" class="mt-3" />
+
+ <!-- Notifications -->
+ <h2 class="mb-2">{{ $t("Notifications") }}</h2>
+ <p v-if="$root.notificationList.length === 0">
+ {{ $t("Not available, please setup.") }}
+ </p>
+
+ <div v-for="notification in $root.notificationList" :key="notification.id" class="form-check form-switch my-3">
+ <input :id=" 'notification' + notification.id" v-model="monitor.notificationIDList[notification.id]" class="form-check-input" type="checkbox">
+
+ <label class="form-check-label" :for=" 'notification' + notification.id">
+ {{ notification.name }}
+ <a href="#" @click="$refs.notificationDialog.show(notification.id)">{{ $t("Edit") }}</a>
+ </label>
+
+ <span v-if="notification.isDefault == true" class="badge bg-primary ms-2">{{ $t("Default") }}</span>
+ </div>
+
+ <button class="btn btn-primary me-2" type="button" @click="$refs.notificationDialog.show()">
+ {{ $t("Setup Notification") }}
+ </button>
+
+ <!-- Proxies -->
+ <div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query'">
+ <h2 class="mt-5 mb-2">{{ $t("Proxy") }}</h2>
+ <p v-if="$root.proxyList.length === 0">
+ {{ $t("Not available, please setup.") }}
+ </p>
+
+ <div v-if="$root.proxyList.length > 0" class="form-check my-3">
+ <input id="proxy-disable" v-model="monitor.proxyId" :value="null" name="proxy" class="form-check-input" type="radio">
+ <label class="form-check-label" for="proxy-disable">{{ $t("No Proxy") }}</label>
+ </div>
+
+ <div v-for="proxy in $root.proxyList" :key="proxy.id" class="form-check my-3">
+ <input :id="`proxy-${proxy.id}`" v-model="monitor.proxyId" :value="proxy.id" name="proxy" class="form-check-input" type="radio">
+
+ <label class="form-check-label" :for="`proxy-${proxy.id}`">
+ {{ proxy.host }}:{{ proxy.port }} ({{ proxy.protocol }})
+ <a href="#" @click="$refs.proxyDialog.show(proxy.id)">{{ $t("Edit") }}</a>
+ </label>
+
+ <span v-if="proxy.default === true" class="badge bg-primary ms-2">{{ $t("default") }}</span>
+ </div>
+
+ <button class="btn btn-primary me-2" type="button" @click="$refs.proxyDialog.show()">
+ {{ $t("Setup Proxy") }}
+ </button>
+ </div>
+
+ <!-- Kafka SASL Options -->
+ <!-- Kafka Producer only -->
+ <template v-if="monitor.type === 'kafka-producer'">
+ <h2 class="mt-5 mb-2">{{ $t("Kafka SASL Options") }}</h2>
+ <div class="my-3">
+ <label class="form-label" for="kafkaProducerSaslMechanism">
+ {{ $t("Mechanism") }}
+ </label>
+ <VueMultiselect
+ id="kafkaProducerSaslMechanism"
+ v-model="monitor.kafkaProducerSaslOptions.mechanism"
+ :options="kafkaSaslMechanismOptions"
+ :multiple="false"
+ :clear-on-select="false"
+ :preserve-search="false"
+ :placeholder="$t('Pick a SASL Mechanism...')"
+ :preselect-first="false"
+ :max-height="500"
+ :allow-empty="false"
+ :taggable="false"
+ ></VueMultiselect>
+ </div>
+ <div v-if="monitor.kafkaProducerSaslOptions.mechanism !== 'None'">
+ <div v-if="monitor.kafkaProducerSaslOptions.mechanism !== 'aws'" class="my-3">
+ <label for="kafkaProducerSaslUsername" class="form-label">{{ $t("Username") }}</label>
+ <input id="kafkaProducerSaslUsername" v-model="monitor.kafkaProducerSaslOptions.username" type="text" autocomplete="kafkaProducerSaslUsername" class="form-control">
+ </div>
+ <div v-if="monitor.kafkaProducerSaslOptions.mechanism !== 'aws'" class="my-3">
+ <label for="kafkaProducerSaslPassword" class="form-label">{{ $t("Password") }}</label>
+ <input id="kafkaProducerSaslPassword" v-model="monitor.kafkaProducerSaslOptions.password" type="password" autocomplete="kafkaProducerSaslPassword" class="form-control">
+ </div>
+ <div v-if="monitor.kafkaProducerSaslOptions.mechanism === 'aws'" class="my-3">
+ <label for="kafkaProducerSaslAuthorizationIdentity" class="form-label">{{ $t("Authorization Identity") }}</label>
+ <input id="kafkaProducerSaslAuthorizationIdentity" v-model="monitor.kafkaProducerSaslOptions.authorizationIdentity" type="text" autocomplete="kafkaProducerSaslAuthorizationIdentity" class="form-control" required>
+ </div>
+ <div v-if="monitor.kafkaProducerSaslOptions.mechanism === 'aws'" class="my-3">
+ <label for="kafkaProducerSaslAccessKeyId" class="form-label">{{ $t("AccessKey Id") }}</label>
+ <input id="kafkaProducerSaslAccessKeyId" v-model="monitor.kafkaProducerSaslOptions.accessKeyId" type="text" autocomplete="kafkaProducerSaslAccessKeyId" class="form-control" required>
+ </div>
+ <div v-if="monitor.kafkaProducerSaslOptions.mechanism === 'aws'" class="my-3">
+ <label for="kafkaProducerSaslSecretAccessKey" class="form-label">{{ $t("Secret AccessKey") }}</label>
+ <input id="kafkaProducerSaslSecretAccessKey" v-model="monitor.kafkaProducerSaslOptions.secretAccessKey" type="password" autocomplete="kafkaProducerSaslSecretAccessKey" class="form-control" required>
+ </div>
+ <div v-if="monitor.kafkaProducerSaslOptions.mechanism === 'aws'" class="my-3">
+ <label for="kafkaProducerSaslSessionToken" class="form-label">{{ $t("Session Token") }}</label>
+ <input id="kafkaProducerSaslSessionToken" v-model="monitor.kafkaProducerSaslOptions.sessionToken" type="password" autocomplete="kafkaProducerSaslSessionToken" class="form-control">
+ </div>
+ </div>
+ </template>
+
+ <!-- HTTP Options -->
+ <template v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' ">
+ <h2 class="mt-5 mb-2">{{ $t("HTTP Options") }}</h2>
+
+ <!-- Method -->
+ <div class="my-3">
+ <label for="method" class="form-label">{{ $t("Method") }}</label>
+ <select id="method" v-model="monitor.method" class="form-select">
+ <option value="GET">
+ GET
+ </option>
+ <option value="POST">
+ POST
+ </option>
+ <option value="PUT">
+ PUT
+ </option>
+ <option value="PATCH">
+ PATCH
+ </option>
+ <option value="DELETE">
+ DELETE
+ </option>
+ <option value="HEAD">
+ HEAD
+ </option>
+ <option value="OPTIONS">
+ OPTIONS
+ </option>
+ </select>
+ </div>
+
+ <!-- Encoding -->
+ <div class="my-3">
+ <label for="httpBodyEncoding" class="form-label">{{ $t("Body Encoding") }}</label>
+ <select id="httpBodyEncoding" v-model="monitor.httpBodyEncoding" class="form-select">
+ <option value="json">JSON</option>
+ <option value="form">x-www-form-urlencoded</option>
+ <option value="xml">XML</option>
+ </select>
+ </div>
+
+ <!-- Body -->
+ <div class="my-3">
+ <label for="body" class="form-label">{{ $t("Body") }}</label>
+ <textarea id="body" v-model="monitor.body" class="form-control" :placeholder="bodyPlaceholder"></textarea>
+ </div>
+
+ <!-- Headers -->
+ <div class="my-3">
+ <label for="headers" class="form-label">{{ $t("Headers") }}</label>
+ <textarea id="headers" v-model="monitor.headers" class="form-control" :placeholder="headersPlaceholder"></textarea>
+ </div>
+
+ <!-- HTTP Auth -->
+ <h4 class="mt-5 mb-2">{{ $t("Authentication") }}</h4>
+
+ <!-- Method -->
+ <div class="my-3">
+ <label for="method" class="form-label">{{ $t("Method") }}</label>
+ <select id="method" v-model="monitor.authMethod" class="form-select">
+ <option :value="null">
+ {{ $t("None") }}
+ </option>
+ <option value="basic">
+ {{ $t("HTTP Basic Auth") }}
+ </option>
+ <option value="oauth2-cc">
+ {{ $t("OAuth2: Client Credentials") }}
+ </option>
+ <option value="ntlm">
+ NTLM
+ </option>
+ <option value="mtls">
+ mTLS
+ </option>
+ </select>
+ </div>
+ <template v-if="monitor.authMethod && monitor.authMethod !== null ">
+ <template v-if="monitor.authMethod === 'mtls' ">
+ <div class="my-3">
+ <label for="tls-cert" class="form-label">{{ $t("Cert") }}</label>
+ <textarea id="tls-cert" v-model="monitor.tlsCert" class="form-control" :placeholder="$t('Cert body')" required></textarea>
+ </div>
+ <div class="my-3">
+ <label for="tls-key" class="form-label">{{ $t("Key") }}</label>
+ <textarea id="tls-key" v-model="monitor.tlsKey" class="form-control" :placeholder="$t('Key body')" required></textarea>
+ </div>
+ <div class="my-3">
+ <label for="tls-ca" class="form-label">{{ $t("CA") }}</label>
+ <textarea id="tls-ca" v-model="monitor.tlsCa" class="form-control" :placeholder="$t('Server CA')"></textarea>
+ </div>
+ </template>
+ <template v-else-if="monitor.authMethod === 'oauth2-cc' ">
+ <div class="my-3">
+ <label for="oauth_auth_method" class="form-label">{{ $t("Authentication Method") }}</label>
+ <select id="oauth_auth_method" v-model="monitor.oauth_auth_method" class="form-select">
+ <option value="client_secret_basic">
+ {{ $t("Authorization Header") }}
+ </option>
+ <option value="client_secret_post">
+ {{ $t("Form Data Body") }}
+ </option>
+ </select>
+ </div>
+ <div class="my-3">
+ <label for="oauth_token_url" class="form-label">{{ $t("OAuth Token URL") }}</label>
+ <input id="oauth_token_url" v-model="monitor.oauth_token_url" type="text" class="form-control" :placeholder="$t('OAuth Token URL')" required>
+ </div>
+ <div class="my-3">
+ <label for="oauth_client_id" class="form-label">{{ $t("Client ID") }}</label>
+ <input id="oauth_client_id" v-model="monitor.oauth_client_id" type="text" class="form-control" :placeholder="$t('Client ID')" required>
+ </div>
+ <template v-if="monitor.oauth_auth_method === 'client_secret_post' || monitor.oauth_auth_method === 'client_secret_basic'">
+ <div class="my-3">
+ <label for="oauth_client_secret" class="form-label">{{ $t("Client Secret") }}</label>
+ <input id="oauth_client_secret" v-model="monitor.oauth_client_secret" type="password" class="form-control" :placeholder="$t('Client Secret')" required>
+ </div>
+ <div class="my-3">
+ <label for="oauth_scopes" class="form-label">{{ $t("OAuth Scope") }}</label>
+ <input id="oauth_scopes" v-model="monitor.oauth_scopes" type="text" class="form-control" :placeholder="$t('Optional: Space separated list of scopes')">
+ </div>
+ </template>
+ </template>
+ <template v-else>
+ <div class="my-3">
+ <label for="basicauth-user" class="form-label">{{ $t("Username") }}</label>
+ <input id="basicauth-user" v-model="monitor.basic_auth_user" type="text" class="form-control" :placeholder="$t('Username')">
+ </div>
+
+ <div class="my-3">
+ <label for="basicauth-pass" class="form-label">{{ $t("Password") }}</label>
+ <input id="basicauth-pass" v-model="monitor.basic_auth_pass" type="password" autocomplete="new-password" class="form-control" :placeholder="$t('Password')">
+ </div>
+ <template v-if="monitor.authMethod === 'ntlm' ">
+ <div class="my-3">
+ <label for="ntlm-domain" class="form-label">{{ $t("Domain") }}</label>
+ <input id="ntlm-domain" v-model="monitor.authDomain" type="text" class="form-control" :placeholder="$t('Domain')">
+ </div>
+
+ <div class="my-3">
+ <label for="ntlm-workstation" class="form-label">{{ $t("Workstation") }}</label>
+ <input id="ntlm-workstation" v-model="monitor.authWorkstation" type="text" class="form-control" :placeholder="$t('Workstation')">
+ </div>
+ </template>
+ </template>
+ </template>
+ </template>
+
+ <!-- gRPC Options -->
+ <template v-if="monitor.type === 'grpc-keyword' ">
+ <!-- Proto service enable TLS -->
+ <h2 class="mt-5 mb-2">{{ $t("GRPC Options") }}</h2>
+ <div class="my-3 form-check">
+ <input id="grpc-enable-tls" v-model="monitor.grpcEnableTls" class="form-check-input" type="checkbox" value="">
+ <label class="form-check-label" for="grpc-enable-tls">
+ {{ $t("Enable TLS") }}
+ </label>
+ <div class="form-text">
+ {{ $t("enableGRPCTls") }}
+ </div>
+ </div>
+ <!-- Proto service name data -->
+ <div class="my-3">
+ <label for="protobuf" class="form-label">{{ $t("Proto Service Name") }}</label>
+ <input id="name" v-model="monitor.grpcServiceName" type="text" class="form-control" :placeholder="protoServicePlaceholder" required>
+ </div>
+
+ <!-- Proto method data -->
+ <div class="my-3">
+ <label for="protobuf" class="form-label">{{ $t("Proto Method") }}</label>
+ <input id="name" v-model="monitor.grpcMethod" type="text" class="form-control" :placeholder="protoMethodPlaceholder" required>
+ <div class="form-text">
+ {{ $t("grpcMethodDescription") }}
+ </div>
+ </div>
+
+ <!-- Proto data -->
+ <div class="my-3">
+ <label for="protobuf" class="form-label">{{ $t("Proto Content") }}</label>
+ <textarea id="protobuf" v-model="monitor.grpcProtobuf" class="form-control" :placeholder="protoBufDataPlaceholder"></textarea>
+ </div>
+
+ <!-- Body -->
+ <div class="my-3">
+ <label for="body" class="form-label">{{ $t("Body") }}</label>
+ <textarea id="body" v-model="monitor.grpcBody" class="form-control" :placeholder="bodyPlaceholder"></textarea>
+ </div>
+
+ <!-- Metadata: temporary disable waiting for next PR allow to send gRPC with metadata -->
+ <template v-if="false">
+ <div class="my-3">
+ <label for="metadata" class="form-label">{{ $t("Metadata") }}</label>
+ <textarea id="metadata" v-model="monitor.grpcMetadata" class="form-control" :placeholder="headersPlaceholder"></textarea>
+ </div>
+ </template>
+ </template>
+ </div>
+ </div>
+
+ <div class="fixed-bottom-bar p-3">
+ <button
+ id="monitor-submit-btn"
+ class="btn btn-primary"
+ type="submit"
+ :disabled="processing"
+ data-testid="save-button"
+ >
+ {{ $t("Save") }}
+ </button>
+ </div>
+ </div>
+ </form>
+
+ <NotificationDialog ref="notificationDialog" @added="addedNotification" />
+ <DockerHostDialog ref="dockerHostDialog" @added="addedDockerHost" />
+ <ProxyDialog ref="proxyDialog" @added="addedProxy" />
+ <CreateGroupDialog ref="createGroupDialog" @added="addedDraftGroup" />
+ <RemoteBrowserDialog ref="remoteBrowserDialog" />
+ </div>
+ </transition>
+</template>
+
+<script>
+import VueMultiselect from "vue-multiselect";
+import { useToast } from "vue-toastification";
+import ActionSelect from "../components/ActionSelect.vue";
+import CopyableInput from "../components/CopyableInput.vue";
+import CreateGroupDialog from "../components/CreateGroupDialog.vue";
+import NotificationDialog from "../components/NotificationDialog.vue";
+import DockerHostDialog from "../components/DockerHostDialog.vue";
+import RemoteBrowserDialog from "../components/RemoteBrowserDialog.vue";
+import ProxyDialog from "../components/ProxyDialog.vue";
+import TagsManager from "../components/TagsManager.vue";
+import { genSecret, isDev, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND, sleep } from "../util.ts";
+import { hostNameRegexPattern } from "../util-frontend";
+import HiddenInput from "../components/HiddenInput.vue";
+import EditMonitorConditions from "../components/EditMonitorConditions.vue";
+
+const toast = useToast;
+
+const pushTokenLength = 32;
+
+const monitorDefaults = {
+ type: "http",
+ name: "",
+ parent: null,
+ url: "https://",
+ method: "GET",
+ interval: 60,
+ retryInterval: 60,
+ resendInterval: 0,
+ maxretries: 0,
+ notificationIDList: {},
+ ignoreTls: false,
+ upsideDown: false,
+ packetSize: 56,
+ expiryNotification: false,
+ maxredirects: 10,
+ accepted_statuscodes: [ "200-299" ],
+ dns_resolve_type: "A",
+ dns_resolve_server: "1.1.1.1",
+ docker_container: "",
+ docker_host: null,
+ proxyId: null,
+ mqttUsername: "",
+ mqttPassword: "",
+ mqttTopic: "",
+ mqttSuccessMessage: "",
+ mqttCheckType: "keyword",
+ authMethod: null,
+ oauth_auth_method: "client_secret_basic",
+ httpBodyEncoding: "json",
+ kafkaProducerBrokers: [],
+ kafkaProducerSaslOptions: {
+ mechanism: "None",
+ },
+ cacheBust: false,
+ kafkaProducerSsl: false,
+ kafkaProducerAllowAutoTopicCreation: false,
+ gamedigGivenPortOnly: true,
+ remote_browser: null,
+ rabbitmqNodes: [],
+ rabbitmqUsername: "",
+ rabbitmqPassword: "",
+ conditions: []
+};
+
+export default {
+ components: {
+ HiddenInput,
+ ActionSelect,
+ ProxyDialog,
+ CopyableInput,
+ CreateGroupDialog,
+ NotificationDialog,
+ DockerHostDialog,
+ RemoteBrowserDialog,
+ TagsManager,
+ VueMultiselect,
+ EditMonitorConditions,
+ },
+
+ data() {
+ return {
+ minInterval: MIN_INTERVAL_SECOND,
+ maxInterval: MAX_INTERVAL_SECOND,
+ processing: false,
+ monitor: {
+ notificationIDList: {},
+ // Do not add default value here, please check init() method
+ },
+ acceptedStatusCodeOptions: [],
+ dnsresolvetypeOptions: [],
+ kafkaSaslMechanismOptions: [],
+ ipOrHostnameRegexPattern: hostNameRegexPattern(),
+ mqttIpOrHostnameRegexPattern: hostNameRegexPattern(true),
+ gameList: null,
+ connectionStringTemplates: {
+ "sqlserver": "Server=<hostname>,<port>;Database=<your database>;User Id=<your user id>;Password=<your password>;Encrypt=<true/false>;TrustServerCertificate=<Yes/No>;Connection Timeout=<int>",
+ "postgres": "postgres://username:password@host:port/database",
+ "mysql": "mysql://username:password@host:port/database",
+ "redis": "redis://user:password@host:port",
+ "mongodb": "mongodb://username:password@host:port/database",
+ },
+ draftGroupName: null,
+ remoteBrowsersEnabled: false,
+ };
+ },
+
+ computed: {
+ ipRegex() {
+
+ // Allow to test with simple dns server with port (127.0.0.1:5300)
+ if (! isDev) {
+ return this.ipRegexPattern;
+ }
+ return null;
+ },
+
+ pageName() {
+ let name = "Add New Monitor";
+ if (this.isClone) {
+ name = "Clone Monitor";
+ } else if (this.isEdit) {
+ name = "Edit";
+ }
+ return this.$t(name);
+ },
+ remoteBrowsersOptions() {
+ return this.$root.remoteBrowserList.map(browser => {
+ return {
+ label: browser.name,
+ value: browser.id,
+ };
+ });
+ },
+ remoteBrowsersToggle: {
+ get() {
+ return this.remoteBrowsersEnabled || this.monitor.remote_browser != null;
+ },
+ set(value) {
+ if (value) {
+ this.remoteBrowsersEnabled = true;
+ if (this.monitor.remote_browser == null && this.$root.remoteBrowserList.length > 0) {
+ // set a default remote browser if there is one. Otherwise, the user will have to select one manually.
+ this.monitor.remote_browser = this.$root.remoteBrowserList[0].id;
+ }
+ } else {
+ this.remoteBrowsersEnabled = false;
+ this.monitor.remote_browser = null;
+ }
+ }
+ },
+ isAdd() {
+ return this.$route.path === "/add";
+ },
+
+ isClone() {
+ return this.$route.path.startsWith("/clone");
+ },
+
+ isEdit() {
+ return this.$route.path.startsWith("/edit");
+ },
+
+ pushURL() {
+ return this.$root.baseURL + "/api/push/" + this.monitor.pushToken + "?status=up&msg=OK&ping=";
+ },
+
+ protoServicePlaceholder() {
+ return this.$t("Example:", [ "Health" ]);
+ },
+
+ protoMethodPlaceholder() {
+ return this.$t("Example:", [ "check" ]);
+ },
+
+ protoBufDataPlaceholder() {
+ return this.$t("Example:", [ `
+syntax = "proto3";
+
+package grpc.health.v1;
+
+service Health {
+ rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
+ rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
+}
+
+message HealthCheckRequest {
+ string service = 1;
+}
+
+message HealthCheckResponse {
+ enum ServingStatus {
+ UNKNOWN = 0;
+ SERVING = 1;
+ NOT_SERVING = 2;
+ SERVICE_UNKNOWN = 3; // Used only by the Watch method.
+ }
+ ServingStatus status = 1;
+}
+ ` ]);
+ },
+ bodyPlaceholder() {
+ if (this.monitor && this.monitor.httpBodyEncoding && this.monitor.httpBodyEncoding === "xml") {
+ return this.$t("Example:", [ `
+<?xml version="1.0" encoding="utf-8"?>
+<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
+ <soap:Body>
+ <Uptime>Kuma</Uptime>
+ </soap:Body>
+</soap:Envelope>` ]);
+ }
+ if (this.monitor && this.monitor.httpBodyEncoding === "form") {
+ return this.$t("Example:", [ "key1=value1&key2=value2" ]);
+ }
+ return this.$t("Example:", [ `
+{
+ "key": "value"
+}` ]);
+ },
+
+ headersPlaceholder() {
+ return this.$t("Example:", [ `
+{
+ "HeaderName": "HeaderValue"
+}` ]);
+ },
+
+ currentGameObject() {
+ if (this.gameList) {
+ for (let game of this.gameList) {
+ if (game.keys[0] === this.monitor.game) {
+ return game;
+ }
+ }
+ }
+ return null;
+ },
+
+ // Filter result by active state, weight and alphabetical
+ // Only return groups which arent't itself and one of its decendants
+ sortedGroupMonitorList() {
+ let result = Object.values(this.$root.monitorList);
+
+ // Only groups, not itself, not a decendant
+ result = result.filter(
+ monitor => monitor.type === "group" &&
+ monitor.id !== this.monitor.id &&
+ !this.monitor.childrenIDs?.includes(monitor.id)
+ );
+
+ // Filter result by active state, weight and alphabetical
+ result.sort((m1, m2) => {
+
+ if (m1.active !== m2.active) {
+ if (m1.active === 0) {
+ return 1;
+ }
+
+ if (m2.active === 0) {
+ return -1;
+ }
+ }
+
+ if (m1.weight !== m2.weight) {
+ if (m1.weight > m2.weight) {
+ return -1;
+ }
+
+ if (m1.weight < m2.weight) {
+ return 1;
+ }
+ }
+
+ return m1.pathName.localeCompare(m2.pathName);
+ });
+
+ return result;
+ },
+
+ /**
+ * Generates the parent monitor options list based on the sorted group monitor list and draft group name.
+ * @returns {Array} The parent monitor options list.
+ */
+ parentMonitorOptionsList() {
+ let list = [];
+ if (this.sortedGroupMonitorList.length === 0 && this.draftGroupName == null) {
+ list = [
+ {
+ label: this.$t("noGroupMonitorMsg"),
+ value: null
+ }
+ ];
+ } else {
+ list = [
+ {
+ label: this.$t("None"),
+ value: null
+ },
+ ... this.sortedGroupMonitorList.map(monitor => {
+ return {
+ label: monitor.pathName,
+ value: monitor.id,
+ };
+ }),
+ ];
+ }
+
+ if (this.draftGroupName != null) {
+ list = [{
+ label: this.draftGroupName,
+ value: -1,
+ }].concat(list);
+ }
+
+ return list;
+ },
+
+ dockerHostOptionsList() {
+ if (this.$root.dockerHostList && this.$root.dockerHostList.length > 0) {
+ return this.$root.dockerHostList.map((host) => {
+ return {
+ label: host.name,
+ value: host.id
+ };
+ });
+ } else {
+ return [{
+ label: this.$t("noDockerHostMsg"),
+ value: null,
+ }];
+ }
+ },
+
+ supportsConditions() {
+ return this.$root.monitorTypeList[this.monitor.type]?.supportsConditions || false;
+ },
+
+ conditionVariables() {
+ return this.$root.monitorTypeList[this.monitor.type]?.conditionVariables || [];
+ },
+ },
+ watch: {
+ "$root.proxyList"() {
+ if (this.isAdd) {
+ if (this.$root.proxyList && !this.monitor.proxyId) {
+ const proxy = this.$root.proxyList.find(proxy => proxy.default);
+
+ if (proxy) {
+ this.monitor.proxyId = proxy.id;
+ }
+ }
+ }
+ },
+
+ "$route.fullPath"() {
+ this.init();
+ },
+
+ "monitor.interval"(value, oldValue) {
+ // Link interval and retryInterval if they are the same value.
+ if (this.monitor.retryInterval === oldValue) {
+ this.monitor.retryInterval = value;
+ }
+ },
+
+ "monitor.timeout"(value, oldValue) {
+ // keep timeout within 80% range
+ if (value && value !== oldValue) {
+ this.monitor.timeout = this.clampTimeout(value);
+ }
+ },
+
+ "monitor.type"(newType, oldType) {
+ if (this.monitor.type === "push") {
+ if (! this.monitor.pushToken) {
+ // ideally this would require checking if the generated token is already used
+ // it's very unlikely to get a collision though (62^32 ~ 2.27265788 * 10^57 unique tokens)
+ this.monitor.pushToken = genSecret(pushTokenLength);
+ }
+ }
+
+ // Set default port for DNS if not already defined
+ if (! this.monitor.port || this.monitor.port === "53" || this.monitor.port === "1812") {
+ if (this.monitor.type === "dns") {
+ this.monitor.port = "53";
+ } else if (this.monitor.type === "radius") {
+ this.monitor.port = "1812";
+ } else if (this.monitor.type === "snmp") {
+ this.monitor.port = "161";
+ } else {
+ this.monitor.port = undefined;
+ }
+ }
+
+ if (this.monitor.type === "snmp") {
+ // snmp is not expected to be executed via the internet => we can choose a lower default timeout
+ this.monitor.timeout = 5;
+ } else {
+ this.monitor.timeout = 48;
+ }
+
+ // Set default SNMP version
+ if (!this.monitor.snmpVersion) {
+ this.monitor.snmpVersion = "2c";
+ }
+
+ // Set default jsonPath
+ if (!this.monitor.jsonPath) {
+ this.monitor.jsonPath = "$";
+ }
+
+ // Set default condition for for jsonPathOperator
+ if (!this.monitor.jsonPathOperator) {
+ this.monitor.jsonPathOperator = "==";
+ }
+
+ // Get the game list from server
+ if (this.monitor.type === "gamedig") {
+ this.$root.getSocket().emit("getGameList", (res) => {
+ if (res.ok) {
+ this.gameList = res.gameList;
+ } else {
+ this.$root.toastError(res.msg);
+ }
+ });
+ }
+
+ // Set default database connection string if empty or it is a template from another database monitor type
+ for (let monitorType in this.connectionStringTemplates) {
+ if (this.monitor.type === monitorType) {
+ let isTemplate = false;
+ for (let key in this.connectionStringTemplates) {
+ if (this.monitor.databaseConnectionString === this.connectionStringTemplates[key]) {
+ isTemplate = true;
+ break;
+ }
+ }
+ if (!this.monitor.databaseConnectionString || isTemplate) {
+ this.monitor.databaseConnectionString = this.connectionStringTemplates[monitorType];
+ }
+ break;
+ }
+ }
+
+ // Reset conditions since condition variables likely change:
+ if (oldType && newType !== oldType) {
+ this.monitor.conditions = [];
+ }
+ },
+
+ currentGameObject(newGameObject, previousGameObject) {
+ if (!this.monitor.port || (previousGameObject && previousGameObject.options.port === this.monitor.port)) {
+ this.monitor.port = newGameObject.options.port;
+ }
+ this.monitor.game = newGameObject.keys[0];
+ },
+
+ "monitor.ignoreTls"(newVal) {
+ if (newVal) {
+ this.monitor.expiryNotification = false;
+ }
+ },
+ },
+ mounted() {
+ this.init();
+
+ let acceptedStatusCodeOptions = [
+ "100-199",
+ "200-299",
+ "300-399",
+ "400-499",
+ "500-599",
+ ];
+
+ let dnsresolvetypeOptions = [
+ "A",
+ "AAAA",
+ "CAA",
+ "CNAME",
+ "MX",
+ "NS",
+ "PTR",
+ "SOA",
+ "SRV",
+ "TXT",
+ ];
+
+ let kafkaSaslMechanismOptions = [
+ "None",
+ "plain",
+ "scram-sha-256",
+ "scram-sha-512",
+ "aws",
+ ];
+
+ for (let i = 100; i <= 999; i++) {
+ acceptedStatusCodeOptions.push(i.toString());
+ }
+
+ this.acceptedStatusCodeOptions = acceptedStatusCodeOptions;
+ this.dnsresolvetypeOptions = dnsresolvetypeOptions;
+ this.kafkaSaslMechanismOptions = kafkaSaslMechanismOptions;
+ },
+ methods: {
+ /**
+ * Initialize the edit monitor form
+ * @returns {void}
+ */
+ init() {
+ if (this.isAdd) {
+
+ this.monitor = {
+ ...monitorDefaults
+ };
+
+ if (this.$root.proxyList && !this.monitor.proxyId) {
+ const proxy = this.$root.proxyList.find(proxy => proxy.default);
+
+ if (proxy) {
+ this.monitor.proxyId = proxy.id;
+ }
+ }
+
+ for (let i = 0; i < this.$root.notificationList.length; i++) {
+ if (this.$root.notificationList[i].isDefault === true) {
+ this.monitor.notificationIDList[this.$root.notificationList[i].id] = true;
+ }
+ }
+ } else if (this.isEdit || this.isClone) {
+ this.$root.getSocket().emit("getMonitor", this.$route.params.id, (res) => {
+ if (res.ok) {
+
+ if (this.isClone) {
+ // Reset push token for cloned monitors
+ if (res.monitor.type === "push") {
+ res.monitor.pushToken = undefined;
+ }
+ }
+
+ this.monitor = res.monitor;
+
+ if (this.isClone) {
+ /*
+ * Cloning a monitor will include properties that can not be posted to backend
+ * as they are not valid columns in the SQLite table.
+ */
+ this.monitor.id = undefined; // Remove id when cloning as we want a new id
+ this.monitor.includeSensitiveData = undefined;
+ this.monitor.maintenance = undefined;
+ // group monitor fields
+ this.monitor.childrenIDs = undefined;
+ this.monitor.forceInactive = undefined;
+ this.monitor.path = undefined;
+ this.monitor.pathName = undefined;
+ this.monitor.screenshot = undefined;
+
+ this.monitor.name = this.$t("cloneOf", [ this.monitor.name ]);
+ this.$refs.tagsManager.newTags = this.monitor.tags.map((monitorTag) => {
+ return {
+ id: monitorTag.tag_id,
+ name: monitorTag.name,
+ color: monitorTag.color,
+ value: monitorTag.value,
+ new: true,
+ };
+ });
+ this.monitor.tags = undefined;
+ }
+
+ // Handling for monitors that are created before 1.7.0
+ if (this.monitor.retryInterval === 0) {
+ this.monitor.retryInterval = this.monitor.interval;
+ }
+ // Handling for monitors that are missing/zeroed timeout
+ if (!this.monitor.timeout) {
+ this.monitor.timeout = ~~(this.monitor.interval * 8) / 10;
+ }
+ } else {
+ this.$root.toastError(res.msg);
+ }
+ });
+ }
+
+ this.draftGroupName = null;
+
+ },
+
+ addKafkaProducerBroker(newBroker) {
+ this.monitor.kafkaProducerBrokers.push(newBroker);
+ },
+
+ addRabbitmqNode(newNode) {
+ this.monitor.rabbitmqNodes.push(newNode);
+ },
+
+ /**
+ * Validate form input
+ * @returns {boolean} Is the form input valid?
+ */
+ isInputValid() {
+ if (this.monitor.body && (!this.monitor.httpBodyEncoding || this.monitor.httpBodyEncoding === "json")) {
+ try {
+ JSON.parse(this.monitor.body);
+ } catch (err) {
+ toast.error(this.$t("BodyInvalidFormat") + err.message);
+ return false;
+ }
+ }
+ if (this.monitor.headers) {
+ try {
+ JSON.parse(this.monitor.headers);
+ } catch (err) {
+ toast.error(this.$t("HeadersInvalidFormat") + err.message);
+ return false;
+ }
+ }
+ if (this.monitor.type === "docker") {
+ if (this.monitor.docker_host == null) {
+ toast.error(this.$t("DockerHostRequired"));
+ return false;
+ }
+ }
+
+ if (this.monitor.type === "rabbitmq") {
+ if (this.monitor.rabbitmqNodes.length === 0) {
+ toast.error(this.$t("rabbitmqNodesRequired"));
+ return false;
+ }
+ if (!this.monitor.rabbitmqNodes.every(node => node.startsWith("http://") || node.startsWith("https://"))) {
+ toast.error(this.$t("rabbitmqNodesInvalid"));
+ return false;
+ }
+ }
+ return true;
+ },
+
+ resetToken() {
+ this.monitor.pushToken = genSecret(pushTokenLength);
+ },
+
+ /**
+ * Submit the form data for processing
+ * @returns {Promise<void>}
+ */
+ async submit() {
+
+ this.processing = true;
+
+ if (!this.isInputValid()) {
+ this.processing = false;
+ return;
+ }
+
+ // Beautify the JSON format (only if httpBodyEncoding is not set or === json)
+ if (this.monitor.body && (!this.monitor.httpBodyEncoding || this.monitor.httpBodyEncoding === "json")) {
+ this.monitor.body = JSON.stringify(JSON.parse(this.monitor.body), null, 4);
+ }
+
+ const monitorTypesWithEncodingAllowed = [ "http", "keyword", "json-query" ];
+ if (this.monitor.type && !monitorTypesWithEncodingAllowed.includes(this.monitor.type)) {
+ this.monitor.httpBodyEncoding = null;
+ }
+
+ if (this.monitor.headers) {
+ this.monitor.headers = JSON.stringify(JSON.parse(this.monitor.headers), null, 4);
+ }
+
+ if (this.monitor.hostname) {
+ this.monitor.hostname = this.monitor.hostname.trim();
+ }
+
+ if (this.monitor.url) {
+ this.monitor.url = this.monitor.url.trim();
+ }
+
+ let createdNewParent = false;
+
+ if (this.draftGroupName && this.monitor.parent === -1) {
+ // Create Monitor with name of draft group
+ const res = await new Promise((resolve) => {
+ this.$root.add({
+ ...monitorDefaults,
+ type: "group",
+ name: this.draftGroupName,
+ interval: this.monitor.interval,
+ active: false,
+ }, resolve);
+ });
+
+ if (res.ok) {
+ createdNewParent = true;
+ this.monitor.parent = res.monitorID;
+ } else {
+ this.$root.toastError(res.msg);
+ this.processing = false;
+ return;
+ }
+ }
+
+ if (this.isAdd || this.isClone) {
+ this.$root.add(this.monitor, async (res) => {
+
+ if (res.ok) {
+ await this.$refs.tagsManager.submit(res.monitorID);
+
+ // Start the new parent monitor after edit is done
+ if (createdNewParent) {
+ await this.startParentGroupMonitor();
+ }
+ this.processing = false;
+ this.$router.push("/dashboard/" + res.monitorID);
+ } else {
+ this.processing = false;
+ }
+
+ this.$root.toastRes(res);
+ });
+ } else {
+ await this.$refs.tagsManager.submit(this.monitor.id);
+
+ this.$root.getSocket().emit("editMonitor", this.monitor, (res) => {
+ this.processing = false;
+ this.$root.toastRes(res);
+ this.init();
+
+ // Start the new parent monitor after edit is done
+ if (createdNewParent) {
+ this.startParentGroupMonitor();
+ }
+ });
+ }
+ },
+
+ async startParentGroupMonitor() {
+ await sleep(2000);
+ await this.$root.getSocket().emit("resumeMonitor", this.monitor.parent, () => {});
+ },
+
+ /**
+ * Added a Notification Event
+ * Enable it if the notification is added in EditMonitor.vue
+ * @param {number} id ID of notification to add
+ * @returns {void}
+ */
+ addedNotification(id) {
+ this.monitor.notificationIDList[id] = true;
+ },
+
+ /**
+ * Added a Proxy Event
+ * Enable it if the proxy is added in EditMonitor.vue
+ * @param {number} id ID of proxy to add
+ * @returns {void}
+ */
+ addedProxy(id) {
+ this.monitor.proxyId = id;
+ },
+
+ /**
+ * Added a Docker Host Event
+ * Enable it if the Docker Host is added in EditMonitor.vue
+ * @param {number} id ID of docker host
+ * @returns {void}
+ */
+ addedDockerHost(id) {
+ this.monitor.docker_host = id;
+ },
+
+ /**
+ * Adds a draft group.
+ * @param {string} draftGroupName The name of the draft group.
+ * @returns {void}
+ */
+ addedDraftGroup(draftGroupName) {
+ this.draftGroupName = draftGroupName;
+ this.monitor.parent = -1;
+ },
+
+ // Clamp timeout
+ clampTimeout(timeout) {
+ // limit to 80% of interval, narrowly avoiding epsilon bug
+ const maxTimeout = ~~(this.monitor.interval * 8 ) / 10;
+ const clamped = Math.max(0, Math.min(timeout, maxTimeout));
+
+ // 0 will be treated as 80% of interval
+ return Number.isFinite(clamped) ? clamped : maxTimeout;
+ },
+
+ finishUpdateInterval() {
+ // Update timeout if it is greater than the clamp timeout
+ let clampedValue = this.clampTimeout(this.monitor.interval);
+ if (this.monitor.timeout > clampedValue) {
+ this.monitor.timeout = clampedValue;
+ }
+ },
+
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+ @import "../assets/vars.scss";
+
+ textarea {
+ min-height: 200px;
+ }
+</style>
diff --git a/src/pages/Entry.vue b/src/pages/Entry.vue
new file mode 100644
index 0000000..6b0e08f
--- /dev/null
+++ b/src/pages/Entry.vue
@@ -0,0 +1,54 @@
+<template>
+ <div>
+ <StatusPage v-if="statusPageSlug" :override-slug="statusPageSlug" />
+ </div>
+</template>
+
+<script>
+import axios from "axios";
+import StatusPage from "./StatusPage.vue";
+
+export default {
+ components: {
+ StatusPage,
+ },
+ data() {
+ return {
+ statusPageSlug: null,
+ };
+ },
+ async mounted() {
+
+ // There are only 3 cases that could come in here.
+ // 1. Matched status Page domain name
+ // 2. Vue Frontend Dev
+ // 3. Vue Frontend Dev (not setup database yet)
+ let res;
+ try {
+ res = (await axios.get("/api/entry-page")).data;
+
+ if (res.type === "statusPageMatchedDomain") {
+ this.statusPageSlug = res.statusPageSlug;
+ this.$root.forceStatusPageTheme = true;
+
+ } else if (res.type === "entryPage") { // Dev only. For production, the logic is in the server side
+ const entryPage = res.entryPage;
+ if (entryPage?.startsWith("statusPage-")) {
+ this.$router.push("/status/" + entryPage.replace("statusPage-", ""));
+ } else {
+ // should the old setting style still exist here?
+ this.$router.push("/dashboard");
+ }
+ } else if (res.type === "setup-database") {
+ this.$router.push("/setup-database");
+ } else {
+ this.$router.push("/dashboard");
+ }
+ } catch (e) {
+ alert("Cannot connect to the backend server. Did you start the backend server? (npm run start-server-dev)");
+ }
+
+ },
+
+};
+</script>
diff --git a/src/pages/List.vue b/src/pages/List.vue
new file mode 100644
index 0000000..dd2d460
--- /dev/null
+++ b/src/pages/List.vue
@@ -0,0 +1,24 @@
+<template>
+ <transition name="slide-fade" appear>
+ <MonitorList :scrollbar="true" />
+ </transition>
+</template>
+
+<script>
+import MonitorList from "../components/MonitorList.vue";
+
+export default {
+ components: {
+ MonitorList,
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars";
+
+.shadow-box {
+ padding: 20px;
+}
+
+</style>
diff --git a/src/pages/MaintenanceDetails.vue b/src/pages/MaintenanceDetails.vue
new file mode 100644
index 0000000..edcb3a0
--- /dev/null
+++ b/src/pages/MaintenanceDetails.vue
@@ -0,0 +1,169 @@
+<template>
+ <transition name="slide-fade" appear>
+ <div v-if="maintenance">
+ <h1>{{ maintenance.title }}</h1>
+ <p class="url">
+ <span>{{ $t("Start") }}: {{ $root.datetimeMaintenance(maintenance.start_date) }}</span>
+ <br>
+ <span>{{ $t("End") }}: {{ $root.datetimeMaintenance(maintenance.end_date) }}</span>
+ </p>
+
+ <div class="functions" style="margin-top: 10px;">
+ <router-link :to=" '/maintenance/edit/' + maintenance.id " class="btn btn-secondary">
+ <font-awesome-icon icon="edit" /> {{ $t("Edit") }}
+ </router-link>
+ <button class="btn btn-danger" @click="deleteDialog">
+ <font-awesome-icon icon="trash" /> {{ $t("Delete") }}
+ </button>
+ </div>
+
+ <label for="description" class="form-label" style="margin-top: 20px;">{{ $t("Description") }}</label>
+ <textarea id="description" v-model="maintenance.description" class="form-control" disabled></textarea>
+
+ <label for="affected_monitors" class="form-label" style="margin-top: 20px;">{{ $t("Affected Monitors") }}</label>
+ <br>
+ <button v-for="monitor in affectedMonitors" :key="monitor.id" class="btn btn-monitor" style="margin: 5px; cursor: auto; color: white; font-weight: 500;">
+ {{ monitor }}
+ </button>
+ <br />
+
+ <label for="selected_status_pages" class="form-label" style="margin-top: 20px;">{{ $t("Show this Maintenance Message on which Status Pages") }}</label>
+ <br>
+ <button v-for="statusPage in selectedStatusPages" :key="statusPage.id" class="btn btn-monitor" style="margin: 5px; cursor: auto; color: white; font-weight: 500;">
+ {{ statusPage }}
+ </button>
+
+ <Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteMaintenance">
+ {{ $t("deleteMaintenanceMsg") }}
+ </Confirm>
+ </div>
+ </transition>
+</template>
+
+<script>
+import { useToast } from "vue-toastification";
+const toast = useToast();
+import Confirm from "../components/Confirm.vue";
+
+export default {
+ components: {
+ Confirm,
+ },
+ data() {
+ return {
+ affectedMonitors: [],
+ selectedStatusPages: [],
+ };
+ },
+ computed: {
+ maintenance() {
+ let id = this.$route.params.id;
+ return this.$root.maintenanceList[id];
+ },
+ },
+ mounted() {
+ this.init();
+ },
+ methods: {
+ /**
+ * Initialise page
+ * @returns {void}
+ */
+ init() {
+ this.$root.getSocket().emit("getMonitorMaintenance", this.$route.params.id, (res) => {
+ if (res.ok) {
+ this.affectedMonitors = Object.values(res.monitors).map(monitor => monitor.name);
+ } else {
+ toast.error(res.msg);
+ }
+ });
+
+ this.$root.getSocket().emit("getMaintenanceStatusPage", this.$route.params.id, (res) => {
+ if (res.ok) {
+ this.selectedStatusPages = Object.values(res.statusPages).map(statusPage => statusPage.title);
+ } else {
+ toast.error(res.msg);
+ }
+ });
+ },
+
+ /**
+ * Confirm deletion
+ * @returns {void}
+ */
+ deleteDialog() {
+ this.$refs.confirmDelete.show();
+ },
+
+ /**
+ * Delete maintenance after showing confirmation
+ * @returns {void}
+ */
+ deleteMaintenance() {
+ this.$root.deleteMaintenance(this.maintenance.id, (res) => {
+ this.$root.toastRes(res);
+ this.$router.push("/maintenance");
+ });
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+@media (max-width: 550px) {
+ .functions {
+ text-align: center;
+
+ button, a {
+ margin-left: 10px !important;
+ margin-right: 10px !important;
+ }
+ }
+}
+
+@media (max-width: 400px) {
+ .btn {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ padding-top: 10px;
+ }
+
+ a.btn {
+ padding-left: 25px;
+ padding-right: 25px;
+ }
+}
+
+.url {
+ color: $primary;
+ margin-bottom: 20px;
+ font-weight: bold;
+
+ a {
+ color: $primary;
+ }
+}
+
+.functions {
+ button, a {
+ margin-right: 20px;
+ }
+}
+
+textarea {
+ min-height: 100px;
+ resize: none;
+}
+
+.btn-monitor {
+ background-color: #5cdd8b;
+}
+
+.dark .btn-monitor {
+ color: #020b05 !important;
+}
+
+</style>
diff --git a/src/pages/ManageMaintenance.vue b/src/pages/ManageMaintenance.vue
new file mode 100644
index 0000000..8378736
--- /dev/null
+++ b/src/pages/ManageMaintenance.vue
@@ -0,0 +1,317 @@
+<template>
+ <transition name="slide-fade" appear>
+ <div>
+ <h1 class="mb-3">
+ {{ $t("Maintenance") }}
+ </h1>
+
+ <div>
+ <router-link to="/add-maintenance" class="btn btn-primary mb-3">
+ <font-awesome-icon icon="plus" /> {{ $t("Schedule Maintenance") }}
+ </router-link>
+ </div>
+
+ <div class="shadow-box">
+ <span v-if="Object.keys(sortedMaintenanceList).length === 0" class="d-flex align-items-center justify-content-center my-3">
+ {{ $t("No Maintenance") }}
+ </span>
+
+ <div
+ v-for="(item, index) in sortedMaintenanceList"
+ :key="index"
+ class="item"
+ :class="item.status"
+ >
+ <div class="left-part">
+ <div
+ class="circle"
+ ></div>
+ <div class="info">
+ <div class="title">{{ item.title }}</div>
+ <div v-if="false">{{ item.description }}</div>
+ <div class="status">
+ {{ $t("maintenanceStatus-" + item.status) }}
+ </div>
+
+ <MaintenanceTime :maintenance="item" />
+ </div>
+ </div>
+
+ <div class="buttons">
+ <router-link v-if="false" :to="maintenanceURL(item.id)" class="btn btn-light">{{ $t("Details") }}</router-link>
+
+ <div class="btn-group" role="group">
+ <button v-if="item.active" class="btn btn-normal" @click="pauseDialog(item.id)">
+ <font-awesome-icon icon="pause" /> {{ $t("Pause") }}
+ </button>
+
+ <button v-if="!item.active" class="btn btn-primary" @click="resumeMaintenance(item.id)">
+ <font-awesome-icon icon="play" /> {{ $t("Resume") }}
+ </button>
+
+ <router-link :to="'/maintenance/edit/' + item.id" class="btn btn-normal">
+ <font-awesome-icon icon="edit" /> {{ $t("Edit") }}
+ </router-link>
+
+ <button class="btn btn-normal text-danger" @click="deleteDialog(item.id)">
+ <font-awesome-icon icon="trash" /> {{ $t("Delete") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="text-center mt-3" style="font-size: 13px;">
+ <a href="https://github.com/louislam/uptime-kuma/wiki/Maintenance" target="_blank">{{ $t("Learn More") }}</a>
+ </div>
+
+ <Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseMaintenance">
+ {{ $t("pauseMaintenanceMsg") }}
+ </Confirm>
+
+ <Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteMaintenance">
+ {{ $t("deleteMaintenanceMsg") }}
+ </Confirm>
+ </div>
+ </transition>
+</template>
+
+<script>
+import { getResBaseURL } from "../util-frontend";
+import { getMaintenanceRelativeURL } from "../util.ts";
+import Confirm from "../components/Confirm.vue";
+import MaintenanceTime from "../components/MaintenanceTime.vue";
+
+export default {
+ components: {
+ MaintenanceTime,
+ Confirm,
+ },
+ data() {
+ return {
+ selectedMaintenanceID: undefined,
+ statusOrderList: {
+ "under-maintenance": 1000,
+ "scheduled": 900,
+ "inactive": 800,
+ "ended": 700,
+ "unknown": 0,
+ }
+ };
+ },
+ computed: {
+ sortedMaintenanceList() {
+ let result = Object.values(this.$root.maintenanceList);
+
+ result.sort((m1, m2) => {
+ if (this.statusOrderList[m1.status] === this.statusOrderList[m2.status]) {
+ return m1.title.localeCompare(m2.title);
+ } else {
+ return this.statusOrderList[m1.status] < this.statusOrderList[m2.status];
+ }
+ });
+
+ return result;
+ },
+ },
+ mounted() {
+
+ },
+ methods: {
+ /**
+ * Get the correct URL for the icon
+ * @param {string} icon Path for icon
+ * @returns {string} Correctly formatted path including port numbers
+ */
+ icon(icon) {
+ if (icon === "/icon.svg") {
+ return icon;
+ } else {
+ return getResBaseURL() + icon;
+ }
+ },
+
+ /**
+ * Get maintenance URL
+ * @param {number} id ID of maintenance to read
+ * @returns {string} Relative URL
+ */
+ maintenanceURL(id) {
+ return getMaintenanceRelativeURL(id);
+ },
+
+ /**
+ * Show delete confirmation
+ * @param {number} maintenanceID ID of maintenance to show delete
+ * confirmation for.
+ * @returns {void}
+ */
+ deleteDialog(maintenanceID) {
+ this.selectedMaintenanceID = maintenanceID;
+ this.$refs.confirmDelete.show();
+ },
+
+ /**
+ * Delete maintenance after showing confirmation dialog
+ * @returns {void}
+ */
+ deleteMaintenance() {
+ this.$root.deleteMaintenance(this.selectedMaintenanceID, (res) => {
+ this.$root.toastRes(res);
+ if (res.ok) {
+ this.$router.push("/maintenance");
+ }
+ });
+ },
+
+ /**
+ * Show dialog to confirm pause
+ * @param {number} maintenanceID ID of maintenance to confirm
+ * pause.
+ * @returns {void}
+ */
+ pauseDialog(maintenanceID) {
+ this.selectedMaintenanceID = maintenanceID;
+ this.$refs.confirmPause.show();
+ },
+
+ /**
+ * Pause maintenance
+ * @returns {void}
+ */
+ pauseMaintenance() {
+ this.$root.getSocket().emit("pauseMaintenance", this.selectedMaintenanceID, (res) => {
+ this.$root.toastRes(res);
+ });
+ },
+
+ /**
+ * Resume maintenance
+ * @param {number} id ID of maintenance to resume
+ * @returns {void}
+ */
+ resumeMaintenance(id) {
+ this.$root.getSocket().emit("resumeMaintenance", 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;
+ }
+ }
+
+ .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;
+ }
+
+ &.under-maintenance {
+ background-color: rgba(23, 71, 245, 0.16);
+
+ &:hover {
+ background-color: rgba(23, 71, 245, 0.3) !important;
+ }
+
+ .circle {
+ background-color: $maintenance;
+ }
+ }
+
+ &.scheduled {
+ .circle {
+ background-color: $primary;
+ }
+ }
+
+ &.inactive {
+ .circle {
+ background-color: $danger;
+ }
+ }
+
+ &.ended {
+ .left-part {
+ opacity: 0.3;
+ }
+
+ .circle {
+ background-color: $dark-font-color;
+ }
+ }
+
+ &.unknown {
+ .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;
+
+ @media (max-width: 550px) {
+ & {
+ width: 100%;
+ }
+
+ .btn-group {
+ margin: 1em 1em 0 1em;
+ width: 100%;
+ }
+ }
+ }
+ }
+
+ .dark {
+ .item {
+ &:hover {
+ background-color: $dark-bg2;
+ }
+ }
+ }
+</style>
diff --git a/src/pages/ManageStatusPage.vue b/src/pages/ManageStatusPage.vue
new file mode 100644
index 0000000..e9d5863
--- /dev/null
+++ b/src/pages/ManageStatusPage.vue
@@ -0,0 +1,123 @@
+<template>
+ <transition name="slide-fade" appear>
+ <div>
+ <h1 class="mb-3">
+ {{ $t("Status Pages") }}
+ </h1>
+
+ <div>
+ <router-link to="/add-status-page" class="btn btn-primary mb-3"><font-awesome-icon icon="plus" /> {{ $t("New Status Page") }}</router-link>
+ </div>
+
+ <div class="shadow-box">
+ <template v-if="$root.statusPageListLoaded">
+ <span v-if="Object.keys($root.statusPageList).length === 0" class="d-flex align-items-center justify-content-center my-3">
+ {{ $t("No status pages") }}
+ </span>
+
+ <!-- use <a> instead of <router-link>, because the heartbeat won't load. -->
+ <a v-for="statusPage in $root.statusPageList" :key="statusPage.slug" :href="'/status/' + statusPage.slug" class="item">
+ <img :src="icon(statusPage.icon)" alt class="logo me-2" />
+ <div class="info">
+ <div class="title">{{ statusPage.title }}</div>
+ <div class="slug">/status/{{ statusPage.slug }}</div>
+ </div>
+ </a>
+ </template>
+ <div v-else class="d-flex align-items-center justify-content-center my-3 spinner">
+ <font-awesome-icon icon="spinner" size="2x" spin />
+ </div>
+ </div>
+ </div>
+ </transition>
+</template>
+
+<script>
+
+import { getResBaseURL } from "../util-frontend";
+
+export default {
+ components: {
+
+ },
+ data() {
+ return {
+ };
+ },
+ computed: {
+
+ },
+ mounted() {
+
+ },
+ methods: {
+ /**
+ * Get the correct URL for the icon
+ * @param {string} icon Path for icon
+ * @returns {string} Correctly formatted path including port numbers
+ */
+ icon(icon) {
+ if (icon === "/icon.svg") {
+ return icon;
+ } else {
+ return getResBaseURL() + icon;
+ }
+ }
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+ @import "../assets/vars.scss";
+
+ .item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ text-decoration: none;
+ border-radius: 10px;
+ transition: all ease-in-out 0.15s;
+ padding: 10px;
+
+ &:hover {
+ background-color: $highlight-white;
+ }
+
+ &.active {
+ background-color: #cdf8f4;
+ }
+
+ $logo-width: 70px;
+
+ .logo {
+ width: $logo-width;
+ height: $logo-width;
+
+ // Better when the image is loading
+ min-height: 1px;
+ }
+
+ .info {
+ .title {
+ font-weight: bold;
+ font-size: 20px;
+ }
+
+ .slug {
+ font-size: 14px;
+ }
+ }
+ }
+
+ .dark {
+ .item {
+ &:hover {
+ background-color: $dark-bg2;
+ }
+
+ &.active {
+ background-color: $dark-bg2;
+ }
+ }
+ }
+</style>
diff --git a/src/pages/NotFound.vue b/src/pages/NotFound.vue
new file mode 100644
index 0000000..361362a
--- /dev/null
+++ b/src/pages/NotFound.vue
@@ -0,0 +1,104 @@
+<template>
+ <div>
+ <!-- Desktop header -->
+ <header v-if="! $root.isMobile" class="d-flex flex-wrap justify-content-center py-3 mb-3 border-bottom">
+ <router-link to="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
+ <object class="bi me-2 ms-4" width="40" height="40" data="/icon.svg" />
+ <span class="fs-4 title">Uptime Kuma</span>
+ </router-link>
+ </header>
+
+ <!-- Mobile header -->
+ <header v-else class="d-flex flex-wrap justify-content-center pt-2 pb-2 mb-3">
+ <router-link to="/dashboard" class="d-flex align-items-center text-dark text-decoration-none">
+ <object class="bi" width="40" height="40" data="/icon.svg" />
+ <span class="fs-4 title ms-2">Uptime Kuma</span>
+ </router-link>
+ </header>
+
+ <div class="content">
+ <div>
+ <strong>🐻 {{ $t("Page Not Found") }}</strong>
+ </div>
+
+ <div class="guide">
+ {{ $t("Most likely causes:") }}
+ <ul>
+ <li>{{ $t("The resource is no longer available.") }}</li>
+ <li>{{ $t("There might be a typing error in the address.") }}</li>
+ </ul>
+
+ {{ $t("What you can try:") }}<br />
+ <ul>
+ <li>{{ $t("Retype the address.") }}</li>
+ <li><a href="#" class="go-back" @click="goBack()">{{ $t("Go back to the previous page.") }}</a></li>
+ <li><a href="/" class="go-back">{{ $t("Go back to home page.") }}</a></li>
+ </ul>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+export default {
+ async mounted() {
+
+ },
+ methods: {
+ /**
+ * Go back 1 in browser history
+ * @returns {void}
+ */
+ goBack() {
+ history.back();
+ }
+ }
+};
+</script>
+
+<style scoped lang="scss">
+@import "../assets/vars.scss";
+
+.go-back {
+ text-decoration: none;
+ color: $primary !important;
+}
+
+.content {
+ display: flex;
+ justify-content: center;
+ align-content: center;
+ align-items: center;
+ flex-direction: column;
+ gap: 50px;
+ padding-top: 30px;
+
+ strong {
+ font-size: 24px;
+ }
+}
+
+.guide {
+ max-width: 800px;
+ font-size: 14px;
+}
+
+.title {
+ font-weight: bold;
+}
+
+.dark {
+ header {
+ background-color: $dark-header-bg;
+ border-bottom-color: $dark-header-bg !important;
+
+ span {
+ color: #f0f6fc;
+ }
+ }
+
+ .bottom-nav {
+ background-color: $dark-bg;
+ }
+}
+</style>
diff --git a/src/pages/Settings.vue b/src/pages/Settings.vue
new file mode 100644
index 0000000..96bb1fe
--- /dev/null
+++ b/src/pages/Settings.vue
@@ -0,0 +1,317 @@
+<template>
+ <div>
+ <div v-if="$root.isMobile" class="shadow-box mb-3">
+ <router-link to="/manage-status-page" class="nav-link">
+ <font-awesome-icon icon="stream" /> {{ $t("Status Pages") }}
+ </router-link>
+ <router-link to="/maintenance" class="nav-link">
+ <font-awesome-icon icon="wrench" /> {{ $t("Maintenance") }}
+ </router-link>
+ </div>
+
+ <h1 v-show="show" class="mb-3">
+ {{ $t("Settings") }}
+ </h1>
+
+ <div class="shadow-box shadow-box-settings">
+ <div class="row">
+ <div v-if="showSubMenu" class="settings-menu col-lg-3 col-md-5">
+ <router-link
+ v-for="(item, key) in subMenus"
+ :key="key"
+ :to="`/settings/${key}`"
+ >
+ <div class="menu-item">
+ {{ item.title }}
+ </div>
+ </router-link>
+
+ <!-- Logout Button -->
+ <a v-if="$root.isMobile && $root.loggedIn && $root.socket.token !== 'autoLogin'" class="logout" @click.prevent="$root.logout">
+ <div class="menu-item">
+ <font-awesome-icon icon="sign-out-alt" />
+ {{ $t("Logout") }}
+ </div>
+ </a>
+ </div>
+ <div class="settings-content col-lg-9 col-md-7">
+ <div v-if="currentPage" class="settings-content-header">
+ {{ subMenus[currentPage].title }}
+ </div>
+ <div class="mx-3">
+ <router-view v-slot="{ Component }">
+ <transition name="slide-fade" appear>
+ <component :is="Component" />
+ </transition>
+ </router-view>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import { useRoute } from "vue-router";
+
+export default {
+ data() {
+ return {
+ show: true,
+ settings: {},
+ settingsLoaded: false,
+ };
+ },
+
+ computed: {
+ currentPage() {
+ let pathSplit = useRoute().path.split("/");
+ let pathEnd = pathSplit[pathSplit.length - 1];
+ if (!pathEnd || pathEnd === "settings") {
+ return null;
+ }
+ return pathEnd;
+ },
+
+ showSubMenu() {
+ if (this.$root.isMobile) {
+ return !this.currentPage;
+ } else {
+ return true;
+ }
+ },
+
+ subMenus() {
+ return {
+ general: {
+ title: this.$t("General"),
+ },
+ appearance: {
+ title: this.$t("Appearance"),
+ },
+ notifications: {
+ title: this.$t("Notifications"),
+ },
+ "reverse-proxy": {
+ title: this.$t("Reverse Proxy"),
+ },
+ tags: {
+ title: this.$t("Tags"),
+ },
+ "monitor-history": {
+ title: this.$t("Monitor History"),
+ },
+ "docker-hosts": {
+ title: this.$t("Docker Hosts"),
+ },
+ "remote-browsers": {
+ title: this.$t("Remote Browsers"),
+ },
+ security: {
+ title: this.$t("Security"),
+ },
+ "api-keys": {
+ title: this.$t("API Keys")
+ },
+ proxies: {
+ title: this.$t("Proxies"),
+ },
+ about: {
+ title: this.$t("About"),
+ },
+ };
+ },
+ },
+
+ watch: {
+ "$root.isMobile"() {
+ this.loadGeneralPage();
+ }
+ },
+
+ mounted() {
+ this.loadSettings();
+ this.loadGeneralPage();
+ },
+
+ methods: {
+
+ /**
+ * Load the general settings page
+ * For desktop only, on mobile do nothing
+ * @returns {void}
+ */
+ loadGeneralPage() {
+ if (!this.currentPage && !this.$root.isMobile) {
+ this.$router.push("/settings/general");
+ }
+ },
+
+ /**
+ * Load settings from server
+ * @returns {void}
+ */
+ loadSettings() {
+ this.$root.getSocket().emit("getSettings", (res) => {
+ this.settings = res.data;
+
+ if (this.settings.checkUpdate === undefined) {
+ this.settings.checkUpdate = true;
+ }
+
+ if (this.settings.searchEngineIndex === undefined) {
+ this.settings.searchEngineIndex = false;
+ }
+
+ if (this.settings.entryPage === undefined) {
+ this.settings.entryPage = "dashboard";
+ }
+
+ if (this.settings.nscd === undefined) {
+ this.settings.nscd = true;
+ }
+
+ if (this.settings.keepDataPeriodDays === undefined) {
+ this.settings.keepDataPeriodDays = 180;
+ }
+
+ if (this.settings.tlsExpiryNotifyDays === undefined) {
+ this.settings.tlsExpiryNotifyDays = [ 7, 14, 21 ];
+ }
+
+ if (this.settings.trustProxy === undefined) {
+ this.settings.trustProxy = false;
+ }
+
+ this.settingsLoaded = true;
+ });
+ },
+
+ /**
+ * Callback for saving settings
+ * @callback saveSettingsCB
+ * @param {object} res Result of operation
+ * @returns {void}
+ */
+
+ /**
+ * Save Settings
+ * @param {saveSettingsCB} callback Callback for socket response
+ * @param {string} currentPassword Only need for disableAuth to true
+ * @returns {void}
+ */
+ saveSettings(callback, currentPassword) {
+ let valid = this.validateSettings();
+ if (valid.success) {
+ this.$root.getSocket().emit("setSettings", this.settings, currentPassword, (res) => {
+ this.$root.toastRes(res);
+ this.loadSettings();
+
+ if (callback) {
+ callback();
+ }
+ });
+ } else {
+ this.$root.toastError(valid.msg);
+ }
+ },
+
+ /**
+ * Ensure settings are valid
+ * @returns {object} Contains success state and error msg
+ */
+ validateSettings() {
+ if (this.settings.keepDataPeriodDays < 0) {
+ return {
+ success: false,
+ msg: this.$t("dataRetentionTimeError"),
+ };
+ }
+ return {
+ success: true,
+ msg: "",
+ };
+ },
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.shadow-box-settings {
+ padding: 20px;
+ min-height: calc(100vh - 155px);
+}
+
+footer {
+ color: $secondary-text;
+ font-size: 13px;
+ margin-top: 20px;
+ padding-bottom: 30px;
+ text-align: center;
+}
+
+.settings-menu {
+ a {
+ text-decoration: none !important;
+ }
+
+ .menu-item {
+ border-radius: 10px;
+ margin: 0.5em;
+ padding: 0.7em 1em;
+ cursor: pointer;
+ border-left-width: 0;
+ transition: all ease-in-out 0.1s;
+ }
+
+ .menu-item:hover {
+ background: $highlight-white;
+
+ .dark & {
+ background: $dark-header-bg;
+ }
+ }
+
+ .active .menu-item {
+ background: $highlight-white;
+ border-left: 4px solid $primary;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+
+ .dark & {
+ background: $dark-header-bg;
+ }
+ }
+}
+
+.settings-content {
+ .settings-content-header {
+ width: calc(100% + 20px);
+ border-bottom: 1px solid #dee2e6;
+ border-radius: 0 10px 0 0;
+ margin-top: -20px;
+ margin-right: -20px;
+ padding: 12.5px 1em;
+ font-size: 26px;
+
+ .dark & {
+ background: $dark-header-bg;
+ border-bottom: 0;
+ }
+
+ .mobile & {
+ padding: 15px 0 0 0;
+
+ .dark & {
+ background-color: transparent;
+ }
+ }
+ }
+}
+
+.logout {
+ color: $danger !important;
+}
+</style>
diff --git a/src/pages/Setup.vue b/src/pages/Setup.vue
new file mode 100644
index 0000000..f681b7a
--- /dev/null
+++ b/src/pages/Setup.vue
@@ -0,0 +1,138 @@
+<template>
+ <div class="form-container" data-cy="setup-form">
+ <div class="form">
+ <form @submit.prevent="submit">
+ <div>
+ <object width="64" height="64" data="/icon.svg" />
+ <div style="font-size: 28px; font-weight: bold; margin-top: 5px;">
+ Uptime Kuma
+ </div>
+ </div>
+
+ <p class="mt-3">
+ {{ $t("Create your admin account") }}
+ </p>
+
+ <div class="form-floating">
+ <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>
+ <label for="language" class="form-label">{{ $t("Language") }}</label>
+ </div>
+
+ <div class="form-floating mt-3">
+ <input id="floatingInput" v-model="username" type="text" class="form-control" :placeholder="$t('Username')" required data-cy="username-input">
+ <label for="floatingInput">{{ $t("Username") }}</label>
+ </div>
+
+ <div class="form-floating mt-3">
+ <input id="floatingPassword" v-model="password" type="password" class="form-control" :placeholder="$t('Password')" required data-cy="password-input">
+ <label for="floatingPassword">{{ $t("Password") }}</label>
+ </div>
+
+ <div class="form-floating mt-3">
+ <input id="repeat" v-model="repeatPassword" type="password" class="form-control" :placeholder="$t('Repeat Password')" required data-cy="password-repeat-input">
+ <label for="repeat">{{ $t("Repeat Password") }}</label>
+ </div>
+
+ <button class="w-100 btn btn-primary mt-3" type="submit" :disabled="processing" data-cy="submit-setup-form">
+ {{ $t("Create") }}
+ </button>
+ </form>
+ </div>
+ </div>
+</template>
+
+<script>
+export default {
+ data() {
+ return {
+ processing: false,
+ username: "",
+ password: "",
+ repeatPassword: "",
+ };
+ },
+ watch: {
+
+ },
+ mounted() {
+ // TODO: Check if it is a database setup
+
+ this.$root.getSocket().emit("needSetup", (needSetup) => {
+ if (! needSetup) {
+ this.$router.push("/");
+ }
+ });
+ },
+ methods: {
+ /**
+ * Submit form data for processing
+ * @returns {void}
+ */
+ submit() {
+ this.processing = true;
+
+ if (this.password !== this.repeatPassword) {
+ this.$root.toastError("PasswordsDoNotMatch");
+ this.processing = false;
+ return;
+ }
+
+ this.$root.getSocket().emit("setup", this.username, this.password, (res) => {
+ this.processing = false;
+ this.$root.toastRes(res);
+
+ if (res.ok) {
+ this.processing = true;
+
+ this.$root.login(this.username, this.password, "", () => {
+ this.processing = false;
+ this.$router.push("/");
+ });
+ }
+ });
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+.form-container {
+ display: flex;
+ align-items: center;
+ padding-top: 40px;
+ padding-bottom: 40px;
+}
+
+.form-floating {
+ > .form-select {
+ padding-left: 1.3rem;
+ padding-top: 1.525rem;
+ line-height: 1.35;
+
+ ~ label {
+ padding-left: 1.3rem;
+ }
+ }
+
+ > 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/pages/SetupDatabase.vue b/src/pages/SetupDatabase.vue
new file mode 100644
index 0000000..81738a9
--- /dev/null
+++ b/src/pages/SetupDatabase.vue
@@ -0,0 +1,238 @@
+<template>
+ <div v-if="show" class="form-container">
+ <form @submit.prevent="submit">
+ <div>
+ <object width="64" height="64" data="/icon.svg" />
+ <div style="font-size: 28px; font-weight: bold; margin-top: 5px;">
+ Uptime Kuma
+ </div>
+ </div>
+
+ <div v-if="info.runningSetup" class="mt-5">
+ <div class="alert alert-success mx-3 px-4" role="alert">
+ <div class="d-flex align-items-center">
+ <strong>{{ $t("settingUpDatabaseMSG") }}</strong>
+ <div class="ms-3 pt-1">
+ <div class="spinner-border" role="status" aria-hidden="true"></div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <template v-if="!info.runningSetup">
+ <div class="form-floating short mt-3">
+ <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>
+ <label for="language" class="form-label">{{ $t("Language") }}</label>
+ </div>
+
+ <p class="mt-5 short">
+ {{ $t("setupDatabaseChooseDatabase") }}
+ </p>
+
+ <div class="btn-group" role="group" aria-label="Basic radio toggle button group">
+ <template v-if="info.isEnabledEmbeddedMariaDB">
+ <input id="btnradio3" v-model="dbConfig.type" type="radio" class="btn-check" autocomplete="off" value="embedded-mariadb">
+
+ <label class="btn btn-outline-primary" for="btnradio3">
+ Embedded MariaDB
+ </label>
+ </template>
+
+ <input id="btnradio2" v-model="dbConfig.type" type="radio" class="btn-check" autocomplete="off" value="mariadb">
+ <label class="btn btn-outline-primary" for="btnradio2">
+ MariaDB/MySQL
+ </label>
+
+ <input id="btnradio1" v-model="dbConfig.type" type="radio" class="btn-check" autocomplete="off" value="sqlite">
+ <label class="btn btn-outline-primary" for="btnradio1">
+ SQLite
+ </label>
+ </div>
+
+ <div v-if="dbConfig.type === 'embedded-mariadb'" class="mt-3 short">
+ {{ $t("setupDatabaseEmbeddedMariaDB") }}
+ </div>
+
+ <div v-if="dbConfig.type === 'mariadb'" class="mt-3 short">
+ {{ $t("setupDatabaseMariaDB") }}
+ </div>
+
+ <div v-if="dbConfig.type === 'sqlite'" class="mt-3 short">
+ {{ $t("setupDatabaseSQLite") }}
+ </div>
+
+ <template v-if="dbConfig.type === 'mariadb'">
+ <div class="form-floating mt-3 short">
+ <input id="floatingInput" v-model="dbConfig.hostname" type="text" class="form-control" required>
+ <label for="floatingInput">{{ $t("Hostname") }}</label>
+ </div>
+
+ <div class="form-floating mt-3 short">
+ <input id="floatingInput" v-model="dbConfig.port" type="text" class="form-control" required>
+ <label for="floatingInput">{{ $t("Port") }}</label>
+ </div>
+
+ <div class="form-floating mt-3 short">
+ <input id="floatingInput" v-model="dbConfig.username" type="text" class="form-control" required>
+ <label for="floatingInput">{{ $t("Username") }}</label>
+ </div>
+
+ <div class="form-floating mt-3 short">
+ <input id="floatingInput" v-model="dbConfig.password" type="password" class="form-control" required>
+ <label for="floatingInput">{{ $t("Password") }}</label>
+ </div>
+
+ <div class="form-floating mt-3 short">
+ <input id="floatingInput" v-model="dbConfig.dbName" type="text" class="form-control" required>
+ <label for="floatingInput">{{ $t("dbName") }}</label>
+ </div>
+ </template>
+
+ <button class="btn btn-primary mt-4 short" type="submit" :disabled="disabledButton">
+ {{ $t("Next") }}
+ </button>
+ </template>
+ </form>
+ </div>
+</template>
+
+<script>
+import axios from "axios";
+import { useToast } from "vue-toastification";
+import { sleep } from "../util.ts";
+const toast = useToast();
+
+export default {
+ data() {
+ return {
+ show: false,
+ dbConfig: {
+ type: undefined,
+ port: 3306,
+ hostname: "",
+ username: "",
+ password: "",
+ dbName: "kuma",
+ },
+ info: {
+ needSetup: false,
+ runningSetup: false,
+ isEnabledEmbeddedMariaDB: false,
+ },
+ };
+ },
+ computed: {
+ disabledButton() {
+ return this.dbConfig.type === undefined || this.info.runningSetup;
+ },
+ },
+ async mounted() {
+ let res = await axios.get("/setup-database-info");
+ this.info = res.data;
+
+ if (this.info && this.info.needSetup === false) {
+ location.href = "/setup";
+ } else {
+ this.show = true;
+ }
+ },
+ methods: {
+ async submit() {
+ this.info.runningSetup = true;
+
+ try {
+ await axios.post("/setup-database", {
+ dbConfig: this.dbConfig,
+ });
+ await sleep(2000);
+ await this.goToMainServerWhenReady();
+ } catch (e) {
+ toast.error(e.response.data);
+ } finally {
+ this.info.runningSetup = false;
+ }
+
+ },
+
+ async goToMainServerWhenReady() {
+ try {
+ console.log("Trying...");
+ let res = await axios.get("/setup-database-info");
+ if (res.data && res.data.needSetup === false) {
+ this.show = false;
+ location.href = "/setup";
+ } else {
+ if (res.data) {
+ this.info = res.data;
+ }
+ throw new Error("not ready");
+ }
+ } catch (e) {
+ console.log("Not ready yet");
+ await sleep(2000);
+ await this.goToMainServerWhenReady();
+ }
+ },
+
+ test() {
+ this.$root.toastError("not implemented");
+ }
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+.form-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding-top: 40px;
+ padding-bottom: 40px;
+}
+
+.btn-group {
+ label {
+ width: 200px;
+ line-height: 55px;
+ font-size: 16px;
+ font-weight: bold;
+ }
+}
+
+.form-floating {
+ > .form-select {
+ padding-left: 1.3rem;
+ padding-top: 1.525rem;
+ line-height: 1.35;
+
+ ~ label {
+ padding-left: 1.3rem;
+ }
+ }
+
+ > label {
+ padding-left: 1.3rem;
+ }
+
+ > .form-control {
+ padding-left: 1.3rem;
+ }
+}
+
+.short {
+ width: 300px;
+}
+
+form {
+ max-width: 800px;
+ text-align: center;
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+ align-items: center;
+}
+</style>
diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue
new file mode 100644
index 0000000..1169682
--- /dev/null
+++ b/src/pages/StatusPage.vue
@@ -0,0 +1,1271 @@
+<template>
+ <div v-if="loadedTheme" class="container mt-3">
+ <!-- Sidebar for edit mode -->
+ <div v-if="enableEditMode" class="sidebar" data-testid="edit-sidebar">
+ <div class="sidebar-body">
+ <div class="my-3">
+ <label for="slug" class="form-label">{{ $t("Slug") }}</label>
+ <div class="input-group">
+ <span id="basic-addon3" class="input-group-text">/status/</span>
+ <input id="slug" v-model="config.slug" type="text" class="form-control">
+ </div>
+ </div>
+
+ <div class="my-3">
+ <label for="title" class="form-label">{{ $t("Title") }}</label>
+ <input id="title" v-model="config.title" type="text" class="form-control">
+ </div>
+
+ <!-- Description -->
+ <div class="my-3">
+ <label for="description" class="form-label">{{ $t("Description") }}</label>
+ <textarea id="description" v-model="config.description" class="form-control" data-testid="description-input"></textarea>
+ <div class="form-text">
+ {{ $t("markdownSupported") }}
+ </div>
+ </div>
+
+ <!-- Footer Text -->
+ <div class="my-3">
+ <label for="footer-text" class="form-label">{{ $t("Footer Text") }}</label>
+ <textarea id="footer-text" v-model="config.footerText" class="form-control" data-testid="footer-text-input"></textarea>
+ <div class="form-text">
+ {{ $t("markdownSupported") }}
+ </div>
+ </div>
+
+ <div class="my-3">
+ <label for="auto-refresh-interval" class="form-label">{{ $t("Refresh Interval") }}</label>
+ <input id="auto-refresh-interval" v-model="config.autoRefreshInterval" type="number" class="form-control" :min="5" data-testid="refresh-interval-input">
+ <div class="form-text">
+ {{ $t("Refresh Interval Description", [config.autoRefreshInterval]) }}
+ </div>
+ </div>
+
+ <div class="my-3">
+ <label for="switch-theme" class="form-label">{{ $t("Theme") }}</label>
+ <select id="switch-theme" v-model="config.theme" class="form-select" data-testid="theme-select">
+ <option value="auto">{{ $t("Auto") }}</option>
+ <option value="light">{{ $t("Light") }}</option>
+ <option value="dark">{{ $t("Dark") }}</option>
+ </select>
+ </div>
+
+ <div class="my-3 form-check form-switch">
+ <input id="showTags" v-model="config.showTags" class="form-check-input" type="checkbox" data-testid="show-tags-checkbox">
+ <label class="form-check-label" for="showTags">{{ $t("Show Tags") }}</label>
+ </div>
+
+ <!-- Show Powered By -->
+ <div class="my-3 form-check form-switch">
+ <input id="show-powered-by" v-model="config.showPoweredBy" class="form-check-input" type="checkbox" data-testid="show-powered-by-checkbox">
+ <label class="form-check-label" for="show-powered-by">{{ $t("Show Powered By") }}</label>
+ </div>
+
+ <!-- Show certificate expiry -->
+ <div class="my-3 form-check form-switch">
+ <input id="show-certificate-expiry" v-model="config.showCertificateExpiry" class="form-check-input" type="checkbox" data-testid="show-certificate-expiry-checkbox">
+ <label class="form-check-label" for="show-certificate-expiry">{{ $t("showCertificateExpiry") }}</label>
+ </div>
+
+ <div v-if="false" class="my-3">
+ <label for="password" class="form-label">{{ $t("Password") }} <sup>{{ $t("Coming Soon") }}</sup></label>
+ <input id="password" v-model="config.password" disabled type="password" autocomplete="new-password" class="form-control">
+ </div>
+
+ <!-- Domain Name List -->
+ <div class="my-3">
+ <label class="form-label">
+ {{ $t("Domain Names") }}
+ <button class="p-0 bg-transparent border-0" :aria-label="$t('Add a domain')" @click="addDomainField">
+ <font-awesome-icon icon="plus-circle" class="action text-primary" />
+ </button>
+ </label>
+
+ <ul class="list-group domain-name-list">
+ <li v-for="(domain, index) in config.domainNameList" :key="index" class="list-group-item">
+ <input v-model="config.domainNameList[index]" type="text" class="no-bg domain-input" placeholder="example.com" />
+ <button class="p-0 bg-transparent border-0" :aria-label="$t('Remove domain', [ domain ])" @click="removeDomain(index)">
+ <font-awesome-icon icon="times" class="action remove ms-2 me-3 text-danger" />
+ </button>
+ </li>
+ </ul>
+ </div>
+
+ <!-- Google Analytics -->
+ <div class="my-3">
+ <label for="googleAnalyticsTag" class="form-label">{{ $t("Google Analytics ID") }}</label>
+ <input id="googleAnalyticsTag" v-model="config.googleAnalyticsId" type="text" class="form-control" data-testid="google-analytics-input">
+ </div>
+
+ <!-- Custom CSS -->
+ <div class="my-3">
+ <div class="mb-1">{{ $t("Custom CSS") }}</div>
+ <prism-editor v-model="config.customCSS" class="css-editor" data-testid="custom-css-input" :highlight="highlighter" line-numbers></prism-editor>
+ </div>
+
+ <div class="danger-zone">
+ <button class="btn btn-danger me-2" @click="deleteDialog">
+ <font-awesome-icon icon="trash" />
+ {{ $t("Delete") }}
+ </button>
+ </div>
+ </div>
+
+ <!-- Sidebar Footer -->
+ <div class="sidebar-footer">
+ <button class="btn btn-success me-2" :disabled="loading" data-testid="save-button" @click="save">
+ <font-awesome-icon icon="save" />
+ {{ $t("Save") }}
+ </button>
+
+ <button class="btn btn-danger me-2" @click="discard">
+ <font-awesome-icon icon="undo" />
+ {{ $t("Discard") }}
+ </button>
+ </div>
+ </div>
+
+ <!-- Main Status Page -->
+ <div :class="{ edit: enableEditMode}" class="main">
+ <!-- Logo & Title -->
+ <h1 class="mb-4 title-flex">
+ <!-- Logo -->
+ <span class="logo-wrapper" @click="showImageCropUploadMethod">
+ <img :src="logoURL" alt class="logo me-2" :class="logoClass" />
+ <font-awesome-icon v-if="enableEditMode" class="icon-upload" icon="upload" />
+ </span>
+
+ <!-- Uploader -->
+ <!-- url="/api/status-page/upload-logo" -->
+ <ImageCropUpload
+ v-model="showImageCropUpload"
+ field="img"
+ :width="128"
+ :height="128"
+ :langType="$i18n.locale"
+ img-format="png"
+ :noCircle="true"
+ :noSquare="false"
+ @crop-success="cropSuccess"
+ />
+
+ <!-- Title -->
+ <Editable v-model="config.title" tag="span" :contenteditable="editMode" :noNL="true" />
+ </h1>
+
+ <!-- Admin functions -->
+ <div v-if="hasToken" class="mb-4">
+ <div v-if="!enableEditMode">
+ <button class="btn btn-info me-2" data-testid="edit-button" @click="edit">
+ <font-awesome-icon icon="edit" />
+ {{ $t("Edit Status Page") }}
+ </button>
+
+ <a href="/manage-status-page" class="btn btn-info">
+ <font-awesome-icon icon="tachometer-alt" />
+ {{ $t("Go to Dashboard") }}
+ </a>
+ </div>
+
+ <div v-else>
+ <button class="btn btn-primary btn-add-group me-2" data-testid="create-incident-button" @click="createIncident">
+ <font-awesome-icon icon="bullhorn" />
+ {{ $t("Create Incident") }}
+ </button>
+ </div>
+ </div>
+
+ <!-- Incident -->
+ <div v-if="incident !== null" class="shadow-box alert mb-4 p-4 incident" role="alert" :class="incidentClass" data-testid="incident">
+ <strong v-if="editIncidentMode">{{ $t("Title") }}:</strong>
+ <Editable v-model="incident.title" tag="h4" :contenteditable="editIncidentMode" :noNL="true" class="alert-heading" data-testid="incident-title" />
+
+ <strong v-if="editIncidentMode">{{ $t("Content") }}:</strong>
+ <Editable v-if="editIncidentMode" v-model="incident.content" tag="div" :contenteditable="editIncidentMode" class="content" data-testid="incident-content-editable" />
+ <div v-if="editIncidentMode" class="form-text">
+ {{ $t("markdownSupported") }}
+ </div>
+ <!-- eslint-disable-next-line vue/no-v-html-->
+ <div v-if="! editIncidentMode" class="content" data-testid="incident-content" v-html="incidentHTML"></div>
+
+ <!-- Incident Date -->
+ <div class="date mt-3">
+ {{ $t("Date Created") }}: {{ $root.datetime(incident.createdDate) }} ({{ dateFromNow(incident.createdDate) }})<br />
+ <span v-if="incident.lastUpdatedDate">
+ {{ $t("Last Updated") }}: {{ $root.datetime(incident.lastUpdatedDate) }} ({{ dateFromNow(incident.lastUpdatedDate) }})
+ </span>
+ </div>
+
+ <div v-if="editMode" class="mt-3">
+ <button v-if="editIncidentMode" class="btn btn-light me-2" data-testid="post-incident-button" @click="postIncident">
+ <font-awesome-icon icon="bullhorn" />
+ {{ $t("Post") }}
+ </button>
+
+ <button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="editIncident">
+ <font-awesome-icon icon="edit" />
+ {{ $t("Edit") }}
+ </button>
+
+ <button v-if="editIncidentMode" class="btn btn-light me-2" @click="cancelIncident">
+ <font-awesome-icon icon="times" />
+ {{ $t("Cancel") }}
+ </button>
+
+ <div v-if="editIncidentMode" class="dropdown d-inline-block me-2">
+ <button id="dropdownMenuButton1" class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+ {{ $t("Style") }}: {{ $t(incident.style) }}
+ </button>
+ <ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
+ <li><a class="dropdown-item" href="#" @click="incident.style = 'info'">{{ $t("info") }}</a></li>
+ <li><a class="dropdown-item" href="#" @click="incident.style = 'warning'">{{ $t("warning") }}</a></li>
+ <li><a class="dropdown-item" href="#" @click="incident.style = 'danger'">{{ $t("danger") }}</a></li>
+ <li><a class="dropdown-item" href="#" @click="incident.style = 'primary'">{{ $t("primary") }}</a></li>
+ <li><a class="dropdown-item" href="#" @click="incident.style = 'light'">{{ $t("light") }}</a></li>
+ <li><a class="dropdown-item" href="#" @click="incident.style = 'dark'">{{ $t("dark") }}</a></li>
+ </ul>
+ </div>
+
+ <button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="unpinIncident">
+ <font-awesome-icon icon="unlink" />
+ {{ $t("Delete") }}
+ </button>
+ </div>
+ </div>
+
+ <!-- Overall Status -->
+ <div class="shadow-box list p-4 overall-status mb-4">
+ <div v-if="Object.keys($root.publicMonitorList).length === 0 && loadedData">
+ <font-awesome-icon icon="question-circle" class="ok" />
+ {{ $t("No Services") }}
+ </div>
+
+ <template v-else>
+ <div v-if="allUp">
+ <font-awesome-icon icon="check-circle" class="ok" />
+ {{ $t("All Systems Operational") }}
+ </div>
+
+ <div v-else-if="partialDown">
+ <font-awesome-icon icon="exclamation-circle" class="warning" />
+ {{ $t("Partially Degraded Service") }}
+ </div>
+
+ <div v-else-if="allDown">
+ <font-awesome-icon icon="times-circle" class="danger" />
+ {{ $t("Degraded Service") }}
+ </div>
+
+ <div v-else-if="isMaintenance">
+ <font-awesome-icon icon="wrench" class="status-maintenance" />
+ {{ $t("maintenanceStatus-under-maintenance") }}
+ </div>
+
+ <div v-else>
+ <font-awesome-icon icon="question-circle" style="color: #efefef;" />
+ </div>
+ </template>
+ </div>
+
+ <!-- Maintenance -->
+ <template v-if="maintenanceList.length > 0">
+ <div
+ v-for="maintenance in maintenanceList" :key="maintenance.id"
+ class="shadow-box alert mb-4 p-3 bg-maintenance mt-4 position-relative" role="alert"
+ >
+ <h4 class="alert-heading">{{ maintenance.title }}</h4>
+ <!-- eslint-disable-next-line vue/no-v-html-->
+ <div class="content" v-html="maintenanceHTML(maintenance.description)"></div>
+ <MaintenanceTime :maintenance="maintenance" />
+ </div>
+ </template>
+
+ <!-- Description -->
+ <strong v-if="editMode">{{ $t("Description") }}:</strong>
+ <Editable v-if="enableEditMode" v-model="config.description" :contenteditable="editMode" tag="div" class="mb-4 description" data-testid="description-editable" />
+ <!-- eslint-disable-next-line vue/no-v-html-->
+ <div v-if="! enableEditMode" class="alert-heading p-2" data-testid="description" v-html="descriptionHTML"></div>
+
+ <div v-if="editMode" class="mb-4">
+ <div>
+ <button class="btn btn-primary btn-add-group me-2" data-testid="add-group-button" @click="addGroup">
+ <font-awesome-icon icon="plus" />
+ {{ $t("Add Group") }}
+ </button>
+ </div>
+
+ <div class="mt-3">
+ <div v-if="sortedMonitorList.length > 0 && loadedData">
+ <label>{{ $t("Add a monitor") }}:</label>
+ <VueMultiselect
+ v-model="selectedMonitor"
+ :options="sortedMonitorList"
+ :multiple="false"
+ :searchable="true"
+ :placeholder="$t('Add a monitor')"
+ label="name"
+ trackBy="name"
+ class="mt-3"
+ data-testid="monitor-select"
+ >
+ <template #option="{ option }">
+ <div class="d-inline-flex">
+ <span>{{ option.pathName }} <Tag v-for="tag in option.tags" :key="tag" :item="tag" :size="'sm'" /></span>
+ </div>
+ </template>
+ </VueMultiselect>
+ </div>
+ <div v-else class="text-center">
+ {{ $t("No monitors available.") }} <router-link to="/add">{{ $t("Add one") }}</router-link>
+ </div>
+ </div>
+ </div>
+
+ <div class="mb-4">
+ <div v-if="$root.publicGroupList.length === 0 && loadedData" class="text-center">
+ <!-- 👀 Nothing here, please add a group or a monitor. -->
+ 👀 {{ $t("statusPageNothing") }}
+ </div>
+
+ <PublicGroupList :edit-mode="enableEditMode" :show-tags="config.showTags" :show-certificate-expiry="config.showCertificateExpiry" />
+ </div>
+
+ <footer class="mt-5 mb-4">
+ <div class="custom-footer-text text-start">
+ <strong v-if="enableEditMode">{{ $t("Custom Footer") }}:</strong>
+ </div>
+ <Editable v-if="enableEditMode" v-model="config.footerText" tag="div" :contenteditable="enableEditMode" :noNL="false" class="alert-heading p-2" data-testid="custom-footer-editable" />
+ <!-- eslint-disable-next-line vue/no-v-html-->
+ <div v-if="! enableEditMode" class="alert-heading p-2" data-testid="footer-text" v-html="footerHTML"></div>
+
+ <p v-if="config.showPoweredBy" data-testid="powered-by">
+ {{ $t("Powered by") }} <a target="_blank" rel="noopener noreferrer" href="https://github.com/louislam/uptime-kuma">{{ $t("Uptime Kuma" ) }}</a>
+ </p>
+
+ <div class="refresh-info mb-2">
+ <div>{{ $t("Last Updated") }}: {{ lastUpdateTimeDisplay }}</div>
+ <div data-testid="update-countdown-text">{{ $tc("statusPageRefreshIn", [ updateCountdownText]) }}</div>
+ </div>
+ </footer>
+ </div>
+
+ <Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteStatusPage">
+ {{ $t("deleteStatusPageMsg") }}
+ </Confirm>
+
+ <component is="style" v-if="config.customCSS" type="text/css">
+ {{ config.customCSS }}
+ </component>
+ </div>
+</template>
+
+<script>
+import axios from "axios";
+import dayjs from "dayjs";
+import duration from "dayjs/plugin/duration";
+import Favico from "favico.js";
+// import highlighting library (you can use any library you want just return html string)
+import { highlight, languages } from "prismjs/components/prism-core";
+import "prismjs/components/prism-css";
+import "prismjs/themes/prism-tomorrow.css"; // import syntax highlighting styles
+import ImageCropUpload from "vue-image-crop-upload";
+// import Prism Editor
+import { PrismEditor } from "vue-prism-editor";
+import "vue-prism-editor/dist/prismeditor.min.css"; // import the styles somewhere
+import { useToast } from "vue-toastification";
+import { marked } from "marked";
+import DOMPurify from "dompurify";
+import Confirm from "../components/Confirm.vue";
+import PublicGroupList from "../components/PublicGroupList.vue";
+import MaintenanceTime from "../components/MaintenanceTime.vue";
+import { getResBaseURL } from "../util-frontend";
+import { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE } from "../util.ts";
+import Tag from "../components/Tag.vue";
+import VueMultiselect from "vue-multiselect";
+
+const toast = useToast();
+dayjs.extend(duration);
+
+const leavePageMsg = "Do you really want to leave? you have unsaved changes!";
+
+// eslint-disable-next-line no-unused-vars
+let feedInterval;
+
+const favicon = new Favico({
+ animation: "none"
+});
+
+export default {
+
+ components: {
+ PublicGroupList,
+ ImageCropUpload,
+ Confirm,
+ PrismEditor,
+ MaintenanceTime,
+ Tag,
+ VueMultiselect
+ },
+
+ // Leave Page for vue route change
+ beforeRouteLeave(to, from, next) {
+ if (this.editMode) {
+ const answer = window.confirm(leavePageMsg);
+ if (answer) {
+ next();
+ } else {
+ next(false);
+ }
+ }
+ next();
+ },
+
+ props: {
+ /** Override for the status page slug */
+ overrideSlug: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+
+ data() {
+ return {
+ slug: null,
+ enableEditMode: false,
+ enableEditIncidentMode: false,
+ hasToken: false,
+ config: {},
+ selectedMonitor: null,
+ incident: null,
+ previousIncident: null,
+ showImageCropUpload: false,
+ imgDataUrl: "/icon.svg",
+ loadedTheme: false,
+ loadedData: false,
+ baseURL: "",
+ clickedEditButton: false,
+ maintenanceList: [],
+ lastUpdateTime: dayjs(),
+ updateCountdown: null,
+ updateCountdownText: null,
+ loading: true,
+ };
+ },
+ computed: {
+
+ logoURL() {
+ if (this.imgDataUrl.startsWith("data:")) {
+ return this.imgDataUrl;
+ } else {
+ return this.baseURL + this.imgDataUrl;
+ }
+ },
+
+ /**
+ * If the monitor is added to public list, which will not be in this list.
+ * @returns {object[]} List of monitors
+ */
+ sortedMonitorList() {
+ let result = [];
+
+ for (let id in this.$root.monitorList) {
+ if (this.$root.monitorList[id] && ! (id in this.$root.publicMonitorList)) {
+ let monitor = this.$root.monitorList[id];
+ result.push(monitor);
+ }
+ }
+
+ result.sort((m1, m2) => {
+
+ if (m1.active !== m2.active) {
+ if (m1.active === 0) {
+ return 1;
+ }
+
+ if (m2.active === 0) {
+ return -1;
+ }
+ }
+
+ if (m1.weight !== m2.weight) {
+ if (m1.weight > m2.weight) {
+ return -1;
+ }
+
+ if (m1.weight < m2.weight) {
+ return 1;
+ }
+ }
+
+ return m1.pathName.localeCompare(m2.pathName);
+ });
+
+ return result;
+ },
+
+ editMode() {
+ return this.enableEditMode && this.$root.socket.connected;
+ },
+
+ editIncidentMode() {
+ return this.enableEditIncidentMode;
+ },
+
+ isPublished() {
+ return this.config.published;
+ },
+
+ logoClass() {
+ if (this.editMode) {
+ return {
+ "edit-mode": true,
+ };
+ }
+ return {};
+ },
+
+ incidentClass() {
+ return "bg-" + this.incident.style;
+ },
+
+ maintenanceClass() {
+ return "bg-maintenance";
+ },
+
+ overallStatus() {
+
+ if (Object.keys(this.$root.publicLastHeartbeatList).length === 0) {
+ return -1;
+ }
+
+ let status = STATUS_PAGE_ALL_UP;
+ let hasUp = false;
+
+ for (let id in this.$root.publicLastHeartbeatList) {
+ let beat = this.$root.publicLastHeartbeatList[id];
+
+ if (beat.status === MAINTENANCE) {
+ return STATUS_PAGE_MAINTENANCE;
+ } else if (beat.status === UP) {
+ hasUp = true;
+ } else {
+ status = STATUS_PAGE_PARTIAL_DOWN;
+ }
+ }
+
+ if (! hasUp) {
+ status = STATUS_PAGE_ALL_DOWN;
+ }
+
+ return status;
+ },
+
+ allUp() {
+ return this.overallStatus === STATUS_PAGE_ALL_UP;
+ },
+
+ partialDown() {
+ return this.overallStatus === STATUS_PAGE_PARTIAL_DOWN;
+ },
+
+ allDown() {
+ return this.overallStatus === STATUS_PAGE_ALL_DOWN;
+ },
+
+ isMaintenance() {
+ return this.overallStatus === STATUS_PAGE_MAINTENANCE;
+ },
+
+ incidentHTML() {
+ if (this.incident.content != null) {
+ return DOMPurify.sanitize(marked(this.incident.content));
+ } else {
+ return "";
+ }
+ },
+
+ descriptionHTML() {
+ if (this.config.description != null) {
+ return DOMPurify.sanitize(marked(this.config.description));
+ } else {
+ return "";
+ }
+ },
+
+ footerHTML() {
+ if (this.config.footerText != null) {
+ return DOMPurify.sanitize(marked(this.config.footerText));
+ } else {
+ return "";
+ }
+ },
+
+ lastUpdateTimeDisplay() {
+ return this.$root.datetime(this.lastUpdateTime);
+ }
+ },
+ watch: {
+
+ /**
+ * If connected to the socket and logged in, request private data of this statusPage
+ * @param {boolean} loggedIn Is the client logged in?
+ * @returns {void}
+ */
+ "$root.loggedIn"(loggedIn) {
+ if (loggedIn) {
+ this.$root.getSocket().emit("getStatusPage", this.slug, (res) => {
+ if (res.ok) {
+ this.config = res.config;
+
+ if (!this.config.customCSS) {
+ this.config.customCSS = "body {\n" +
+ " \n" +
+ "}\n";
+ }
+
+ } else {
+ this.$root.toastError(res.msg);
+ }
+ });
+ }
+ },
+
+ /**
+ * Selected a monitor and add to the list.
+ * @param {object} monitor Monitor to add
+ * @returns {void}
+ */
+ selectedMonitor(monitor) {
+ if (monitor) {
+ if (this.$root.publicGroupList.length === 0) {
+ this.addGroup();
+ }
+
+ const firstGroup = this.$root.publicGroupList[0];
+
+ firstGroup.monitorList.push(monitor);
+ this.selectedMonitor = null;
+ }
+ },
+
+ // Set Theme
+ "config.theme"() {
+ this.$root.statusPageTheme = this.config.theme;
+ this.loadedTheme = true;
+ },
+
+ "config.title"(title) {
+ document.title = title;
+ },
+
+ "$root.monitorList"() {
+ let count = Object.keys(this.$root.monitorList).length;
+
+ // Since publicGroupList is getting from public rest api, monitors' tags may not present if showTags = false
+ if (count > 0) {
+ for (let group of this.$root.publicGroupList) {
+ for (let monitor of group.monitorList) {
+ if (monitor.tags === undefined && this.$root.monitorList[monitor.id]) {
+ monitor.tags = this.$root.monitorList[monitor.id].tags;
+ }
+ }
+ }
+ }
+ }
+
+ },
+ async created() {
+ this.hasToken = ("token" in this.$root.storage());
+
+ // Browser change page
+ // https://stackoverflow.com/questions/7317273/warn-user-before-leaving-web-page-with-unsaved-changes
+ window.addEventListener("beforeunload", (e) => {
+ if (this.editMode) {
+ (e || window.event).returnValue = leavePageMsg;
+ return leavePageMsg;
+ } else {
+ return null;
+ }
+ });
+
+ // Special handle for dev
+ this.baseURL = getResBaseURL();
+ },
+ async mounted() {
+ this.slug = this.overrideSlug || this.$route.params.slug;
+
+ if (!this.slug) {
+ this.slug = "default";
+ }
+
+ this.getData().then((res) => {
+ this.config = res.data.config;
+
+ if (!this.config.domainNameList) {
+ this.config.domainNameList = [];
+ }
+
+ if (this.config.icon) {
+ this.imgDataUrl = this.config.icon;
+ }
+
+ this.incident = res.data.incident;
+ this.maintenanceList = res.data.maintenanceList;
+ this.$root.publicGroupList = res.data.publicGroupList;
+
+ this.loading = false;
+
+ // Configure auto-refresh loop
+ feedInterval = setInterval(() => {
+ this.updateHeartbeatList();
+ }, (this.config.autoRefreshInterval + 10) * 1000);
+
+ this.updateUpdateTimer();
+ }).catch( function (error) {
+ if (error.response.status === 404) {
+ location.href = "/page-not-found";
+ }
+ console.log(error);
+ });
+
+ this.updateHeartbeatList();
+
+ // Go to edit page if ?edit present
+ // null means ?edit present, but no value
+ if (this.$route.query.edit || this.$route.query.edit === null) {
+ this.edit();
+ }
+ },
+ methods: {
+
+ /**
+ * Get status page data
+ * It should be preloaded in window.preloadData
+ * @returns {Promise<any>} Status page data
+ */
+ getData: function () {
+ if (window.preloadData) {
+ return new Promise(resolve => resolve({
+ data: window.preloadData
+ }));
+ } else {
+ return axios.get("/api/status-page/" + this.slug);
+ }
+ },
+
+ /**
+ * Provide syntax highlighting for CSS
+ * @param {string} code Text to highlight
+ * @returns {string} Highlighted CSS
+ */
+ highlighter(code) {
+ return highlight(code, languages.css);
+ },
+
+ /**
+ * Update the heartbeat list and update favicon if necessary
+ * @returns {void}
+ */
+ updateHeartbeatList() {
+ // If editMode, it will use the data from websocket.
+ if (! this.editMode) {
+ axios.get("/api/status-page/heartbeat/" + this.slug).then((res) => {
+ const { heartbeatList, uptimeList } = res.data;
+
+ this.$root.heartbeatList = heartbeatList;
+ this.$root.uptimeList = uptimeList;
+
+ const heartbeatIds = Object.keys(heartbeatList);
+ const downMonitors = heartbeatIds.reduce((downMonitorsAmount, currentId) => {
+ const monitorHeartbeats = heartbeatList[currentId];
+ const lastHeartbeat = monitorHeartbeats.at(-1);
+
+ if (lastHeartbeat) {
+ return lastHeartbeat.status === 0 ? downMonitorsAmount + 1 : downMonitorsAmount;
+ } else {
+ return downMonitorsAmount;
+ }
+ }, 0);
+
+ favicon.badge(downMonitors);
+
+ this.loadedData = true;
+ this.lastUpdateTime = dayjs();
+ this.updateUpdateTimer();
+ });
+ }
+ },
+
+ /**
+ * Setup timer to display countdown to refresh
+ * @returns {void}
+ */
+ updateUpdateTimer() {
+ clearInterval(this.updateCountdown);
+
+ this.updateCountdown = setInterval(() => {
+ const countdown = dayjs.duration(this.lastUpdateTime.add(this.config.autoRefreshInterval, "seconds").add(10, "seconds").diff(dayjs()));
+ if (countdown.as("seconds") < 0) {
+ clearInterval(this.updateCountdown);
+ } else {
+ this.updateCountdownText = countdown.format("mm:ss");
+ }
+ }, 1000);
+ },
+
+ /**
+ * Enable editing mode
+ * @returns {void}
+ */
+ edit() {
+ if (this.hasToken) {
+ this.$root.initSocketIO(true);
+ this.enableEditMode = true;
+ this.clickedEditButton = true;
+
+ // Try to fix #1658
+ this.loadedData = true;
+ }
+ },
+
+ /**
+ * Save the status page
+ * @returns {void}
+ */
+ save() {
+ this.loading = true;
+ let startTime = new Date();
+ this.config.slug = this.config.slug.trim().toLowerCase();
+
+ this.$root.getSocket().emit("saveStatusPage", this.slug, this.config, this.imgDataUrl, this.$root.publicGroupList, (res) => {
+ if (res.ok) {
+ this.enableEditMode = false;
+ this.$root.publicGroupList = res.publicGroupList;
+
+ // Add some delay, so that the side menu animation would be better
+ let endTime = new Date();
+ let time = 100 - (endTime - startTime) / 1000;
+
+ if (time < 0) {
+ time = 0;
+ }
+
+ setTimeout(() => {
+ this.loading = false;
+ location.href = "/status/" + this.config.slug;
+ }, time);
+
+ } else {
+ this.loading = false;
+ toast.error(res.msg);
+ }
+ });
+ },
+
+ /**
+ * Show dialog confirming deletion
+ * @returns {void}
+ */
+ deleteDialog() {
+ this.$refs.confirmDelete.show();
+ },
+
+ /**
+ * Request deletion of this status page
+ * @returns {void}
+ */
+ deleteStatusPage() {
+ this.$root.getSocket().emit("deleteStatusPage", this.slug, (res) => {
+ if (res.ok) {
+ this.enableEditMode = false;
+ location.href = "/manage-status-page";
+ } else {
+ this.$root.toastError(res.msg);
+ }
+ });
+ },
+
+ /**
+ * Returns label for a specified monitor
+ * @param {object} monitor Object representing monitor
+ * @returns {string} Monitor label
+ */
+ monitorSelectorLabel(monitor) {
+ return `${monitor.name}`;
+ },
+
+ /**
+ * Add a group to the status page
+ * @returns {void}
+ */
+ addGroup() {
+ let groupName = this.$t("Untitled Group");
+
+ if (this.$root.publicGroupList.length === 0) {
+ groupName = this.$t("Services");
+ }
+
+ this.$root.publicGroupList.unshift({
+ name: groupName,
+ monitorList: [],
+ });
+ },
+
+ /**
+ * Add a domain to the status page
+ * @returns {void}
+ */
+ addDomainField() {
+ this.config.domainNameList.push("");
+ },
+
+ /**
+ * Discard changes to status page
+ * @returns {void}
+ */
+ discard() {
+ location.href = "/status/" + this.slug;
+ },
+
+ /**
+ * Set URL of new image after successful crop operation
+ * @param {string} imgDataUrl URL of image in data:// format
+ * @returns {void}
+ */
+ cropSuccess(imgDataUrl) {
+ this.imgDataUrl = imgDataUrl;
+ },
+
+ /**
+ * Show image crop dialog if in edit mode
+ * @returns {void}
+ */
+ showImageCropUploadMethod() {
+ if (this.editMode) {
+ this.showImageCropUpload = true;
+ }
+ },
+
+ /**
+ * Create an incident for this status page
+ * @returns {void}
+ */
+ createIncident() {
+ this.enableEditIncidentMode = true;
+
+ if (this.incident) {
+ this.previousIncident = this.incident;
+ }
+
+ this.incident = {
+ title: "",
+ content: "",
+ style: "primary",
+ };
+ },
+
+ /**
+ * Post the incident to the status page
+ * @returns {void}
+ */
+ postIncident() {
+ if (this.incident.title === "" || this.incident.content === "") {
+ this.$root.toastError("Please input title and content");
+ return;
+ }
+
+ this.$root.getSocket().emit("postIncident", this.slug, this.incident, (res) => {
+
+ if (res.ok) {
+ this.enableEditIncidentMode = false;
+ this.incident = res.incident;
+ } else {
+ this.$root.toastError(res.msg);
+ }
+
+ });
+
+ },
+
+ /**
+ * Click Edit Button
+ * @returns {void}
+ */
+ editIncident() {
+ this.enableEditIncidentMode = true;
+ this.previousIncident = Object.assign({}, this.incident);
+ },
+
+ /**
+ * Cancel creation or editing of incident
+ * @returns {void}
+ */
+ cancelIncident() {
+ this.enableEditIncidentMode = false;
+
+ if (this.previousIncident) {
+ this.incident = this.previousIncident;
+ this.previousIncident = null;
+ }
+ },
+
+ /**
+ * Unpin the incident
+ * @returns {void}
+ */
+ unpinIncident() {
+ this.$root.getSocket().emit("unpinIncident", this.slug, () => {
+ this.incident = null;
+ });
+ },
+
+ /**
+ * Get the relative time difference of a date from now
+ * @param {any} date Date to get time difference
+ * @returns {string} Time difference
+ */
+ dateFromNow(date) {
+ return dayjs.utc(date).fromNow();
+ },
+
+ /**
+ * Remove a domain from the status page
+ * @param {number} index Index of domain to remove
+ * @returns {void}
+ */
+ removeDomain(index) {
+ this.config.domainNameList.splice(index, 1);
+ },
+
+ /**
+ * Generate sanitized HTML from maintenance description
+ * @param {string} description Text to sanitize
+ * @returns {string} Sanitized HTML
+ */
+ maintenanceHTML(description) {
+ if (description) {
+ return DOMPurify.sanitize(marked(description));
+ } else {
+ return "";
+ }
+ },
+
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.overall-status {
+ font-weight: bold;
+ font-size: 25px;
+
+ .ok {
+ color: $primary;
+ }
+
+ .warning {
+ color: $warning;
+ }
+
+ .danger {
+ color: $danger;
+ }
+}
+
+h1 {
+ font-size: 30px;
+
+ img {
+ vertical-align: middle;
+ height: 60px;
+ width: 60px;
+ }
+}
+
+.main {
+ transition: all ease-in-out 0.1s;
+
+ &.edit {
+ margin-left: 300px;
+ }
+}
+
+.sidebar {
+ position: fixed;
+ left: 0;
+ top: 0;
+ width: 300px;
+ height: 100vh;
+
+ border-right: 1px solid #ededed;
+
+ .danger-zone {
+ border-top: 1px solid #ededed;
+ padding-top: 15px;
+ }
+
+ .sidebar-body {
+ padding: 0 10px 10px 10px;
+ overflow-x: hidden;
+ overflow-y: auto;
+ height: calc(100% - 70px);
+ }
+
+ .sidebar-footer {
+ border-top: 1px solid #ededed;
+ border-right: 1px solid #ededed;
+ padding: 10px;
+ width: 300px;
+ height: 70px;
+ position: fixed;
+ left: 0;
+ bottom: 0;
+ background-color: white;
+ display: flex;
+ align-items: center;
+ }
+}
+
+footer {
+ text-align: center;
+ font-size: 14px;
+}
+
+.description span {
+ min-width: 50px;
+}
+
+.title-flex {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.logo-wrapper {
+ display: inline-block;
+ position: relative;
+
+ &:hover {
+ .icon-upload {
+ transform: scale(1.2);
+ }
+ }
+
+ .icon-upload {
+ transition: all $easing-in 0.2s;
+ position: absolute;
+ bottom: 6px;
+ font-size: 20px;
+ left: -14px;
+ background-color: white;
+ padding: 5px;
+ border-radius: 10px;
+ cursor: pointer;
+ box-shadow: 0 15px 70px rgba(0, 0, 0, 0.9);
+ }
+}
+
+.logo {
+ transition: all $easing-in 0.2s;
+
+ &.edit-mode {
+ cursor: pointer;
+
+ &:hover {
+ transform: scale(1.2);
+ }
+ }
+}
+
+.incident {
+ .content {
+ &[contenteditable="true"] {
+ min-height: 60px;
+ }
+ }
+
+ .date {
+ font-size: 12px;
+ }
+}
+
+.maintenance-bg-info {
+ color: $maintenance;
+}
+
+.maintenance-icon {
+ font-size: 35px;
+ vertical-align: middle;
+}
+
+.dark .shadow-box {
+ background-color: #0d1117;
+}
+
+.status-maintenance {
+ color: $maintenance;
+ margin-right: 5px;
+}
+
+.mobile {
+ h1 {
+ font-size: 22px;
+ }
+
+ .overall-status {
+ font-size: 20px;
+ }
+}
+
+.dark {
+ .sidebar {
+ background-color: $dark-header-bg;
+ border-right-color: $dark-border-color;
+
+ .danger-zone {
+ border-top-color: $dark-border-color;
+ }
+
+ .sidebar-footer {
+ border-right-color: $dark-border-color;
+ border-top-color: $dark-border-color;
+ background-color: $dark-header-bg;
+ }
+ }
+}
+
+.domain-name-list {
+ li {
+ display: flex;
+ align-items: center;
+ padding: 10px 0 10px 10px;
+
+ .domain-input {
+ flex-grow: 1;
+ background-color: transparent;
+ border: none;
+ color: $dark-font-color;
+ outline: none;
+
+ &::placeholder {
+ color: #1d2634;
+ }
+ }
+ }
+}
+
+.bg-maintenance {
+ .alert-heading {
+ font-weight: bold;
+ }
+}
+
+.refresh-info {
+ opacity: 0.7;
+}
+
+</style>