Merge remote-tracking branch 'origin/master' into feature/482-add-description-to-monitor

# Conflicts:
#	server/database.js
#	server/model/monitor.js
#	src/icon.js
#	src/languages/en.js
#	src/languages/es-ES.js
This commit is contained in:
Louis Lam
2023-02-25 19:14:44 +08:00
393 changed files with 56675 additions and 19206 deletions

View File

@@ -0,0 +1,82 @@
<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>
</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>
</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>
</ul>
</div>
</div>
<div class="mt-2 mb-1">
<button id="monitor-submit-btn" class="btn btn-primary w-100" type="submit" :disabled="processing">{{ $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 */
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(this.$t("The slug is already taken. Please choose another slug."));
} else {
this.$root.toastRes(res);
}
}
});
}
}
};
</script>
<style lang="scss" scoped>
.shadow-box {
padding: 20px;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="container-fluid">
<div class="row">
<div v-if="! $root.isMobile" class="col-12 col-md-5 col-xl-4">
<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>
@@ -25,9 +25,9 @@ export default {
MonitorList,
},
data() {
return {}
return {};
},
}
};
</script>
<style lang="scss" scoped>

View File

@@ -9,19 +9,23 @@
<div class="row">
<div class="col">
<h3>{{ $t("Up") }}</h3>
<span class="num">{{ stats.up }}</span>
<span class="num">{{ $root.stats.up }}</span>
</div>
<div class="col">
<h3>{{ $t("Down") }}</h3>
<span class="num text-danger">{{ stats.down }}</span>
<span class="num text-danger">{{ $root.stats.down }}</span>
</div>
<div class="col">
<h3>{{ $t("Maintenance") }}</h3>
<span class="num text-maintenance">{{ $root.stats.maintenance }}</span>
</div>
<div class="col">
<h3>{{ $t("Unknown") }}</h3>
<span class="num text-secondary">{{ stats.unknown }}</span>
<span class="num text-secondary">{{ $root.stats.unknown }}</span>
</div>
<div class="col">
<h3>{{ $t("pauseDashboardHome") }}</h3>
<span class="num text-secondary">{{ stats.pause }}</span>
<span class="num text-secondary">{{ $root.stats.pause }}</span>
</div>
</div>
</div>
@@ -89,37 +93,6 @@ export default {
};
},
computed: {
stats() {
let result = {
up: 0,
down: 0,
unknown: 0,
pause: 0,
};
for (let monitorID in this.$root.monitorList) {
let beat = this.$root.lastHeartbeatList[monitorID];
let monitor = this.$root.monitorList[monitorID];
if (monitor && ! monitor.active) {
result.pause++;
} else if (beat) {
if (beat.status === 1) {
result.up++;
} else if (beat.status === 0) {
result.down++;
} else if (beat.status === 2) {
result.up++;
} else {
result.unknown++;
}
} else {
result.unknown++;
}
}
return result;
},
importantHeartBeatList() {
let result = [];
@@ -149,6 +122,7 @@ export default {
return 0;
});
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.heartBeatList = result;
return result;

View File

@@ -7,7 +7,7 @@
<Tag v-for="tag in monitor.tags" :key="tag.id" :item="tag" :size="'sm'" />
</div>
<p class="url">
<a v-if="monitor.type === 'http' || monitor.type === 'keyword' " :href="monitor.url" target="_blank">{{ monitor.url }}</a>
<a v-if="monitor.type === 'http' || monitor.type === 'keyword' " :href="monitor.url" target="_blank" rel="noopener noreferrer">{{ monitor.url }}</a>
<span v-if="monitor.type === 'port'">TCP Ping {{ monitor.hostname }}:{{ monitor.port }}</span>
<span v-if="monitor.type === 'ping'">Ping: {{ monitor.hostname }}</span>
<span v-if="monitor.type === 'keyword'">
@@ -21,18 +21,23 @@
</p>
<div class="functions">
<button v-if="monitor.active" class="btn btn-light" @click="pauseDialog">
<font-awesome-icon icon="pause" /> {{ $t("Pause") }}
</button>
<button v-if="! monitor.active" class="btn btn-primary" @click="resumeMonitor">
<font-awesome-icon icon="play" /> {{ $t("Resume") }}
</button>
<router-link :to=" '/edit/' + monitor.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 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" @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-danger" @click="deleteDialog">
<font-awesome-icon icon="trash" /> {{ $t("Delete") }}
</button>
</div>
</div>
<div class="shadow-box">
@@ -78,7 +83,7 @@
<h4>{{ $t("Cert Exp.") }}</h4>
<p>(<Datetime :value="tlsInfo.certInfo.validTo" date-only />)</p>
<span class="num">
<a href="#" @click.prevent="toggleCertInfoBox = !toggleCertInfoBox">{{ tlsInfo.certInfo.daysRemaining }} {{ $t("days") }}</a>
<a href="#" @click.prevent="toggleCertInfoBox = !toggleCertInfoBox">{{ tlsInfo.certInfo.daysRemaining }} {{ $tc("day", tlsInfo.certInfo.daysRemaining) }}</a>
</span>
</div>
</div>
@@ -290,39 +295,47 @@ export default {
},
methods: {
/** Request a test notification be sent for this monitor */
testNotification() {
this.$root.getSocket().emit("testNotification", this.monitor.id);
toast.success("Test notification is requested.");
},
/** Show dialog to confirm pause */
pauseDialog() {
this.$refs.confirmPause.show();
},
/** Resume this monitor */
resumeMonitor() {
this.$root.getSocket().emit("resumeMonitor", this.monitor.id, (res) => {
this.$root.toastRes(res);
});
},
/** Request that this monitor is paused */
pauseMonitor() {
this.$root.getSocket().emit("pauseMonitor", this.monitor.id, (res) => {
this.$root.toastRes(res);
});
},
/** Show dialog to confirm deletion */
deleteDialog() {
this.$refs.confirmDelete.show();
},
/** Show dialog to confirm clearing events */
clearEventsDialog() {
this.$refs.confirmClearEvents.show();
},
/** Show dialog to confirm clearing heartbeats */
clearHeartbeatsDialog() {
this.$refs.confirmClearHeartbeats.show();
},
/** Request that this monitor is deleted */
deleteMonitor() {
this.$root.deleteMonitor(this.monitor.id, (res) => {
if (res.ok) {
@@ -334,6 +347,7 @@ export default {
});
},
/** Request that this monitors events are cleared */
clearEvents() {
this.$root.clearEvents(this.monitor.id, (res) => {
if (! res.ok) {
@@ -342,6 +356,7 @@ export default {
});
},
/** Request that this monitors heartbeats are cleared */
clearHeartbeats() {
this.$root.clearHeartbeats(this.monitor.id, (res) => {
if (! res.ok) {
@@ -350,6 +365,11 @@ export default {
});
},
/**
* Return the correct title for the ping stat
* @param {boolean} [average=false] Is the statistic an average?
* @returns {string} Title formated dependant on monitor type
*/
pingTitle(average = false) {
let translationPrefix = "";
if (average) {
@@ -378,11 +398,6 @@ export default {
@media (max-width: 550px) {
.functions {
text-align: center;
button, a {
margin-left: 10px !important;
margin-right: 10px !important;
}
}
.ping-chart-wrapper {
@@ -425,12 +440,6 @@ export default {
}
}
.functions {
button, a {
margin-right: 20px;
}
}
.shadow-box {
padding: 20px;
margin-top: 25px;
@@ -468,6 +477,12 @@ table {
.dropdown-clear-data {
float: right;
ul {
width: 100%;
min-width: unset;
padding-left: 0;
}
}
.dark {

View File

@@ -0,0 +1,549 @@
<template>
<transition name="slide-fade" appear>
<div>
<h1 class="mb-3">{{ pageName }}</h1>
<form @submit.prevent="submit">
<div class="shadow-box">
<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="name"
: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>
<div> {{ $t("warningTimezone") }}: <mark>{{ $root.info.serverTimezone }} ({{ $root.info.serverTimezoneOffset }})</mark></div>
<!-- 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="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>
<option v-if="false" value="recurring-day-of-year">{{ $t("Recurring") }} - Day of Year</option>
</select>
</div>
<!-- Single Maintenance Window -->
<template v-if="maintenance.strategy === 'single'">
<!-- DateTime Range -->
<div class="my-3">
<label class="form-label">{{ $t("DateTime Range") }}</label>
<Datepicker
v-model="maintenance.dateRange"
:dark="$root.isDark"
range
:monthChangeOnScroll="false"
:minDate="minDate"
format="yyyy-MM-dd HH:mm"
modelType="yyyy-MM-dd HH:mm:ss"
/>
</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>
<!-- For any recurring types -->
<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>
<!-- Date Range -->
<div class="my-3">
<label class="form-label">{{ $t("Effective Date Range") }}</label>
<Datepicker
v-model="maintenance.dateRange"
:dark="$root.isDark"
range datePicker
:monthChangeOnScroll="false"
:minDate="minDate"
format="yyyy-MM-dd HH:mm:ss"
modelType="yyyy-MM-dd HH:mm:ss"
required
/>
</div>
</template>
<div class="mt-4 mb-1">
<button
id="monitor-submit-btn" class="btn btn-primary" type="submit"
:disabled="processing"
>
{{ $t("Save") }}
</button>
</div>
</div>
</div>
</div>
</form>
</div>
</transition>
</template>
<script>
import { useToast } from "vue-toastification";
import VueMultiselect from "vue-multiselect";
import dayjs from "dayjs";
import Datepicker from "@vuepic/vue-datepicker";
const toast = useToast();
export default {
components: {
VueMultiselect,
Datepicker
},
data() {
return {
processing: false,
maintenance: {},
affectedMonitors: [],
affectedMonitorsOptions: [],
showOnAllPages: false,
selectedStatusPages: [],
dark: (this.$root.theme === "dark"),
neverEnd: false,
minDate: this.$root.date(dayjs()) + " 00:00",
lastDays: [
{
langKey: "lastDay1",
value: "lastDay1",
},
{
langKey: "lastDay2",
value: "lastDay2",
},
{
langKey: "lastDay3",
value: "lastDay3",
},
{
langKey: "lastDay4",
value: "lastDay4",
}
],
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: {
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.init();
this.$root.getMonitorList((res) => {
if (res.ok) {
Object.values(this.$root.monitorList).map(monitor => {
this.affectedMonitorsOptions.push({
id: monitor.id,
name: monitor.name,
});
});
}
});
},
methods: {
/** Initialise page */
init() {
this.affectedMonitors = [];
this.selectedStatusPages = [];
if (this.isAdd) {
this.maintenance = {
title: "",
description: "",
strategy: "single",
active: 1,
intervalDay: 1,
dateRange: [ this.minDate ],
timeRange: [{
hours: 2,
minutes: 0,
}, {
hours: 3,
minutes: 0,
}],
weekdays: [],
daysOfMonth: [],
};
} 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(monitor);
});
} else {
toast.error(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 {
toast.error(res.msg);
}
});
} else {
toast.error(res.msg);
}
});
}
},
/** Create new maintenance */
async submit() {
this.processing = true;
if (this.affectedMonitors.length === 0) {
toast.error(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, () => {
toast.success(res.msg);
this.processing = false;
this.$root.getMaintenanceList();
this.$router.push("/maintenance");
});
});
} else {
toast.error(res.msg);
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;
toast.error(res.msg);
}
});
}
},
/**
* Add monitor to maintenance
* @param {number} maintenanceID
* @param {socketCB} callback
*/
async addMonitorMaintenance(maintenanceID, callback) {
await this.$root.addMonitorMaintenance(maintenanceID, this.affectedMonitors, async (res) => {
if (!res.ok) {
toast.error(res.msg);
} else {
this.$root.getMonitorList();
}
callback();
});
},
/**
* Add status page to maintenance
* @param {number} maintenanceID
* @param {socketCB} callback
*/
async addMaintenanceStatusPage(maintenanceID, callback) {
await this.$root.addMaintenanceStatusPage(maintenanceID, (this.showOnAllPages) ? this.selectedStatusPagesOptions : this.selectedStatusPages, async (res) => {
if (!res.ok) {
toast.error(res.msg);
} else {
this.$root.getMaintenanceList();
}
callback();
});
},
},
};
</script>
<style lang="scss" scoped>
.shadow-box {
padding: 20px;
}
textarea {
min-height: 150px;
}
.dark-calendar::-webkit-calendar-picker-indicator {
filter: invert(1);
}
.weekday-picker {
display: flex;
gap: 10px;
& > div {
display: flex;
flex-direction: column;
align-items: center;
width: 40px;
.form-check-inline {
margin-right: 0;
}
}
}
.day-picker {
display: flex;
gap: 10px;
flex-wrap: wrap;
& > div {
display: flex;
flex-direction: column;
align-items: center;
width: 40px;
.form-check-inline {
margin-right: 0;
}
}
}
</style>

View File

@@ -11,27 +11,75 @@
<div class="my-3">
<label for="type" class="form-label">{{ $t("Monitor Type") }}</label>
<select id="type" v-model="monitor.type" class="form-select">
<option value="http">
HTTP(s)
</option>
<option value="port">
TCP Port
</option>
<option value="ping">
Ping
</option>
<option value="keyword">
HTTP(s) - {{ $t("Keyword") }}
</option>
<option value="dns">
DNS
</option>
<option value="push">
Push
</option>
<option value="steam">
Steam Game Server
</option>
<optgroup :label="$t('General Monitor Type')">
<option value="http">
HTTP(s)
</option>
<option value="port">
TCP Port
</option>
<option value="ping">
Ping
</option>
<option value="keyword">
HTTP(s) - {{ $t("Keyword") }}
</option>
<option value="grpc-keyword">
gRPC(s) - {{ $t("Keyword") }}
</option>
<option value="dns">
DNS
</option>
<option value="docker">
{{ $t("Docker Container") }}
</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="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>
</optgroup>
<!--
Hidden for now: Reason refer to Setting.vue
<optgroup :label="$t('Custom Monitor Type')">
<option value="browser">
(Beta) HTTP(s) - Browser Engine (Chrome/Firefox)
</option>
</optgroup>
</select>
-->
</select>
</div>
@@ -48,23 +96,29 @@
</div>
<!-- URL -->
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3">
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === '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>
</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="url" class="form-control" pattern="[^\:]+:[0-9]{5}" 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", ["msg, ping"]) }}
{{ $t("pushOptionalParams", ["status, msg, ping"]) }}
</div>
</div>
<!-- Keyword -->
<div v-if="monitor.type === 'keyword' " class="my-3">
<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">
@@ -72,16 +126,27 @@
</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>
<!-- Hostname -->
<!-- TCP Port / Ping / DNS / Steam only -->
<div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam'" class="my-3">
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius 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'" 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="`${ipRegexPattern}|${hostnameRegexPattern}`" required>
<input id="hostname" v-model="monitor.hostname" type="text" class="form-control" :pattern="`${monitor.type === 'mqtt' ? mqttIpOrHostnameRegexPattern : ipOrHostnameRegexPattern}`" required>
</div>
<!-- Port -->
<!-- For TCP Port / Steam Type -->
<div v-if="monitor.type === 'port' || monitor.type === 'steam'" class="my-3">
<!-- For TCP Port / Steam / MQTT / Radius Type -->
<div v-if="monitor.type === 'port' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius'" 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>
@@ -93,7 +158,16 @@
<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("resoverserverDescription") }}
{{ $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>
@@ -121,10 +195,137 @@
</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">
<h2 class="mb-2">{{ $t("Docker Host") }}</h2>
<p v-if="$root.dockerHostList.length === 0">
{{ $t("Not available, please setup.") }}
</p>
<div v-else class="mb-3">
<label for="docker-host" class="form-label">{{ $t("Docker Host") }}</label>
<select id="docket-host" v-model="monitor.docker_host" class="form-select">
<option v-for="host in $root.dockerHostList" :key="host.id" :value="host.id">{{ host.name }}</option>
</select>
<a href="#" @click="$refs.dockerHostDialog.show(monitor.docker_host)">{{ $t("Edit") }}</a>
</div>
<button class="btn btn-primary me-2" type="button" @click="$refs.dockerHostDialog.show()">
{{ $t("Setup Docker Host") }}
</button>
</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="mqttSuccessMessage" class="form-label">MQTT {{ $t("successMessage") }}</label>
<input id="mqttSuccessMessage" v-model="monitor.mqttSuccessMessage" type="text" class="form-control">
<div class="form-text">
{{ $t("successMessageExplanation") }}
</div>
</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 -->
<template v-if="monitor.type === 'sqlserver' || monitor.type === 'postgres' || monitor.type === 'mysql'">
<div class="my-3">
<label for="sqlConnectionString" class="form-label">{{ $t("Connection String") }}</label>
<template v-if="monitor.type === 'sqlserver'">
<input id="sqlConnectionString" v-model="monitor.databaseConnectionString" type="text" class="form-control" placeholder="Server=<hostname>,<port>;Database=<your database>;User Id=<your user id>;Password=<your password>;Encrypt=<true/false>;TrustServerCertificate=<Yes/No>;Connection Timeout=<int>">
</template>
<template v-if="monitor.type === 'postgres'">
<input id="sqlConnectionString" v-model="monitor.databaseConnectionString" type="text" class="form-control" placeholder="postgres://username:password@host:port/database">
</template>
<template v-if="monitor.type === 'mysql'">
<input id="sqlConnectionString" v-model="monitor.databaseConnectionString" type="text" class="form-control" placeholder="mysql://username:password@host:port/database">
</template>
</div>
<div class="my-3">
<label for="sqlQuery" class="form-label">{{ $t("Query") }}</label>
<textarea id="sqlQuery" v-model="monitor.databaseQuery" class="form-control" placeholder="Example: select getdate()"></textarea>
</div>
</template>
<!-- Redis -->
<template v-if="monitor.type === 'redis'">
<div class="my-3">
<label for="redisConnectionString" class="form-label">{{ $t("Connection String") }}</label>
<input id="redisConnectionString" v-model="monitor.databaseConnectionString" type="text" class="form-control" placeholder="redis://user:password@host:port">
</div>
</template>
<!-- MongoDB -->
<template v-if="monitor.type === 'mongodb'">
<div class="my-3">
<label for="sqlConnectionString" class="form-label">{{ $t("Connection String") }}</label>
<template v-if="monitor.type === 'mongodb'">
<input id="sqlConnectionString" v-model="monitor.databaseConnectionString" type="text" class="form-control" placeholder="mongodb://username:password@host:port/database">
</template>
</div>
</template>
<!-- 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="20" step="1">
<input id="interval" v-model="monitor.interval" type="number" class="form-control" required :min="minInterval" step="1" :max="maxInterval">
</div>
<div class="my-3">
@@ -140,11 +341,29 @@
{{ $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="20" step="1">
<input id="retry-interval" v-model="monitor.retryInterval" type="number" class="form-control" required :min="minInterval" step="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' " class="my-3 form-check">
<input id="expiry-notification" v-model="monitor.expiryNotification" class="form-check-input" type="checkbox">
<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' " 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">
@@ -162,8 +381,14 @@
</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' ">
<template v-if="monitor.type === 'http' || monitor.type === 'keyword' || 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">
@@ -198,10 +423,6 @@
<div class="my-3">
<tags-manager ref="tagsManager" :pre-selected-tags="monitor.tags"></tags-manager>
</div>
<div class="mt-5 mb-1">
<button id="monitor-submit-btn" class="btn btn-primary" type="submit" :disabled="processing">{{ $t("Save") }}</button>
</div>
</div>
<div class="col-md-6">
@@ -228,6 +449,34 @@
{{ $t("Setup Notification") }}
</button>
<!-- Proxies -->
<div v-if="monitor.type === 'http' || monitor.type === 'keyword'">
<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>
<!-- HTTP Options -->
<template v-if="monitor.type === 'http' || monitor.type === 'keyword' ">
<h2 class="mt-5 mb-2">{{ $t("HTTP Options") }}</h2>
@@ -260,6 +509,15 @@
</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="xml">XML</option>
</select>
</div>
<!-- Body -->
<div class="my-3">
<label for="body" class="form-label">{{ $t("Body") }}</label>
@@ -272,50 +530,139 @@
<textarea id="headers" v-model="monitor.headers" class="form-control" :placeholder="headersPlaceholder"></textarea>
</div>
<!-- HTTP Basic Auth -->
<h4 class="mt-5 mb-2">{{ $t("HTTP Basic Auth") }}</h4>
<!-- HTTP Auth -->
<h4 class="mt-5 mb-2">{{ $t("Authentication") }}</h4>
<!-- Method -->
<div class="my-3">
<label for="basicauth" class="form-label">{{ $t("Username") }}</label>
<input id="basicauth-user" v-model="monitor.basic_auth_user" type="text" class="form-control" :placeholder="$t('Username')">
<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="ntlm">
NTLM
</option>
</select>
</div>
<template v-if="monitor.authMethod && monitor.authMethod !== null ">
<div class="my-3">
<label for="basicauth" 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" 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="basicauth" class="form-label">{{ $t("Domain") }}</label>
<input id="basicauth-domain" v-model="monitor.authDomain" type="text" class="form-control" :placeholder="$t('Domain')">
</div>
<div class="my-3">
<label for="basicauth" class="form-label">{{ $t("Workstation") }}</label>
<input id="basicauth-workstation" v-model="monitor.authWorkstation" type="text" class="form-control" :placeholder="$t('Workstation')">
</div>
</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="basicauth" class="form-label">{{ $t("Password") }}</label>
<input id="basicauth-pass" v-model="monitor.basic_auth_pass" type="password" class="form-control" :placeholder="$t('Password')">
<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">{{ $t("Save") }}</button>
</div>
</div>
</form>
<NotificationDialog ref="notificationDialog" @added="addedNotification" />
<DockerHostDialog ref="dockerHostDialog" @added="addedDockerHost" />
<ProxyDialog ref="proxyDialog" @added="addedProxy" />
</div>
</transition>
</template>
<script>
import NotificationDialog from "../components/NotificationDialog.vue";
import TagsManager from "../components/TagsManager.vue";
import CopyableInput from "../components/CopyableInput.vue";
import { useToast } from "vue-toastification";
import VueMultiselect from "vue-multiselect";
import { genSecret, isDev } from "../util.ts";
import { useToast } from "vue-toastification";
import CopyableInput from "../components/CopyableInput.vue";
import NotificationDialog from "../components/NotificationDialog.vue";
import DockerHostDialog from "../components/DockerHostDialog.vue";
import ProxyDialog from "../components/ProxyDialog.vue";
import TagsManager from "../components/TagsManager.vue";
import { genSecret, isDev, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND } from "../util.ts";
import { hostNameRegexPattern } from "../util-frontend";
const toast = useToast();
export default {
components: {
ProxyDialog,
CopyableInput,
NotificationDialog,
DockerHostDialog,
TagsManager,
VueMultiselect,
},
data() {
return {
minInterval: MIN_INTERVAL_SECOND,
maxInterval: MAX_INTERVAL_SECOND,
processing: false,
monitor: {
notificationIDList: {},
@@ -323,11 +670,9 @@ export default {
},
acceptedStatusCodeOptions: [],
dnsresolvetypeOptions: [],
// Source: https://digitalfortress.tech/tips/top-15-commonly-used-regex/
ipRegexPattern: "((^\\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\\s*$)|(^\\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))(%.+)?\\s*$))",
// Source: https://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address
hostnameRegexPattern: "^(([a-zA-Z0-9_]|[a-zA-Z0-9_][a-zA-Z0-9\\-_]*[a-zA-Z0-9_])\\.)*([A-Za-z0-9_]|[A-Za-z0-9_][A-Za-z0-9\\-_]*[A-Za-z0-9_])$"
ipOrHostnameRegexPattern: hostNameRegexPattern(),
mqttIpOrHostnameRegexPattern: hostNameRegexPattern(true),
gameList: null,
};
},
@@ -343,37 +688,112 @@ export default {
},
pageName() {
return this.$t((this.isAdd) ? "Add New Monitor" : "Edit");
let name = "Add New Monitor";
if (this.isClone) {
name = "Clone Monitor";
} else if (this.isEdit) {
name = "Edit";
}
return this.$t(name);
},
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 + "?msg=OK&ping=";
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() {
return this.$t("Example:", [`
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>` ]);
}
return this.$t("Example:", [ `
{
"key": "value"
}`]);
}` ]);
},
headersPlaceholder() {
return this.$t("Example:", [`
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;
},
},
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();
@@ -392,6 +812,35 @@ export default {
this.monitor.pushToken = genSecret(10);
}
}
// 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 {
this.monitor.port = undefined;
}
}
// 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 {
toast.error(res.msg);
}
});
}
},
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];
}
},
@@ -427,6 +876,7 @@ export default {
this.dnsresolvetypeOptions = dnsresolvetypeOptions;
},
methods: {
/** Initialize the edit monitor form */
init() {
if (this.isAdd) {
@@ -437,26 +887,58 @@ export default {
method: "GET",
interval: 60,
retryInterval: this.interval,
resendInterval: 0,
maxretries: 0,
notificationIDList: {},
ignoreTls: false,
upsideDown: false,
packetSize: 56,
expiryNotification: false,
maxredirects: 10,
accepted_statuscodes: ["200-299"],
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: "",
authMethod: null,
httpBodyEncoding: "json"
};
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) {
if (this.$root.notificationList[i].isDefault === true) {
this.monitor.notificationIDList[this.$root.notificationList[i].id] = true;
}
}
} else if (this.isEdit) {
} else if (this.isEdit || this.isClone) {
this.$root.getSocket().emit("getMonitor", this.$route.params.id, (res) => {
if (res.ok) {
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;
this.monitor.name = this.$t("cloneOf", [ this.monitor.name ]);
this.monitor.tags = undefined; // FIXME: Cloning tags does not work yet
}
// Handling for monitors that are created before 1.7.0
if (this.monitor.retryInterval === 0) {
this.monitor.retryInterval = this.monitor.interval;
@@ -469,8 +951,12 @@ export default {
},
/**
* Validate form input
* @returns {boolean} Is the form input valid?
*/
isInputValid() {
if (this.monitor.body) {
if (this.monitor.body && (!this.monitor.httpBodyEncoding || this.monitor.httpBodyEncoding === "json")) {
try {
JSON.parse(this.monitor.body);
} catch (err) {
@@ -489,7 +975,12 @@ export default {
return true;
},
/**
* Submit the form data for processing
* @returns {void}
*/
async submit() {
this.processing = true;
if (!this.isInputValid()) {
@@ -497,16 +988,28 @@ export default {
return;
}
// Beautify the JSON format
if (this.monitor.body) {
// 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);
}
if (this.monitor.type && this.monitor.type !== "http" && this.monitor.type !== "keyword") {
this.monitor.httpBodyEncoding = null;
}
if (this.monitor.headers) {
this.monitor.headers = JSON.stringify(JSON.parse(this.monitor.headers), null, 4);
}
if (this.isAdd) {
if (this.monitor.hostname) {
this.monitor.hostname = this.monitor.hostname.trim();
}
if (this.monitor.url) {
this.monitor.url = this.monitor.url.trim();
}
if (this.isAdd || this.isClone) {
this.$root.add(this.monitor, async (res) => {
if (res.ok) {
@@ -533,21 +1036,61 @@ export default {
}
},
// Added a Notification Event
// Enable it if the notification is added in EditMonitor.vue
/**
* Added a Notification Event
* Enable it if the notification is added in EditMonitor.vue
* @param {number} id ID of notification to add
*/
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
*/
addedProxy(id) {
this.monitor.proxyId = id;
},
// Added a Docker Host Event
// Enable it if the Docker Host is added in EditMonitor.vue
addedDockerHost(id) {
this.monitor.docker_host = id;
},
},
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
$padding: 20px;
.shadow-box {
padding: 20px;
padding-top: $padding;
padding-bottom: 0;
padding-right: $padding;
padding-left: $padding;
}
textarea {
min-height: 200px;
}
.fixed-bottom-bar {
position: sticky;
bottom: 0;
margin-left: -$padding;
margin-right: -$padding;
z-index: 100;
background-color: rgba(white, 0.2);
backdrop-filter: blur(2px);
border-radius: 0 0 10px 10px;
.dark & {
background-color: rgba($dark-header-bg, 0.9);
}
}
</style>

View File

@@ -1,19 +1,45 @@
<template>
<div></div>
<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() {
let entryPage = (await axios.get("/api/entry-page")).data;
if (entryPage === "statusPage") {
this.$router.push("/status");
// There are only 2 cases that could come in here.
// 1. Matched status Page domain name
// 2. Vue Frontend Dev
let 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 === "statusPage") {
this.$router.push("/status");
} else {
this.$router.push("/dashboard");
}
} else {
this.$router.push("/dashboard");
}
},
};

View File

@@ -1,6 +1,6 @@
<template>
<transition name="slide-fade" appear>
<MonitorList />
<MonitorList :scrollbar="true" />
</transition>
</template>
@@ -11,6 +11,14 @@ export default {
components: {
MonitorList,
},
}
};
</script>
<style lang="scss" scoped>
@import "../assets/vars";
.shadow-box {
padding: 20px;
}
</style>

View File

@@ -0,0 +1,164 @@
<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 */
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 */
deleteDialog() {
this.$refs.confirmDelete.show();
},
/** Delete maintenance after showing confirmation */
deleteMaintenance() {
this.$root.deleteMaintenance(this.maintenance.id, (res) => {
if (res.ok) {
toast.success(res.msg);
this.$router.push("/maintenance");
} else {
toast.error(res.msg);
}
});
},
},
};
</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>

View File

@@ -0,0 +1,303 @@
<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-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";
import { useToast } from "vue-toastification";
const toast = useToast();
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
* @returns {string} Relative URL
*/
maintenanceURL(id) {
return getMaintenanceRelativeURL(id);
},
/**
* Show delete confirmation
* @param {number} maintenanceID
*/
deleteDialog(maintenanceID) {
this.selectedMaintenanceID = maintenanceID;
this.$refs.confirmDelete.show();
},
/** Delete maintenance after showing confirmation dialog */
deleteMaintenance() {
this.$root.deleteMaintenance(this.selectedMaintenanceID, (res) => {
if (res.ok) {
toast.success(res.msg);
this.$router.push("/maintenance");
} else {
toast.error(res.msg);
}
});
},
/**
* Show dialog to confirm pause
*/
pauseDialog(maintenanceID) {
this.selectedMaintenanceID = maintenanceID;
this.$refs.confirmPause.show();
},
/**
* Pause maintenance
*/
pauseMaintenance() {
this.$root.getSocket().emit("pauseMaintenance", this.selectedMaintenanceID, (res) => {
this.$root.toastRes(res);
});
},
/**
* Resume maintenance
*/
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;
.btn-group {
width: 310px;
}
}
}
.dark {
.item {
&:hover {
background-color: $dark-bg2;
}
}
}
</style>

View File

@@ -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>

101
src/pages/NotFound.vue Normal file
View File

@@ -0,0 +1,101 @@
<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">Go back to home page.</a></li>
</ul>
</div>
</div>
</div>
</template>
<script>
export default {
async mounted() {
},
methods: {
/** Go back 1 in browser history */
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>

View File

@@ -1,12 +1,21 @@
<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">
<div class="shadow-box shadow-box-settings">
<div class="row">
<div class="settings-menu">
<div v-if="showSubMenu" class="settings-menu col-lg-3 col-md-5">
<router-link
v-for="(item, key) in subMenus"
:key="key"
@@ -16,9 +25,17 @@
{{ 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">
<div class="settings-content-header">
<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">
@@ -41,7 +58,6 @@ export default {
data() {
return {
show: true,
settings: {},
settingsLoaded: false,
};
@@ -52,11 +68,19 @@ export default {
let pathSplit = useRoute().path.split("/");
let pathEnd = pathSplit[pathSplit.length - 1];
if (!pathEnd || pathEnd === "settings") {
return "general";
return null;
}
return pathEnd;
},
showSubMenu() {
if (this.$root.isMobile) {
return !this.currentPage;
} else {
return true;
}
},
subMenus() {
return {
general: {
@@ -68,15 +92,33 @@ export default {
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"),
},
security: {
title: this.$t("Security"),
},
proxies: {
title: this.$t("Proxies"),
},
backup: {
title: this.$t("Backup"),
},
/*
Hidden for now: Unfortunately, after some test, I found that Playwright requires a lot of libraries to be installed on the Linux host in order to start Chrome or Firefox.
It will be hard to install, so I hide this feature for now. But it still accessible via URL: /settings/plugins.
plugins: {
title: this.$tc("plugin", 2),
},*/
about: {
title: this.$t("About"),
},
@@ -84,15 +126,38 @@ export default {
},
},
watch: {
"$root.isMobile"() {
this.loadGeneralPage();
}
},
mounted() {
this.loadSettings();
this.loadGeneralPage();
},
methods: {
/**
* Load the general settings page
* For desktop only, on mobile do nothing
*/
loadGeneralPage() {
if (!this.currentPage && !this.$root.isMobile) {
this.$router.push("/settings/general");
}
},
/** Load settings from server */
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;
}
@@ -101,28 +166,77 @@ export default {
this.settings.entryPage = "dashboard";
}
if (this.settings.dnsCache === undefined) {
this.settings.dnsCache = false;
}
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;
});
},
saveSettings() {
this.$root.getSocket().emit("setSettings", this.settings, (res) => {
this.$root.toastRes(res);
this.loadSettings();
});
/**
* Callback for saving settings
* @callback saveSettingsCB
* @param {Object} res Result of operation
*/
/**
* Save Settings
* @param {saveSettingsCB} [callback]
* @param {string} [currentPassword] Only need for disableAuth to true
*/
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 {
.shadow-box-settings {
padding: 20px;
min-height: calc(100vh - 155px);
}
@@ -136,9 +250,6 @@ footer {
}
.settings-menu {
flex: 0 0 auto;
width: 300px;
a {
text-decoration: none !important;
}
@@ -148,6 +259,8 @@ footer {
margin: 0.5em;
padding: 0.7em 1em;
cursor: pointer;
border-left-width: 0;
transition: all ease-in-out 0.1s;
}
.menu-item:hover {
@@ -171,9 +284,6 @@ footer {
}
.settings-content {
flex: 0 0 auto;
width: calc(100% - 300px);
.settings-content-header {
width: calc(100% + 20px);
border-bottom: 1px solid #dee2e6;
@@ -187,6 +297,18 @@ footer {
background: $dark-header-bg;
border-bottom: 0;
}
.mobile & {
padding: 15px 0 0 0;
.dark & {
background-color: transparent;
}
}
}
}
.logout {
color: $danger !important;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="form-container">
<div class="form-container" data-cy="setup-form">
<div class="form">
<form @submit.prevent="submit">
<div>
@@ -14,7 +14,7 @@
</p>
<div class="form-floating">
<select id="language" v-model="$i18n.locale" class="form-select">
<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>
@@ -23,21 +23,21 @@
</div>
<div class="form-floating mt-3">
<input id="floatingInput" v-model="username" type="text" class="form-control" placeholder="Username" required>
<input id="floatingInput" v-model="username" type="text" class="form-control" placeholder="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="Password" required>
<input id="floatingPassword" v-model="password" type="password" class="form-control" placeholder="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="Repeat Password" required>
<input id="repeat" v-model="repeatPassword" type="password" class="form-control" placeholder="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">
<button class="w-100 btn btn-primary mt-3" type="submit" :disabled="processing" data-cy="submit-setup-form">
{{ $t("Create") }}
</button>
</form>
@@ -59,9 +59,7 @@ export default {
};
},
watch: {
"$i18n.locale"() {
localStorage.locale = this.$i18n.locale;
},
},
mounted() {
this.$root.getSocket().emit("needSetup", (needSetup) => {
@@ -71,6 +69,10 @@ export default {
});
},
methods: {
/**
* Submit form data for processing
* @returns {void}
*/
submit() {
this.processing = true;

File diff suppressed because it is too large Load Diff