diff options
Diffstat (limited to 'src/pages')
-rw-r--r-- | src/pages/AddStatusPage.vue | 88 | ||||
-rw-r--r-- | src/pages/Dashboard.vue | 42 | ||||
-rw-r--r-- | src/pages/DashboardHome.vue | 248 | ||||
-rw-r--r-- | src/pages/Details.vue | 826 | ||||
-rw-r--r-- | src/pages/EditMaintenance.vue | 611 | ||||
-rw-r--r-- | src/pages/EditMonitor.vue | 1858 | ||||
-rw-r--r-- | src/pages/Entry.vue | 54 | ||||
-rw-r--r-- | src/pages/List.vue | 24 | ||||
-rw-r--r-- | src/pages/MaintenanceDetails.vue | 169 | ||||
-rw-r--r-- | src/pages/ManageMaintenance.vue | 317 | ||||
-rw-r--r-- | src/pages/ManageStatusPage.vue | 123 | ||||
-rw-r--r-- | src/pages/NotFound.vue | 104 | ||||
-rw-r--r-- | src/pages/Settings.vue | 317 | ||||
-rw-r--r-- | src/pages/Setup.vue | 138 | ||||
-rw-r--r-- | src/pages/SetupDatabase.vue | 238 | ||||
-rw-r--r-- | src/pages/StatusPage.vue | 1271 |
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=">">></option> + <option value=">=">>=</option> + <option value="<"><</option> + <option value="<="><=</option> + <option value="!=">!=</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:', [ '{ "ping": 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> |