summaryrefslogtreecommitdiffstats
path: root/src/pages/StatusPage.vue
diff options
context:
space:
mode:
Diffstat (limited to 'src/pages/StatusPage.vue')
-rw-r--r--src/pages/StatusPage.vue1271
1 files changed, 1271 insertions, 0 deletions
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>