Merge pull request #753 from chakflying/settings-redesign

UI: Redesign/organize settings page
pull/950/head
Louis Lam 3 years ago committed by GitHub
commit c811c1ccde
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,6 +1,6 @@
# Project Info
First of all, thank you everyone who made pull requests for Uptime Kuma, I never thought GitHub Community can be that nice! And also because of this, I also never thought other people actually read my code and edit my code. It is not structed and commented so well, lol. Sorry about that.
First of all, thank you everyone who made pull requests for Uptime Kuma, I never thought GitHub Community can be that nice! And also because of this, I also never thought other people actually read my code and edit my code. It is not structured and commented so well, lol. Sorry about that.
The project was created with vite.js (vue3). Then I created a sub-directory called "server" for server part. Both frontend and backend share the same package.json.
@ -27,7 +27,7 @@ The frontend code build into "dist" directory. The server (express.js) exposes t
## Can I create a pull request for Uptime Kuma?
Generally, if the pull request is working fine and it do not affect any existing logic, workflow and perfomance, I will merge into the master branch once it is tested.
Generally, if the pull request is working fine and it do not affect any existing logic, workflow and performance, I will merge into the master branch once it is tested.
If you are not sure whether I will accept your pull request, feel free to create an empty pull request draft first.
@ -66,13 +66,13 @@ I do not have such knowledge to test it.
#### ⚠ Low Priority - Harsh Mode
Some pull requests are required to modifiy the core. To be honest, I do not want anyone to try to do that, because it would spend a lot of your time. I will review your pull request harshly. Also you may need to write a lot of unit tests to ensure that there is no breaking change.
Some pull requests are required to modify the core. To be honest, I do not want anyone to try to do that, because it would spend a lot of your time. I will review your pull request harshly. Also you may need to write a lot of unit tests to ensure that there is no breaking change.
- Touch large parts of code of any very important features
- Touch monitoring logic
- Drop a table or drop a column for any reason
- Touch the entry point of Docker or Node.js
- Modifiy auth
- Modify auth
#### *️⃣ Low Priority
@ -114,7 +114,7 @@ I personally do not like something need to learn so much and need to config so m
- Node.js >= 14
- Git
- IDE that supports ESLint and EditorConfig (I am using Intellji Idea)
- IDE that supports ESLint and EditorConfig (I am using IntelliJ IDEA)
- A SQLite tool (SQLite Expert Personal is suggested)
## Install dependencies
@ -141,7 +141,7 @@ express.js is just used for serving the frontend built files (index.html, .js an
- model/ (Object model, auto mapping to the database table name)
- modules/ (Modified 3rd-party modules)
- notification-providers/ (indivdual notification logic)
- notification-providers/ (individual notification logic)
- routers/ (Express Routers)
- scoket-handler (Socket.io Handlers)
- server.js (Server main logic)

@ -0,0 +1,33 @@
const PuppeteerEnvironment = require("jest-environment-puppeteer");
const util = require("util");
class DebugEnv extends PuppeteerEnvironment {
async handleTestEvent(event, state) {
const ignoredEvents = [
"setup",
"add_hook",
"start_describe_definition",
"add_test",
"finish_describe_definition",
"run_start",
"run_describe_start",
"test_start",
"hook_start",
"hook_success",
"test_fn_start",
"test_fn_success",
"test_done",
"run_describe_finish",
"run_finish",
"teardown",
"test_fn_failure",
];
if (!ignoredEvents.includes(event.name)) {
console.log(
new Date().toString() + ` Unhandled event [${event.name}] ` + util.inspect(event)
);
}
}
}
module.exports = DebugEnv;

@ -1,6 +1,20 @@
module.exports = {
"launch": {
"dumpio": true,
"slowMo": 500,
"headless": process.env.HEADLESS_TEST || false,
"userDataDir": "./data/test-chrome-profile",
args: [
"--disable-setuid-sandbox",
"--disable-gpu",
"--disable-dev-shm-usage",
"--no-default-browser-check",
"--no-experiments",
"--no-first-run",
"--no-pings",
"--no-sandbox",
"--no-zygote",
"--single-process",
],
}
};

@ -5,6 +5,7 @@ module.exports = {
"__DEV__": true
},
"testRegex": "./test/e2e.spec.js",
"testEnvironment": "./config/jest-debug-env.js",
"rootDir": "..",
"testTimeout": 30000,
};

@ -22,7 +22,7 @@
"build": "vite build --config ./config/vite.config.js",
"test": "node test/prepare-test-server.js && node server/server.js --port=3002 --data-dir=./data/test/ --test",
"test-with-build": "npm run build && npm test",
"jest": "node test/prepare-jest.js && npm run jest-frontend && npm run jest-backend && jest --config=./config/jest.config.js",
"jest": "node test/prepare-jest.js && npm run jest-frontend && npm run jest-backend && jest --runInBand --config=./config/jest.config.js",
"jest-frontend": "cross-env TEST_FRONTEND=1 jest --config=./config/jest-frontend.config.js",
"jest-backend": "cross-env TEST_BACKEND=1 jest --config=./config/jest-backend.config.js",
"tsc": "tsc",

@ -79,7 +79,7 @@ class Database {
console.log(`Data Dir: ${Database.dataDir}`);
}
static async connect() {
static async connect(testMode = false) {
const acquireConnectionTimeout = 120 * 1000;
const Dialect = require("knex/lib/dialects/sqlite3/index.js");
@ -112,8 +112,13 @@ class Database {
await R.autoloadModels("./server/model");
await R.exec("PRAGMA foreign_keys = ON");
// Change to WAL
await R.exec("PRAGMA journal_mode = WAL");
if (testMode) {
// Change to MEMORY
await R.exec("PRAGMA journal_mode = MEMORY");
} else {
// Change to WAL
await R.exec("PRAGMA journal_mode = WAL");
}
await R.exec("PRAGMA cache_size = -12000");
await R.exec("PRAGMA auto_vacuum = FULL");

@ -177,7 +177,7 @@ exports.entryPage = "dashboard";
(async () => {
Database.init(args);
await initDatabase();
await initDatabase(testMode);
exports.entryPage = await setting("entryPage");
@ -539,8 +539,8 @@ exports.entryPage = "dashboard";
await updateMonitorNotification(bean.id, notificationIDList);
await startMonitor(socket.userID, bean.id);
await sendMonitorList(socket);
await startMonitor(socket.userID, bean.id);
callback({
ok: true,
@ -1415,14 +1415,14 @@ async function getMonitorJSONList(userID) {
return result;
}
async function initDatabase() {
async function initDatabase(testMode = false) {
if (! fs.existsSync(Database.path)) {
console.log("Copying Database");
fs.copyFileSync(Database.templatePath, Database.path);
}
console.log("Connecting to the Database");
await Database.connect();
await Database.connect(testMode);
console.log("Connected");
// Patch the database

@ -12,6 +12,7 @@ $dark-font-color2: #020b05;
$dark-bg: #0d1117;
$dark-bg2: #070a10;
$dark-border-color: #1d2634;
$dark-header-bg: #161b22;
$easing-in: cubic-bezier(0.54, 0.78, 0.55, 0.97);
$easing-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);

@ -137,7 +137,7 @@ export default {
justify-content: space-between;
.dark & {
background-color: #161b22;
background-color: $dark-header-bg;
border-bottom: 0;
}
}

@ -0,0 +1,25 @@
<template>
<div class="d-flex justify-content-center align-items-center">
<div class="logo d-flex flex-column justify-content-center align-items-center">
<object class="my-4" width="200" height="200" data="/icon.svg" />
<div class="fs-4 fw-bold">Uptime Kuma</div>
<div>{{ $t("Version") }}: {{ $root.info.version }}</div>
<div class="my-1 update-link"><a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">{{ $t("Check Update On GitHub") }}</a></div>
</div>
</div>
</template>
<script>
export default {
};
</script>
<style lang="scss" scoped>
.logo {
margin: 4em 1em;
}
.update-link {
font-size: 0.9em;
}
</style>

@ -0,0 +1,149 @@
<template>
<div>
<div class="my-4">
<label for="language" class="form-label">
{{ $t("Language") }}
</label>
<select id="language" v-model="$i18n.locale" class="form-select">
<option
v-for="(lang, i) in $i18n.availableLocales"
:key="`Lang${i}`"
:value="lang"
>
{{ $i18n.messages[lang].languageName }}
</option>
</select>
</div>
<div class="my-4">
<label for="timezone" class="form-label">{{ $t("Theme") }}</label>
<div>
<div
class="btn-group"
role="group"
aria-label="Basic checkbox toggle button group"
>
<input
id="btncheck1"
v-model="$root.userTheme"
type="radio"
class="btn-check"
name="theme"
autocomplete="off"
value="light"
/>
<label class="btn btn-outline-primary" for="btncheck1">
{{ $t("Light") }}
</label>
<input
id="btncheck2"
v-model="$root.userTheme"
type="radio"
class="btn-check"
name="theme"
autocomplete="off"
value="dark"
/>
<label class="btn btn-outline-primary" for="btncheck2">
{{ $t("Dark") }}
</label>
<input
id="btncheck3"
v-model="$root.userTheme"
type="radio"
class="btn-check"
name="theme"
autocomplete="off"
value="auto"
/>
<label class="btn btn-outline-primary" for="btncheck3">
{{ $t("Auto") }}
</label>
</div>
</div>
</div>
<div class="my-4">
<label class="form-label">{{ $t("Theme - Heartbeat Bar") }}</label>
<div>
<div
class="btn-group"
role="group"
aria-label="Basic checkbox toggle button group"
>
<input
id="btncheck4"
v-model="$root.userHeartbeatBar"
type="radio"
class="btn-check"
name="heartbeatBarTheme"
autocomplete="off"
value="normal"
/>
<label class="btn btn-outline-primary" for="btncheck4">
{{ $t("Normal") }}
</label>
<input
id="btncheck5"
v-model="$root.userHeartbeatBar"
type="radio"
class="btn-check"
name="heartbeatBarTheme"
autocomplete="off"
value="bottom"
/>
<label class="btn btn-outline-primary" for="btncheck5">
{{ $t("Bottom") }}
</label>
<input
id="btncheck6"
v-model="$root.userHeartbeatBar"
type="radio"
class="btn-check"
name="heartbeatBarTheme"
autocomplete="off"
value="none"
/>
<label class="btn btn-outline-primary" for="btncheck6">
{{ $t("None") }}
</label>
</div>
</div>
</div>
</div>
</template>
<script>
import { setPageLocale } from "../../util-frontend";
export default {
watch: {
"$i18n.locale"() {
localStorage.locale = this.$i18n.locale;
setPageLocale();
},
},
};
</script>
<style lang="scss" scoped>
@import "../../assets/vars.scss";
.btn-check:active + .btn-outline-primary,
.btn-check:checked + .btn-outline-primary,
.btn-check:hover + .btn-outline-primary {
color: #fff;
.dark & {
color: #000;
}
}
.dark {
.list-group-item {
background-color: $dark-bg2;
color: $dark-font-color;
}
}
</style>

@ -0,0 +1,213 @@
<template>
<div>
<div class="my-4">
<h4 class="mt-4 mb-2">{{ $t("Export Backup") }}</h4>
<p>
{{ $t("backupDescription") }} <br />
({{ $t("backupDescription2") }}) <br />
</p>
<div class="mb-2">
<button class="btn btn-primary" @click="downloadBackup">
{{ $t("Export") }}
</button>
</div>
<p>
<strong>{{ $t("backupDescription3") }}</strong>
</p>
</div>
<div class="my-4">
<h4 class="mt-4 mb-2">{{ $t("Import Backup") }}</h4>
<label class="form-label">{{ $t("Options") }}:</label>
<br />
<div class="form-check form-check-inline">
<input
id="radioKeep"
v-model="importHandle"
class="form-check-input"
type="radio"
name="radioImportHandle"
value="keep"
/>
<label class="form-check-label" for="radioKeep">
{{ $t("Keep both") }}
</label>
</div>
<div class="form-check form-check-inline">
<input
id="radioSkip"
v-model="importHandle"
class="form-check-input"
type="radio"
name="radioImportHandle"
value="skip"
/>
<label class="form-check-label" for="radioSkip">
{{ $t("Skip existing") }}
</label>
</div>
<div class="form-check form-check-inline">
<input
id="radioOverwrite"
v-model="importHandle"
class="form-check-input"
type="radio"
name="radioImportHandle"
value="overwrite"
/>
<label class="form-check-label" for="radioOverwrite">
{{ $t("Overwrite") }}
</label>
</div>
<div class="form-text mb-2">
{{ $t("importHandleDescription") }}
</div>
<div class="mb-2">
<input
id="importBackup"
type="file"
class="form-control"
accept="application/json"
/>
</div>
<div class="input-group mb-2 justify-content-end">
<button
type="button"
class="btn btn-outline-primary"
:disabled="processing"
@click="confirmImport"
>
<div
v-if="processing"
class="spinner-border spinner-border-sm me-1"
></div>
{{ $t("Import") }}
</button>
</div>
<div
v-if="importAlert"
class="alert alert-danger mt-3"
style="padding: 6px 16px"
>
{{ importAlert }}
</div>
</div>
<Confirm
ref="confirmImport"
btn-style="btn-danger"
:yes-text="$t('Yes')"
:no-text="$t('No')"
@yes="importBackup"
>
{{ $t("confirmImportMsg") }}
</Confirm>
</div>
</template>
<script>
import Confirm from "../../components/Confirm.vue";
import dayjs from "dayjs";
import { useToast } from "vue-toastification";
const toast = useToast();
export default {
components: {
Confirm,
},
data() {
return {
processing: false,
importHandle: "skip",
importAlert: null,
};
},
methods: {
confirmImport() {
this.$refs.confirmImport.show();
},
downloadBackup() {
let time = dayjs().format("YYYY_MM_DD-hh_mm_ss");
let fileName = `Uptime_Kuma_Backup_${time}.json`;
let monitorList = Object.values(this.$root.monitorList);
let exportData = {
version: this.$root.info.version,
notificationList: this.$root.notificationList,
monitorList: monitorList,
};
exportData = JSON.stringify(exportData, null, 4);
let downloadItem = document.createElement("a");
downloadItem.setAttribute(
"href",
"data:application/json;charset=utf-8," +
encodeURIComponent(exportData)
);
downloadItem.setAttribute("download", fileName);
downloadItem.click();
},
importBackup() {
this.processing = true;
let uploadItem = document.getElementById("importBackup").files;
if (uploadItem.length <= 0) {
this.processing = false;
return (this.importAlert = this.$t("alertNoFile"));
}
if (uploadItem.item(0).type !== "application/json") {
this.processing = false;
return (this.importAlert = this.$t("alertWrongFileType"));
}
let fileReader = new FileReader();
fileReader.readAsText(uploadItem.item(0));
fileReader.onload = (item) => {
this.$root.uploadBackup(
item.target.result,
this.importHandle,
(res) => {
this.processing = false;
if (res.ok) {
toast.success(res.msg);
} else {
toast.error(res.msg);
}
}
);
};
},
},
};
</script>
<style lang="scss" scoped>
@import "../../assets/vars.scss";
.dark {
#importBackup {
&::file-selector-button {
color: $primary;
background-color: $dark-bg;
}
&:hover:not(:disabled):not([readonly])::file-selector-button {
color: $dark-font-color2;
background-color: $primary;
}
}
}
</style>

@ -0,0 +1,191 @@
<template>
<div>
<form class="my-4" @submit.prevent="saveGeneral">
<!-- Timezone -->
<div class="mb-4">
<label for="timezone" class="form-label">
{{ $t("Timezone") }}
</label>
<select id="timezone" v-model="$root.userTimezone" class="form-select">
<option value="auto">
{{ $t("Auto") }}: {{ guessTimezone }}
</option>
<option
v-for="(timezone, index) in timezoneList"
:key="index"
:value="timezone.value"
>
{{ timezone.name }}
</option>
</select>
</div>
<!-- Search Engine -->
<div class="mb-4">
<label class="form-label">
{{ $t("Search Engine Visibility") }}
</label>
<div class="form-check">
<input
id="searchEngineIndexYes"
v-model="settings.searchEngineIndex"
class="form-check-input"
type="radio"
name="flexRadioDefault"
:value="true"
required
/>
<label class="form-check-label" for="searchEngineIndexYes">
{{ $t("Allow indexing") }}
</label>
</div>
<div class="form-check">
<input
id="searchEngineIndexNo"
v-model="settings.searchEngineIndex"
class="form-check-input"
type="radio"
name="flexRadioDefault"
:value="false"
required
/>
<label class="form-check-label" for="searchEngineIndexNo">
{{ $t("Discourage search engines from indexing site") }}
</label>
</div>
</div>
<!-- Entry Page -->
<div class="mb-4">
<label class="form-label">{{ $t("Entry Page") }}</label>
<div class="form-check">
<input
id="entryPageYes"
v-model="settings.entryPage"
class="form-check-input"
type="radio"
name="statusPage"
value="dashboard"
required
/>
<label class="form-check-label" for="entryPageYes">
{{ $t("Dashboard") }}
</label>
</div>
<div class="form-check">
<input
id="entryPageNo"
v-model="settings.entryPage"
class="form-check-input"
type="radio"
name="statusPage"
value="statusPage"
required
/>
<label class="form-check-label" for="entryPageNo">
{{ $t("Status Page") }}
</label>
</div>
</div>
<!-- Primary Base URL -->
<div class="mb-4">
<label class="form-label" for="primaryBaseURL">
{{ $t("Primary Base URL") }}
</label>
<div class="input-group mb-3">
<input
id="primaryBaseURL"
v-model="settings.primaryBaseURL"
class="form-control"
name="primaryBaseURL"
placeholder="https://"
pattern="https?://.+"
/>
<button class="btn btn-outline-primary" type="button" @click="autoGetPrimaryBaseURL">
{{ $t("Auto Get") }}
</button>
</div>
<div class="form-text"></div>
</div>
<!-- Steam API Key -->
<div class="mb-4">
<label class="form-label" for="steamAPIKey">
{{ $t("Steam API Key") }}
</label>
<HiddenInput
id="steamAPIKey"
v-model="settings.steamAPIKey"
/>
<div class="form-text">
{{ $t("steamApiKeyDescription") }}
<a href="https://steamcommunity.com/dev" target="_blank">
https://steamcommunity.com/dev
</a>
</div>
</div>
<!-- Save Button -->
<div>
<button class="btn btn-primary" type="submit">
{{ $t("Save") }}
</button>
</div>
</form>
</div>
</template>
<script>
import HiddenInput from "../../components/HiddenInput.vue";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import { timezoneList } from "../../util-frontend";
dayjs.extend(utc);
dayjs.extend(timezone);
export default {
components: {
HiddenInput,
},
data() {
return {
timezoneList: timezoneList(),
};
},
computed: {
settings() {
return this.$parent.$parent.$parent.settings;
},
saveSettings() {
return this.$parent.$parent.$parent.saveSettings;
},
settingsLoaded() {
return this.$parent.$parent.$parent.settingsLoaded;
},
guessTimezone() {
return dayjs.tz.guess();
}
},
methods: {
saveGeneral() {
localStorage.timezone = this.$root.userTimezone;
this.saveSettings();
},
autoGetPrimaryBaseURL() {
this.settings.primaryBaseURL = location.protocol + "//" + location.host;
},
},
};
</script>
<style></style>

@ -0,0 +1,133 @@
<template>
<div>
<div class="my-4">
<label for="keepDataPeriodDays" class="form-label">
{{
$t("clearDataOlderThan", [
settings.keepDataPeriodDays,
])
}}
</label>
<input
id="keepDataPeriodDays"
v-model="settings.keepDataPeriodDays"
type="number"
class="form-control"
required
min="1"
step="1"
/>
</div>
<div class="my-4">
<button class="btn btn-primary" type="button" @click="saveSettings()">
{{ $t("Save") }}
</button>
</div>
<div class="my-4">
<div class="my-3">
<button class="btn btn-outline-info me-2" @click="shrinkDatabase">
{{ $t("Shrink Database") }} ({{ databaseSizeDisplay }})
</button>
<div class="form-text mt-2 mb-4 ms-2">{{ $t("shrinkDatabaseDescription") }}</div>
</div>
<button
id="clearAllStats-btn"
class="btn btn-outline-danger me-2 mb-2"
@click="confirmClearStatistics"
>
{{ $t("Clear all statistics") }}
</button>
</div>
<Confirm
ref="confirmClearStatistics"
btn-style="btn-danger"
:yes-text="$t('Yes')"
:no-text="$t('No')"
@yes="clearStatistics"
>
{{ $t("confirmClearStatisticsMsg") }}
</Confirm>
</div>
</template>
<script>
import Confirm from "../../components/Confirm.vue";
import { debug } from "../../util.ts";
import { useToast } from "vue-toastification";
const toast = useToast();
export default {
components: {
Confirm,
},
data() {
return {
databaseSize: 0,
};
},
computed: {
settings() {
return this.$parent.$parent.$parent.settings;
},
saveSettings() {
return this.$parent.$parent.$parent.saveSettings;
},
settingsLoaded() {
return this.$parent.$parent.$parent.settingsLoaded;
},
databaseSizeDisplay() {
return (
Math.round((this.databaseSize / 1024 / 1024) * 10) / 10 + " MB"
);
},
},
mounted() {
this.loadDatabaseSize();
},
methods: {
loadDatabaseSize() {
debug("load database size");
this.$root.getSocket().emit("getDatabaseSize", (res) => {
if (res.ok) {
this.databaseSize = res.size;
debug("database size: " + res.size);
} else {
debug(res);
}
});
},
shrinkDatabase() {
this.$root.getSocket().emit("shrinkDatabase", (res) => {
if (res.ok) {
this.loadDatabaseSize();
toast.success("Done");
} else {
debug(res);
}
});
},
confirmClearStatistics() {
this.$refs.confirmClearStatistics.show();
},
clearStatistics() {
this.$root.clearStatistics((res) => {
if (res.ok) {
this.$router.go();
} else {
toast.error(res.msg);
}
});
},
},
};
</script>
<style></style>

@ -0,0 +1,46 @@
<template>
<div>
<div class="notification-list my-4">
<p v-if="$root.notificationList.length === 0">
{{ $t("Not available, please setup.") }}
</p>
<p v-else>
{{ $t("notificationDescription") }}
</p>
<ul class="list-group mb-3" style="border-radius: 1rem;">
<li v-for="(notification, index) in $root.notificationList" :key="index" class="list-group-item">
{{ notification.name }}<br>
<a href="#" @click="$refs.notificationDialog.show(notification.id)">{{ $t("Edit") }}</a>
</li>
</ul>
<button class="btn btn-primary me-2" type="button" @click="$refs.notificationDialog.show()">
{{ $t("Setup Notification") }}
</button>
</div>
<NotificationDialog ref="notificationDialog" />
</div>
</template>
<script>
import NotificationDialog from "../../components/NotificationDialog.vue";
export default {
components: {
NotificationDialog
},
};
</script>
<style lang="scss" scoped>
@import "../../assets/vars.scss";
.dark {
.list-group-item {
background-color: $dark-bg2;
color: $dark-font-color;
}
}
</style>

@ -0,0 +1,323 @@
<template>
<div>
<div v-if="settingsLoaded" class="my-4">
<!-- Change Password -->
<template v-if="!settings.disableAuth">
<p>
{{ $t("Current User") }}: <strong>{{ username }}</strong>
<button v-if="! settings.disableAuth" id="logout-btn" class="btn btn-danger ms-4 me-2 mb-2" @click="$root.logout">{{ $t("Logout") }}</button>
</p>
<h5 class="my-4">{{ $t("Change Password") }}</h5>
<form class="mb-3" @submit.prevent="savePassword">
<div class="mb-3">
<label for="current-password" class="form-label">
{{ $t("Current Password") }}
</label>
<input
id="current-password"
v-model="password.currentPassword"
type="password"
class="form-control"
required
/>
</div>
<div class="mb-3">
<label for="new-password" class="form-label">
{{ $t("New Password") }}
</label>
<input
id="new-password"
v-model="password.newPassword"
type="password"
class="form-control"
required
/>
</div>
<div class="mb-3">
<label for="repeat-new-password" class="form-label">
{{ $t("Repeat New Password") }}
</label>
<input
id="repeat-new-password"
v-model="password.repeatNewPassword"
type="password"
class="form-control"
:class="{ 'is-invalid': invalidPassword }"
required
/>
<div class="invalid-feedback">
{{ $t("passwordNotMatchMsg") }}
</div>
</div>
<div>
<button class="btn btn-primary" type="submit">
{{ $t("Update Password") }}
</button>
</div>
</form>
</template>
<div v-if="! settings.disableAuth" class="mt-5 mb-3">
<h5 class="my-4">
{{ $t("Two Factor Authentication") }}
</h5>
<div class="mb-4">
<button
class="btn btn-primary me-2"
type="button"
@click="$refs.TwoFADialog.show()"
>
{{ $t("2FA Settings") }}
</button>
</div>
</div>
<div class="my-4">
<!-- Advanced -->
<h5 class="my-4">{{ $t("Advanced") }}</h5>
<div class="mb-4">
<button v-if="settings.disableAuth" id="enableAuth-btn" class="btn btn-outline-primary me-2 mb-2" @click="enableAuth">{{ $t("Enable Auth") }}</button>
<button v-if="! settings.disableAuth" id="disableAuth-btn" class="btn btn-primary me-2 mb-2" @click="confirmDisableAuth">{{ $t("Disable Auth") }}</button>
</div>
</div>
</div>
<TwoFADialog ref="TwoFADialog" />
<Confirm ref="confirmDisableAuth" btn-style="btn-danger" :yes-text="$t('I understand, please disable')" :no-text="$t('Leave')" @yes="disableAuth">
<template v-if="$i18n.locale === 'es-ES' ">
<p>Seguro que deseas <strong>deshabilitar la autenticación</strong>?</p>
<p>Es para <strong>quien implementa autenticación de terceros</strong> ante Uptime Kuma como por ejemplo Cloudflare Access.</p>
<p>Por favor usar con cuidado.</p>
</template>
<template v-else-if="$i18n.locale === 'pt-BR' ">
<p>Você tem certeza que deseja <strong>desativar a autenticação</strong>?</p>
<p>Isso é para <strong>alguém que tem autenticação de terceiros</strong> na frente do 'UpTime Kuma' como o Cloudflare Access.</p>
<p>Por favor, utilize isso com cautela.</p>
</template>
<template v-else-if="$i18n.locale === 'zh-HK' ">
<p>你是否確認<strong>取消登入認証</strong></p>
<p>這個功能是設計給已有<strong>第三方認証</strong>的用家例如 Cloudflare Access</p>
<p>請小心使用</p>
</template>
<template v-else-if="$i18n.locale === 'zh-CN' ">
<p>是否确定 <strong>取消登录验证</strong></p>
<p>这是为 <strong>有第三方认证</strong> 的用户提供的功能 Cloudflare Access</p>
<p>请谨慎使用</p>
</template>
<template v-else-if="$i18n.locale === 'zh-TW' ">
<p>你是否要<strong>取消登入驗證</strong></p>
<p>此功能是設計給已有<strong>第三方認證</strong>的使用者例如 Cloudflare Access</p>
<p>請謹慎使用</p>
</template>
<template v-else-if="$i18n.locale === 'de-DE' ">
<p>Bist du sicher das du die <strong>Authentifizierung deaktivieren</strong> möchtest?</p>
<p>Es ist für <strong>jemanden der eine externe Authentifizierung</strong> vor Uptime Kuma geschaltet hat, wie z.B. Cloudflare Access.</p>
<p>Bitte mit Vorsicht nutzen.</p>
</template>
<template v-else-if="$i18n.locale === 'sr' ">
<p>Да ли сте сигурни да желите да <strong>искључите аутентификацију</strong>?</p>
<p>То је за <strong>оне који имају додату аутентификацију</strong> испред Uptime Kuma као на пример Cloudflare Access.</p>
<p>Молим Вас користите ово са пажњом.</p>
</template>
<template v-else-if="$i18n.locale === 'sr-latn' ">
<p>Da li ste sigurni da želite da <strong>isključite autentifikaciju</strong>?</p>
<p>To je za <strong>one koji imaju dodatu autentifikaciju</strong> ispred Uptime Kuma kao na primer Cloudflare Access.</p>
<p>Molim Vas koristite ovo sa pažnjom.</p>
</template>
<template v-if="$i18n.locale === 'hr-HR' ">
<p>Jeste li sigurni da želite <strong>isključiti autentikaciju</strong>?</p>
<p>To je za <strong>korisnike koji imaju vanjsku autentikaciju stranice</strong> ispred Uptime Kume, poput usluge Cloudflare Access.</p>
<p>Pažljivo koristite ovu opciju.</p>
</template>
<template v-else-if="$i18n.locale === 'tr-TR' ">
<p><strong>Şifreli girişi devre dışı bırakmak istediğinizden</strong>emin misiniz?</p>
<p>Bu, Uptime Kuma'nın önünde Cloudflare Access gibi <strong>üçüncü taraf yetkilendirmesi olan</strong> kişiler içindir.</p>
<p>Lütfen dikkatli kullanın.</p>
</template>
<template v-else-if="$i18n.locale === 'ko-KR' ">
<p>정말로 <strong>인증 기능을 끌까요</strong>?</p>
<p> 기능은 <strong>Cloudflare Access와 같은 서드파티 인증</strong> Uptime Kuma 앞에 사용자를 위한 기능이에요.</p>
<p>신중하게 사용하세요.</p>
</template>
<template v-else-if="$i18n.locale === 'pl' ">
<p>Czy na pewno chcesz <strong>wyłączyć autoryzację</strong>?</p>
<p>Jest przeznaczony dla <strong>kogoś, kto ma autoryzację zewnętrzną</strong> przed Uptime Kuma, taką jak Cloudflare Access.</p>
<p>Proszę używać ostrożnie.</p>
</template>
<template v-else-if="$i18n.locale === 'et-EE' ">
<p>Kas soovid <strong>lülitada autentimise välja</strong>?</p>
<p>Kastuamiseks <strong>välise autentimispakkujaga</strong>, näiteks Cloudflare Access.</p>
<p>Palun kasuta vastutustundlikult.</p>
</template>
<template v-else-if="$i18n.locale === 'it-IT' ">
<p>Si è certi di voler <strong>disabilitare l'autenticazione</strong>?</p>
<p>È per <strong>chi ha l'autenticazione gestita da terze parti</strong> messa davanti ad Uptime Kuma, ad esempio Cloudflare Access.</p>
<p>Utilizzare con attenzione.</p>
</template>
<template v-else-if="$i18n.locale === 'id-ID' ">
<p>Apakah Anda yakin ingin <strong>menonaktifkan autentikasi</strong>?</p>
<p>Ini untuk <strong>mereka yang memiliki autentikasi pihak ketiga</strong> diletakkan di depan Uptime Kuma, misalnya akses Cloudflare.</p>
<p>Gunakan dengan hati-hati.</p>
</template>
<template v-else-if="$i18n.locale === 'ru-RU' ">
<p>Вы уверены, что хотите <strong>отключить авторизацию</strong>?</p>
<p>Это подходит для <strong>тех, у кого стоит другая авторизация</strong> перед открытием Uptime Kuma, например Cloudflare Access.</p>
<p>Пожалуйста, используйте с осторожностью.</p>
</template>
<template v-else-if="$i18n.locale === 'fa' ">
<p>آیا مطمئن هستید که میخواهید <strong>احراز هویت را غیر فعال کنید</strong>?</p>
<p>این ویژگی برای کسانی است که <strong> لایه امنیتی شخص ثالث دیگر بر روی این آدرس فعال کردهاند</strong>، مانند Cloudflare Access.</p>
<p>لطفا از این امکان با دقت استفاده کنید.</p>
</template>
<template v-else-if="$i18n.locale === 'bg-BG' ">
<p>Сигурни ли сте, че желаете да <strong>изключите удостоверяването</strong>?</p>
<p>Използва се в случаите, когато <strong>има настроен алтернативен метод за удостоверяване</strong> преди Uptime Kuma, например Cloudflare Access.</p>
<p>Моля, използвайте с повишено внимание.</p>
</template>
<template v-else-if="$i18n.locale === 'hu' ">
<p>Biztos benne, hogy <strong>kikapcsolja a hitelesítést</strong>?</p>
<p>Akkor érdemes, ha <strong>van 3rd-party hitelesítés</strong> az Uptime Kuma-t megelőzően mint a Cloudflare Access.</p>
<p>Használja megfontoltan!</p>
</template>
<template v-else-if="$i18n.locale === 'nb-NO' ">
<p>Er du sikker at du vil <strong>deaktiver autentisering</strong>?</p>
<p>Dette er for <strong>de som har tredjepartsautorisering</strong> foran Uptime Kuma, for eksempel Cloudflare Access.</p>
<p>Vennligst vær forsiktig.</p>
</template>
<!-- English (en) -->
<template v-else>
<p>Are you sure want to <strong>disable auth</strong>?</p>
<p>It is for <strong>someone who have 3rd-party auth</strong> in front of Uptime Kuma such as Cloudflare Access.</p>
<p>Please use it carefully.</p>
</template>
</Confirm>
</div>
</template>
<script>
import Confirm from "../../components/Confirm.vue";
import TwoFADialog from "../../components/TwoFADialog.vue";
export default {
components: {
Confirm,
TwoFADialog
},
data() {
return {
username: "",
invalidPassword: false,
password: {
currentPassword: "",
newPassword: "",
repeatNewPassword: "",
}
};
},
computed: {
settings() {
return this.$parent.$parent.$parent.settings;
},
saveSettings() {
return this.$parent.$parent.$parent.saveSettings;
},
settingsLoaded() {
return this.$parent.$parent.$parent.settingsLoaded;
}
},
watch: {
"password.repeatNewPassword"() {
this.invalidPassword = false;
},
},
mounted() {
this.loadUsername();
},
methods: {
savePassword() {
if (this.password.newPassword !== this.password.repeatNewPassword) {
this.invalidPassword = true;
} else {
this.$root
.getSocket()
.emit("changePassword", this.password, (res) => {
this.$root.toastRes(res);
if (res.ok) {
this.password.currentPassword = "";
this.password.newPassword = "";
this.password.repeatNewPassword = "";
}
});
}
},
loadUsername() {
const jwtPayload = this.$root.getJWTPayload();
if (jwtPayload) {
this.username = jwtPayload.username;
}
},
disableAuth() {
this.settings.disableAuth = true;
this.saveSettings();
},
enableAuth() {
this.settings.disableAuth = false;
this.saveSettings();
this.$root.storage().removeItem("token");
location.reload();
},
confirmDisableAuth() {
this.$refs.confirmDisableAuth.show();
},
},
};
</script>
<style lang="scss" scoped>
@import "../../assets/vars.scss";
h5:after {
content: "";
display: block;
width: 50%;
padding-top: 8px;
border-bottom: 1px solid $dark-border-color;
}
</style>

@ -4,7 +4,7 @@
2. Create a language file (e.g. `zh-TW.js`). The filename must be ISO language code: http://www.lingoes.net/en/translator/langcode.htm
3. Run `npm run update-language-files`. You can also use this command to check if there are new strings to translate for your language.
4. Your language file should be filled in. You can translate now.
5. Translate `src/pages/Settings.vue` (search for a `Confirm` component with `rel="confirmDisableAuth"`).
5. Translate `src/components/settings/Security.vue` (search for a `Confirm` component with `rel="confirmDisableAuth"`).
6. Import your language file in `src/i18n.js` and add it to `languageList` constant.
7. Make a [pull request](https://github.com/louislam/uptime-kuma/pulls) when you have done.

@ -307,4 +307,5 @@ export default {
steamApiKeyDescription: "For monitoring a Steam Game Server you need a Steam Web-API key. You can register your API key here: ",
"Current User": "Current User",
recent: "Recent",
shrinkDatabaseDescription: "Trigger database VACCUM for SQLite. If your database is created after 1.10.0, AUTO_VACCUM is already enabled and this action is not needed.",
};

@ -29,7 +29,7 @@
</router-link>
</li>
<li v-if="$root.loggedIn" class="nav-item">
<router-link to="/settings" class="nav-link">
<router-link to="/settings" class="nav-link" :class="{ active: $route.path.includes('settings') }">
<font-awesome-icon icon="cog" /> {{ $t("Settings") }}
</router-link>
</li>
@ -188,8 +188,8 @@ main {
.dark {
header {
background-color: #161b22;
border-bottom-color: #161b22 !important;
background-color: $dark-header-bg;
border-bottom-color: $dark-header-bg !important;
span {
color: #f0f6fc;

@ -194,7 +194,7 @@
</div>
<div class="mt-5 mb-1">
<button class="btn btn-primary" type="submit" :disabled="processing">{{ $t("Save") }}</button>
<button id="monitor-submit-btn" class="btn btn-primary" type="submit" :disabled="processing">{{ $t("Save") }}</button>
</div>
</div>

@ -1,527 +1,91 @@
<template>
<transition name="slide-fade" appear>
<div>
<h1 v-show="show" class="mb-3">
{{ $t("Settings") }}
</h1>
<div class="shadow-box">
<div class="row">
<div class="col-md-6">
<h2 class="mb-2">{{ $t("Appearance") }}</h2>
<div class="mb-3">
<label for="language" class="form-label">{{ $t("Language") }}</label>
<select id="language" v-model="$i18n.locale" class="form-select">
<option v-for="(lang, i) in $i18n.availableLocales" :key="`Lang${i}`" :value="lang">
{{ $i18n.messages[lang].languageName }}
</option>
</select>
</div>
<div class="mb-3">
<label for="timezone" class="form-label">{{ $t("Theme") }}</label>
<div>
<div class="btn-group" role="group" aria-label="Basic checkbox toggle button group">
<input id="btncheck1" v-model="$root.userTheme" type="radio" class="btn-check" name="theme" autocomplete="off" value="light">
<label class="btn btn-outline-primary" for="btncheck1">{{ $t("Light") }}</label>
<input id="btncheck2" v-model="$root.userTheme" type="radio" class="btn-check" name="theme" autocomplete="off" value="dark">
<label class="btn btn-outline-primary" for="btncheck2">{{ $t("Dark") }}</label>
<input id="btncheck3" v-model="$root.userTheme" type="radio" class="btn-check" name="theme" autocomplete="off" value="auto">
<label class="btn btn-outline-primary" for="btncheck3">{{ $t("Auto") }}</label>
</div>
</div>
<div>
<h1 v-show="show" class="mb-3">
{{ $t("Settings") }}
</h1>
<div class="shadow-box">
<div class="row">
<div class="settings-menu">
<router-link
v-for="(item, key) in subMenus"
:key="key"
:to="`/settings/${key}`"
>
<div class="menu-item">
{{ item.title }}
</div>
<div class="mb-3">
<label class="form-label">{{ $t("Theme - Heartbeat Bar") }}</label>
<div>
<div class="btn-group" role="group" aria-label="Basic checkbox toggle button group">
<input id="btncheck4" v-model="$root.userHeartbeatBar" type="radio" class="btn-check" name="heartbeatBarTheme" autocomplete="off" value="normal">
<label class="btn btn-outline-primary" for="btncheck4">{{ $t("Normal") }}</label>
<input id="btncheck5" v-model="$root.userHeartbeatBar" type="radio" class="btn-check" name="heartbeatBarTheme" autocomplete="off" value="bottom">
<label class="btn btn-outline-primary" for="btncheck5">{{ $t("Bottom") }}</label>
<input id="btncheck6" v-model="$root.userHeartbeatBar" type="radio" class="btn-check" name="heartbeatBarTheme" autocomplete="off" value="none">
<label class="btn btn-outline-primary" for="btncheck6">{{ $t("None") }}</label>
</div>
</div>
</div>
<!-- General Settings -->
<h2 class="mt-5 mb-2">{{ $t("General") }}</h2>
<form class="mb-3" @submit.prevent="saveGeneral">
<!-- Timezone -->
<div class="mb-4">
<label for="timezone" class="form-label">{{ $t("Timezone") }}</label>
<select id="timezone" v-model="$root.userTimezone" class="form-select">
<option value="auto">
{{ $t("Auto") }}: {{ guessTimezone }}
</option>
<option v-for="(timezone, index) in timezoneList" :key="index" :value="timezone.value">
{{ timezone.name }}
</option>
</select>
</div>
<!-- Search Engine -->
<div class="mb-4">
<label class="form-label">{{ $t("Search Engine Visibility") }}</label>
<div class="form-check">
<input id="searchEngineIndexYes" v-model="settings.searchEngineIndex" class="form-check-input" type="radio" name="flexRadioDefault" :value="true" required>
<label class="form-check-label" for="searchEngineIndexYes">
{{ $t("Allow indexing") }}
</label>
</div>
<div class="form-check">
<input id="searchEngineIndexNo" v-model="settings.searchEngineIndex" class="form-check-input" type="radio" name="flexRadioDefault" :value="false" required>
<label class="form-check-label" for="searchEngineIndexNo">
{{ $t("Discourage search engines from indexing site") }}
</label>
</div>
</div>
<!-- Entry Page -->
<div class="mb-4">
<label class="form-label">{{ $t("Entry Page") }}</label>
<div class="form-check">
<input id="entryPageYes" v-model="settings.entryPage" class="form-check-input" type="radio" name="statusPage" value="dashboard" required>
<label class="form-check-label" for="entryPageYes">
{{ $t("Dashboard") }}
</label>
</div>
<div class="form-check">
<input id="entryPageNo" v-model="settings.entryPage" class="form-check-input" type="radio" name="statusPage" value="statusPage" required>
<label class="form-check-label" for="entryPageNo">
{{ $t("Status Page") }}
</label>
</div>
</div>
<!-- Primary Base URL -->
<div class="mb-4">
<label class="form-label" for="primaryBaseURL">{{ $t("Primary Base URL") }}</label>
<div class="input-group mb-3">
<input id="primaryBaseURL" v-model="settings.primaryBaseURL" class="form-control" name="primaryBaseURL" placeholder="https://" pattern="https?://.+">
<button class="btn btn-outline-primary" type="button" @click="autoGetPrimaryBaseURL">{{ $t("Auto Get") }}</button>
</div>
<div class="form-text">
</div>
</div>
<!-- Steam API Key -->
<div class="mb-4">
<label class="form-label" for="steamAPIKey">{{ $t("Steam API Key") }}</label>
<HiddenInput id="steamAPIKey" v-model="settings.steamAPIKey" />
<div class="form-text">
{{ $t("steamApiKeyDescription") }}<a href="https://steamcommunity.com/dev" target="_blank">https://steamcommunity.com/dev</a>
</div>
</div>
<!-- Monitor History -->
<div class="mb-4">
<h4 class="mt-4">{{ $t("Monitor History") }}</h4>
<div class="mt-2">
<label for="keepDataPeriodDays" class="form-label">{{ $t("clearDataOlderThan", [ settings.keepDataPeriodDays ]) }}</label>
<input id="keepDataPeriodDays" v-model="settings.keepDataPeriodDays" type="number" class="form-control" required min="1" step="1">
</div>
</div>
<!-- Save Button -->
<div>
<button class="btn btn-primary" type="submit">
{{ $t("Save") }}
</button>
</div>
</form>
<template v-if="loaded">
<!-- Change Password -->
<template v-if="! settings.disableAuth">
<h2 class="mt-5 mb-2">{{ $t("Change Password") }}</h2>
<p>{{ $t("Current User") }}: <strong>{{ username }}</strong></p>
<form class="mb-3" @submit.prevent="savePassword">
<div class="mb-3">
<label for="current-password" class="form-label">{{ $t("Current Password") }}</label>
<input id="current-password" v-model="password.currentPassword" type="password" class="form-control" required>
</div>
<div class="mb-3">
<label for="new-password" class="form-label">{{ $t("New Password") }}</label>
<input id="new-password" v-model="password.newPassword" type="password" class="form-control" required>
</div>
<div class="mb-3">
<label for="repeat-new-password" class="form-label">{{ $t("Repeat New Password") }}</label>
<input id="repeat-new-password" v-model="password.repeatNewPassword" type="password" class="form-control" :class="{ 'is-invalid' : invalidPassword }" required>
<div class="invalid-feedback">
{{ $t("passwordNotMatchMsg") }}
</div>
</div>
<div>
<button class="btn btn-primary" type="submit">
{{ $t("Update Password") }}
</button>
</div>
</form>
</template>
<div v-if="! settings.disableAuth" class="mt-5 mb-3">
<h2 class="mb-2">
{{ $t("Two Factor Authentication") }}
</h2>
<button class="btn btn-primary me-2" type="button" @click="$refs.TwoFADialog.show()">{{ $t("2FA Settings") }}</button>
</div>
<h2 class="mt-5 mb-2">{{ $t("Export Backup") }}</h2>
<p>
{{ $t("backupDescription") }} <br />
({{ $t("backupDescription2") }}) <br />
</p>
<div class="mb-2">
<button class="btn btn-primary" @click="downloadBackup">{{ $t("Export") }}</button>
</div>
<p><strong>{{ $t("backupDescription3") }}</strong></p>
<h2 class="mt-5 mb-2">{{ $t("Import Backup") }}</h2>
<label class="form-label">{{ $t("Options") }}:</label>
<br>
<div class="form-check form-check-inline">
<input id="radioKeep" v-model="importHandle" class="form-check-input" type="radio" name="radioImportHandle" value="keep">
<label class="form-check-label" for="radioKeep">{{ $t("Keep both") }}</label>
</div>
<div class="form-check form-check-inline">
<input id="radioSkip" v-model="importHandle" class="form-check-input" type="radio" name="radioImportHandle" value="skip">
<label class="form-check-label" for="radioSkip">{{ $t("Skip existing") }}</label>
</div>
<div class="form-check form-check-inline">
<input id="radioOverwrite" v-model="importHandle" class="form-check-input" type="radio" name="radioImportHandle" value="overwrite">
<label class="form-check-label" for="radioOverwrite">{{ $t("Overwrite") }}</label>
</div>
<div class="form-text mb-2">
{{ $t("importHandleDescription") }}
</div>
<div class="mb-2">
<input id="importBackup" type="file" class="form-control" accept="application/json">
</div>
<div class="input-group mb-2 justify-content-end">
<button type="button" class="btn btn-outline-primary" :disabled="processing" @click="confirmImport">
<div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
{{ $t("Import") }}
</button>
</div>
<div v-if="importAlert" class="alert alert-danger mt-3" style="padding: 6px 16px;">
{{ importAlert }}
</div>
<!-- Advanced -->
<h2 class="mt-5 mb-2">{{ $t("Advanced") }}</h2>
<div class="mb-3">
<button v-if="settings.disableAuth" class="btn btn-outline-primary me-2 mb-2" @click="enableAuth">{{ $t("Enable Auth") }}</button>
<button v-if="! settings.disableAuth" class="btn btn-primary me-2 mb-2" @click="confirmDisableAuth">{{ $t("Disable Auth") }}</button>
<button v-if="! settings.disableAuth" class="btn btn-danger me-2 mb-2" @click="$root.logout">{{ $t("Logout") }}</button>
<button class="btn btn-outline-danger me-2 mb-2" @click="confirmClearStatistics">{{ $t("Clear all statistics") }}</button>
<button class="btn btn-info me-2 mb-2" @click="shrinkDatabase">{{ $t("Shrink Database") }} ({{ databaseSizeDisplay }})</button>
</div>
</template>
</router-link>
</div>
<div class="settings-content">
<div class="settings-content-header">
{{ subMenus[currentPage].title }}
</div>
<div class="col-md-6">
<div v-if="$root.isMobile" class="mt-3" />
<!-- Notifications -->
<div class="notification-list ">
<h2>{{ $t("Notifications") }}</h2>
<p v-if="$root.notificationList.length === 0">
{{ $t("Not available, please setup.") }}
</p>
<p v-else>
{{ $t("notificationDescription") }}
</p>
<ul class="list-group mb-3" style="border-radius: 1rem;">
<li v-for="(notification, index) in $root.notificationList" :key="index" class="list-group-item">
{{ notification.name }}<br>
<a href="#" @click="$refs.notificationDialog.show(notification.id)">{{ $t("Edit") }}</a>
</li>
</ul>
<button class="btn btn-primary me-2" type="button" @click="$refs.notificationDialog.show()">
{{ $t("Setup Notification") }}
</button>
</div>
<!-- Info -->
<h2 class="mt-5">{{ $t("Info") }}</h2>
{{ $t("Version") }}: {{ $root.info.version }} <br />
<a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">{{ $t("Check Update On GitHub") }}</a>
<div class="mx-3">
<router-view v-slot="{ Component }">
<transition name="slide-fade" appear>
<component :is="Component" />
</transition>
</router-view>
</div>
</div>
</div>
<NotificationDialog ref="notificationDialog" />
<TwoFADialog ref="TwoFADialog" />
<Confirm ref="confirmDisableAuth" btn-style="btn-danger" :yes-text="$t('I understand, please disable')" :no-text="$t('Leave')" @yes="disableAuth">
<template v-if="$i18n.locale === 'es-ES' ">
<p>Seguro que deseas <strong>deshabilitar la autenticación</strong>?</p>
<p>Es para <strong>quien implementa autenticación de terceros</strong> ante Uptime Kuma como por ejemplo Cloudflare Access.</p>
<p>Por favor usar con cuidado.</p>
</template>
<template v-else-if="$i18n.locale === 'pt-BR' ">
<p>Você tem certeza que deseja <strong>desativar a autenticação</strong>?</p>
<p>Isso é para <strong>alguém que tem autenticação de terceiros</strong> na frente do 'UpTime Kuma' como o Cloudflare Access.</p>
<p>Por favor, utilize isso com cautela.</p>
</template>
<template v-else-if="$i18n.locale === 'zh-HK' ">
<p>你是否確認<strong>取消登入認証</strong></p>
<p>這個功能是設計給已有<strong>第三方認証</strong>的用家例如 Cloudflare Access</p>
<p>請小心使用</p>
</template>
<template v-else-if="$i18n.locale === 'zh-CN' ">
<p>是否确定 <strong>取消登录验证</strong></p>
<p>这是为 <strong>有第三方认证</strong> 的用户提供的功能 Cloudflare Access</p>
<p>请谨慎使用</p>
</template>
<template v-else-if="$i18n.locale === 'zh-TW' ">
<p>你是否要<strong>取消登入驗證</strong></p>
<p>此功能是設計給已有<strong>第三方認證</strong>的使用者例如 Cloudflare Access</p>
<p>請謹慎使用</p>
</template>
<template v-else-if="$i18n.locale === 'de-DE' ">
<p>Bist du sicher das du die <strong>Authentifizierung deaktivieren</strong> möchtest?</p>
<p>Es ist für <strong>jemanden der eine externe Authentifizierung</strong> vor Uptime Kuma geschaltet hat, wie z.B. Cloudflare Access.</p>
<p>Bitte mit Vorsicht nutzen.</p>
</template>
<template v-else-if="$i18n.locale === 'sr' ">
<p>Да ли сте сигурни да желите да <strong>искључите аутентификацију</strong>?</p>
<p>То је за <strong>оне који имају додату аутентификацију</strong> испред Uptime Kuma као на пример Cloudflare Access.</p>
<p>Молим Вас користите ово са пажњом.</p>
</template>
<template v-else-if="$i18n.locale === 'sr-latn' ">
<p>Da li ste sigurni da želite da <strong>isključite autentifikaciju</strong>?</p>
<p>To je za <strong>one koji imaju dodatu autentifikaciju</strong> ispred Uptime Kuma kao na primer Cloudflare Access.</p>
<p>Molim Vas koristite ovo sa pažnjom.</p>
</template>
<template v-if="$i18n.locale === 'hr-HR' ">
<p>Jeste li sigurni da želite <strong>isključiti autentikaciju</strong>?</p>
<p>To je za <strong>korisnike koji imaju vanjsku autentikaciju stranice</strong> ispred Uptime Kume, poput usluge Cloudflare Access.</p>
<p>Pažljivo koristite ovu opciju.</p>
</template>
<template v-else-if="$i18n.locale === 'tr-TR' ">
<p><strong>Şifreli girişi devre dışı bırakmak istediğinizden</strong>emin misiniz?</p>
<p>Bu, Uptime Kuma'nın önünde Cloudflare Access gibi <strong>üçüncü taraf yetkilendirmesi olan</strong> kişiler içindir.</p>
<p>Lütfen dikkatli kullanın.</p>
</template>
<template v-else-if="$i18n.locale === 'ko-KR' ">
<p>정말로 <strong>인증 기능을 끌까요</strong>?</p>
<p> 기능은 <strong>Cloudflare Access와 같은 서드파티 인증</strong> Uptime Kuma 앞에 사용자를 위한 기능이에요.</p>
<p>신중하게 사용하세요.</p>
</template>
<template v-else-if="$i18n.locale === 'pl' ">
<p>Czy na pewno chcesz <strong>wyłączyć autoryzację</strong>?</p>
<p>Jest przeznaczony dla <strong>kogoś, kto ma autoryzację zewnętrzną</strong> przed Uptime Kuma, taką jak Cloudflare Access.</p>
<p>Proszę używać ostrożnie.</p>
</template>
<template v-else-if="$i18n.locale === 'et-EE' ">
<p>Kas soovid <strong>lülitada autentimise välja</strong>?</p>
<p>Kastuamiseks <strong>välise autentimispakkujaga</strong>, näiteks Cloudflare Access.</p>
<p>Palun kasuta vastutustundlikult.</p>
</template>
<template v-else-if="$i18n.locale === 'it-IT' ">
<p>Si è certi di voler <strong>disabilitare l'autenticazione</strong>?</p>
<p>È per <strong>chi ha l'autenticazione gestita da terze parti</strong> messa davanti ad Uptime Kuma, ad esempio Cloudflare Access.</p>
<p>Utilizzare con attenzione.</p>
</template>
<template v-else-if="$i18n.locale === 'id-ID' ">
<p>Apakah Anda yakin ingin <strong>menonaktifkan autentikasi</strong>?</p>
<p>Ini untuk <strong>mereka yang memiliki autentikasi pihak ketiga</strong> diletakkan di depan Uptime Kuma, misalnya akses Cloudflare.</p>
<p>Gunakan dengan hati-hati.</p>
</template>
<template v-else-if="$i18n.locale === 'ru-RU' ">
<p>Вы уверены, что хотите <strong>отключить авторизацию</strong>?</p>
<p>Это подходит для <strong>тех, у кого стоит другая авторизация</strong> перед открытием Uptime Kuma, например Cloudflare Access.</p>
<p>Пожалуйста, используйте с осторожностью.</p>
</template>
<template v-else-if="$i18n.locale === 'fa' ">
<p>آیا مطمئن هستید که میخواهید <strong>احراز هویت را غیر فعال کنید</strong>?</p>
<p>این ویژگی برای کسانی است که <strong> لایه امنیتی شخص ثالث دیگر بر روی این آدرس فعال کردهاند</strong>، مانند Cloudflare Access.</p>
<p>لطفا از این امکان با دقت استفاده کنید.</p>
</template>
<template v-else-if="$i18n.locale === 'bg-BG' ">
<p>Сигурни ли сте, че желаете да <strong>изключите удостоверяването</strong>?</p>
<p>Използва се в случаите, когато <strong>има настроен алтернативен метод за удостоверяване</strong> преди Uptime Kuma, например Cloudflare Access.</p>
<p>Моля, използвайте с повишено внимание.</p>
</template>
<template v-else-if="$i18n.locale === 'hu' ">
<p>Biztos benne, hogy <strong>kikapcsolja a hitelesítést</strong>?</p>
<p>Akkor érdemes, ha <strong>van 3rd-party hitelesítés</strong> az Uptime Kuma-t megelőzően mint a Cloudflare Access.</p>
<p>Használja megfontoltan!</p>
</template>
<template v-else-if="$i18n.locale === 'nb-NO' ">
<p>Er du sikker at du vil <strong>deaktiver autentisering</strong>?</p>
<p>Dette er for <strong>de som har tredjepartsautorisering</strong> foran Uptime Kuma, for eksempel Cloudflare Access.</p>
<p>Vennligst vær forsiktig.</p>
</template>
<!-- English (en) -->
<template v-else>
<p>Are you sure want to <strong>disable auth</strong>?</p>
<p>It is for <strong>someone who have 3rd-party auth</strong> in front of Uptime Kuma such as Cloudflare Access.</p>
<p>Please use it carefully.</p>
</template>
</Confirm>
<Confirm ref="confirmClearStatistics" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="clearStatistics">
{{ $t("confirmClearStatisticsMsg") }}
</Confirm>
<Confirm ref="confirmImport" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="importBackup">
{{ $t("confirmImportMsg") }}
</Confirm>
</div>
</transition>
</div>
</template>
<script>
import HiddenInput from "../components/HiddenInput.vue";
import Confirm from "../components/Confirm.vue";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import NotificationDialog from "../components/NotificationDialog.vue";
import TwoFADialog from "../components/TwoFADialog.vue";
import jwt_decode from "jwt-decode";
dayjs.extend(utc);
dayjs.extend(timezone);
import { timezoneList, setPageLocale } from "../util-frontend";
import { useToast } from "vue-toastification";
import { debug } from "../util.ts";
const toast = useToast();
import { useRoute } from "vue-router";
export default {
components: {
NotificationDialog,
TwoFADialog,
Confirm,
HiddenInput,
},
data() {
return {
timezoneList: timezoneList(),
guessTimezone: dayjs.tz.guess(),
show: true,
invalidPassword: false,
password: {
currentPassword: "",
newPassword: "",
repeatNewPassword: "",
},
settings: {
settings: {},
settingsLoaded: false,
subMenus: {
general: {
title: this.$t("General"),
},
appearance: {
title: this.$t("Appearance"),
},
notifications: {
title: this.$t("Notifications"),
},
"monitor-history": {
title: this.$t("Monitor History"),
},
security: {
title: this.$t("Security"),
},
backup: {
title: this.$t("Backup"),
},
about: {
title: this.$t("About"),
},
},
loaded: false,
importAlert: null,
importHandle: "skip",
processing: false,
databaseSize: 0,
};
},
computed: {
databaseSizeDisplay() {
return Math.round(this.databaseSize / 1024 / 1024 * 10) / 10 + " MB";
}
},
watch: {
"password.repeatNewPassword"() {
this.invalidPassword = false;
},
"$i18n.locale"() {
localStorage.locale = this.$i18n.locale;
setPageLocale();
currentPage() {
let pathEnd = useRoute().path.split("/").at(-1);
if (pathEnd == "settings" || pathEnd == null) {
return "general";
}
return pathEnd;
},
},
mounted() {
this.loadUsername();
this.loadSettings();
this.loadDatabaseSize();
},
methods: {
saveGeneral() {
localStorage.timezone = this.$root.userTimezone;
this.saveSettings();
},
savePassword() {
if (this.password.newPassword !== this.password.repeatNewPassword) {
this.invalidPassword = true;
} else {
this.$root.getSocket().emit("changePassword", this.password, (res) => {
this.$root.toastRes(res);
if (res.ok) {
this.password.currentPassword = "";
this.password.newPassword = "";
this.password.repeatNewPassword = "";
}
});
}
},
loadUsername() {
const jwtPayload = this.$root.getJWTPayload();
if (jwtPayload) {
this.username = jwtPayload.username;
}
},
loadSettings() {
this.$root.getSocket().emit("getSettings", (res) => {
this.settings = res.data;
@ -538,7 +102,7 @@ export default {
this.settings.keepDataPeriodDays = 180;
}
this.loaded = true;
this.settingsLoaded = true;
});
},
@ -548,116 +112,6 @@ export default {
this.loadSettings();
});
},
confirmDisableAuth() {
this.$refs.confirmDisableAuth.show();
},
confirmClearStatistics() {
this.$refs.confirmClearStatistics.show();
},
confirmImport() {
this.$refs.confirmImport.show();
},
disableAuth() {
this.settings.disableAuth = true;
this.saveSettings();
},
enableAuth() {
this.settings.disableAuth = false;
this.saveSettings();
this.$root.storage().removeItem("token");
location.reload();
},
downloadBackup() {
let time = dayjs().format("YYYY_MM_DD-hh_mm_ss");
let fileName = `Uptime_Kuma_Backup_${time}.json`;
let monitorList = Object.values(this.$root.monitorList);
let exportData = {
version: this.$root.info.version,
notificationList: this.$root.notificationList,
monitorList: monitorList,
};
exportData = JSON.stringify(exportData, null, 4);
let downloadItem = document.createElement("a");
downloadItem.setAttribute("href", "data:application/json;charset=utf-8," + encodeURIComponent(exportData));
downloadItem.setAttribute("download", fileName);
downloadItem.click();
},
importBackup() {
this.processing = true;
let uploadItem = document.getElementById("importBackup").files;
if (uploadItem.length <= 0) {
this.processing = false;
return this.importAlert = this.$t("alertNoFile");
}
if (uploadItem.item(0).type !== "application/json") {
this.processing = false;
return this.importAlert = this.$t("alertWrongFileType");
}
let fileReader = new FileReader();
fileReader.readAsText(uploadItem.item(0));
fileReader.onload = item => {
this.$root.uploadBackup(item.target.result, this.importHandle, (res) => {
this.processing = false;
if (res.ok) {
toast.success(res.msg);
} else {
toast.error(res.msg);
}
});
};
},
clearStatistics() {
this.$root.clearStatistics((res) => {
if (res.ok) {
this.$router.go();
} else {
toast.error(res.msg);
}
});
},
autoGetPrimaryBaseURL() {
this.settings.primaryBaseURL = location.protocol + "//" + location.host;
},
shrinkDatabase() {
this.$root.getSocket().emit("shrinkDatabase", (res) => {
if (res.ok) {
this.loadDatabaseSize();
toast.success("Done");
} else {
debug(res);
}
});
},
loadDatabaseSize() {
debug("load database size");
this.$root.getSocket().emit("getDatabaseSize", (res) => {
if (res.ok) {
this.databaseSize = res.size;
debug("database size: " + res.size);
} else {
debug(res);
}
});
}
},
};
</script>
@ -667,44 +121,69 @@ export default {
.shadow-box {
padding: 20px;
min-height: calc(100vh - 155px);
}
.btn-check:active + .btn-outline-primary,
.btn-check:checked + .btn-outline-primary,
.btn-check:hover + .btn-outline-primary {
color: #fff;
footer {
color: #aaa;
font-size: 13px;
margin-top: 20px;
padding-bottom: 30px;
text-align: center;
}
.dark {
.list-group-item {
background-color: $dark-bg2;
color: $dark-font-color;
.settings-menu {
flex: 0 0 auto;
width: 300px;
a {
text-decoration: none !important;
}
.btn-check:active + .btn-outline-primary,
.btn-check:checked + .btn-outline-primary,
.btn-check:hover + .btn-outline-primary {
color: #000;
.menu-item {
border-radius: 10px;
margin: 0.5em;
padding: 0.7em 1em;
cursor: pointer;
}
#importBackup {
&::file-selector-button {
color: $primary;
background-color: $dark-bg;
.menu-item:hover {
background: $highlight-white;
.dark & {
background: $dark-header-bg;
}
}
&:hover:not(:disabled):not([readonly])::file-selector-button {
color: $dark-font-color2;
background-color: $primary;
.active .menu-item {
background: $highlight-white;
border-left: 4px solid $primary;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
.dark & {
background: $dark-header-bg;
}
}
}
footer {
color: #aaa;
font-size: 13px;
margin-top: 20px;
padding-bottom: 30px;
text-align: center;
.settings-content {
flex: 0 0 auto;
width: calc(100% - 300px);
.settings-content-header {
width: calc(100% + 20px);
border-bottom: 1px solid #dee2e6;
border-radius: 0 10px 0 0;
margin-top: -20px;
margin-right: -20px;
padding: 12.5px 1em;
font-size: 26px;
.dark & {
background: $dark-header-bg;
border-bottom: 0;
}
}
}
</style>

@ -11,6 +11,14 @@ import Setup from "./pages/Setup.vue";
const StatusPage = () => import("./pages/StatusPage.vue");
import Entry from "./pages/Entry.vue";
import Appearance from "./components/settings/Appearance.vue";
import General from "./components/settings/General.vue";
import Notifications from "./components/settings/Notifications.vue";
import MonitorHistory from "./components/settings/MonitorHistory.vue";
import Security from "./components/settings/Security.vue";
import Backup from "./components/settings/Backup.vue";
import About from "./components/settings/About.vue";
const routes = [
{
path: "/",
@ -59,6 +67,37 @@ const routes = [
{
path: "/settings",
component: Settings,
children: [
{
path: "general",
alias: "",
component: General,
},
{
path: "appearance",
component: Appearance,
},
{
path: "notifications",
component: Notifications,
},
{
path: "monitor-history",
component: MonitorHistory,
},
{
path: "security",
component: Security,
},
{
path: "backup",
component: Backup,
},
{
path: "about",
component: About,
},
]
},
],
},

@ -59,18 +59,37 @@ describe("Init", () => {
// Go to /
await page.goto(baseURL);
await sleep(3000);
await page.waitForSelector("h1.mb-3");
pathname = await page.evaluate(() => location.pathname);
expect(pathname).toEqual("/dashboard");
});
it("should create monitor", async () => {
// Create monitor
await page.goto(baseURL + "/add");
await page.waitForSelector("#name");
await page.type("#name", "Myself");
await page.waitForSelector("#url");
await page.click("#url", { clickCount: 3 });
await page.keyboard.type(baseURL);
await page.keyboard.press("Enter");
await page.waitForFunction(() => {
const badge = document.querySelector("span.badge");
return badge && badge.innerText == "100%";
}, { timeout: 5000 });
});
// Settings Page
describe("Settings", () => {
beforeAll(async () => {
beforeEach(async () => {
await page.goto(baseURL + "/settings");
});
it("Change Language", async () => {
await page.goto(baseURL + "/settings/appearance");
await page.waitForSelector("#language");
await page.select("#language", "zh-HK");
@ -83,20 +102,33 @@ describe("Init", () => {
});
it("Change Theme", async () => {
await sleep(1000);
await page.goto(baseURL + "/settings/appearance");
// Dark
await click(page, ".btn[for=btncheck2]");
await page.waitForSelector("div.dark");
await sleep(1000);
await page.waitForSelector(".btn[for=btncheck1]");
// Light
await click(page, ".btn[for=btncheck1]");
await page.waitForSelector("div.light");
});
// TODO: Heartbeat Bar Style
it("Change Heartbeat Bar Style", async () => {
await page.goto(baseURL + "/settings/appearance");
// Bottom
await click(page, ".btn[for=btncheck5]");
await page.waitForSelector("div.hp-bar-big");
// None
await click(page, ".btn[for=btncheck6]");
await page.waitForSelector("div.hp-bar-big", {
hidden: true,
timeout: 1000
});
});
// TODO: Timezone
@ -108,14 +140,14 @@ describe("Init", () => {
// Yes
await click(page, "#searchEngineIndexYes");
await click(page, "form > div > .btn[type=submit]");
await sleep(2000);
await sleep(1000);
res = await axios.get(baseURL + "/robots.txt");
expect(res.data).not.toContain("Disallow: /");
// No
await click(page, "#searchEngineIndexNo");
await click(page, "form > div > .btn[type=submit]");
await sleep(2000);
await sleep(1000);
res = await axios.get(baseURL + "/robots.txt");
expect(res.data).toContain("Disallow: /");
});
@ -125,25 +157,25 @@ describe("Init", () => {
// Default
await newPage.goto(baseURL);
await sleep(3000);
await newPage.waitForSelector("h1.mb-3", { timeout: 3000 });
let pathname = await newPage.evaluate(() => location.pathname);
expect(pathname).toEqual("/dashboard");
// Status Page
await click(page, "#entryPageNo");
await click(page, "form > div > .btn[type=submit]");
await sleep(4000);
await sleep(1000);
await newPage.goto(baseURL);
await sleep(4000);
await newPage.waitForSelector("img.logo", { timeout: 3000 });
pathname = await newPage.evaluate(() => location.pathname);
expect(pathname).toEqual("/status");
// Back to Dashboard
await click(page, "#entryPageYes");
await click(page, "form > div > .btn[type=submit]");
await sleep(4000);
await sleep(1000);
await newPage.goto(baseURL);
await sleep(4000);
await newPage.waitForSelector("h1.mb-3", { timeout: 3000 });
pathname = await newPage.evaluate(() => location.pathname);
expect(pathname).toEqual("/dashboard");
@ -151,7 +183,7 @@ describe("Init", () => {
});
it("Change Password (wrong current password)", async () => {
await page.goto(baseURL + "/settings");
await page.goto(baseURL + "/settings/security");
await page.waitForSelector("#current-password");
await page.type("#current-password", "wrong_passw$$d");
@ -159,10 +191,10 @@ describe("Init", () => {
await page.type("#repeat-new-password", "new_password123");
// Save
await click(page, "form > div > .btn[type=submit]", 1);
await sleep(4000);
await click(page, "form > div > .btn[type=submit]", 0);
await sleep(1000);
await click(page, ".btn-danger.btn.me-2");
await click(page, "#logout-btn");
await login("admin", "new_password123");
let elementCount = await page.evaluate(() => document.querySelectorAll("#floatingPassword").length);
expect(elementCount).toEqual(1);
@ -171,24 +203,26 @@ describe("Init", () => {
});
it("Change Password (wrong repeat)", async () => {
await page.goto(baseURL + "/settings");
await page.goto(baseURL + "/settings/security");
await page.waitForSelector("#current-password");
await page.type("#current-password", "admin123");
await page.type("#new-password", "new_password123");
await page.type("#repeat-new-password", "new_password1234567898797898");
await click(page, "form > div > .btn[type=submit]", 1);
await sleep(4000);
await click(page, "form > div > .btn[type=submit]", 0);
await sleep(1000);
await click(page, ".btn-danger.btn.me-2");
await click(page, "#logout-btn");
await login("admin", "new_password123");
let elementCount = await page.evaluate(() => document.querySelectorAll("#floatingPassword").length);
expect(elementCount).toEqual(1);
await login("admin", "admin123");
await sleep(3000);
await page.waitForSelector("#current-password");
let pathname = await page.evaluate(() => location.pathname);
expect(pathname).toEqual("/settings/security");
});
// TODO: 2FA
@ -197,9 +231,35 @@ describe("Init", () => {
// TODO: Import Backup
// TODO: Disable Auth
it("Should disable & enable auth", async () => {
await page.goto(baseURL + "/settings/security");
await click(page, "#disableAuth-btn");
await click(page, ".btn.btn-danger[data-bs-dismiss='modal']", 2); // Not a good way to do it
await page.waitForSelector("#enableAuth-btn", { timeout: 3000 });
await page.waitForSelector("#logout-btn", {
hidden: true,
timeout: 3000
});
const newPage = await browser.newPage();
await newPage.goto(baseURL);
await newPage.waitForSelector("span.badge", { timeout: 3000 });
newPage.close();
await click(page, "#enableAuth-btn");
await login("admin", "admin123");
await page.waitForSelector("#disableAuth-btn", { timeout: 3000 });
});
// TODO: Clear Stats
// it("Should clear all statistics", async () => {
// await page.goto(baseURL + "/settings/monitor-history");
// await click(page, "#clearAllStats-btn");
// await click(page, ".btn.btn-danger");
// await page.waitForFunction(() => {
// const badge = document.querySelector("span.badge");
// return badge && badge.innerText == "0%";
// }, { timeout: 3000 });
// });
});
/*

Loading…
Cancel
Save