Merge pull request #1415 from louislam/status-page-domain

[Status Page] Map domain names to status pages
pull/1471/head
Louis Lam 3 years ago committed by GitHub
commit df5ba02f3f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -129,6 +129,11 @@ class Database {
await R.exec("PRAGMA cache_size = -12000"); await R.exec("PRAGMA cache_size = -12000");
await R.exec("PRAGMA auto_vacuum = FULL"); await R.exec("PRAGMA auto_vacuum = FULL");
// This ensures that an operating system crash or power failure will not corrupt the database.
// FULL synchronous is very safe, but it is also slower.
// Read more: https://sqlite.org/pragma.html#pragma_synchronous
await R.exec("PRAGMA synchronous = FULL");
if (!noLog) { if (!noLog) {
console.log("SQLite config:"); console.log("SQLite config:");
console.log(await R.getAll("PRAGMA journal_mode")); console.log(await R.getAll("PRAGMA journal_mode"));

@ -3,6 +3,20 @@ const { R } = require("redbean-node");
class StatusPage extends BeanModel { class StatusPage extends BeanModel {
static domainMappingList = { };
/**
* Return object like this: { "test-uptime.kuma.pet": "default" }
* @returns {Promise<void>}
*/
static async loadDomainMappingList() {
StatusPage.domainMappingList = await R.getAssoc(`
SELECT domain, slug
FROM status_page, status_page_cname
WHERE status_page.id = status_page_cname.status_page_id
`);
}
static async sendStatusPageList(io, socket) { static async sendStatusPageList(io, socket) {
let result = {}; let result = {};
@ -16,6 +30,57 @@ class StatusPage extends BeanModel {
return list; return list;
} }
async updateDomainNameList(domainNameList) {
if (!Array.isArray(domainNameList)) {
throw new Error("Invalid array");
}
let trx = await R.begin();
await trx.exec("DELETE FROM status_page_cname WHERE status_page_id = ?", [
this.id,
]);
try {
for (let domain of domainNameList) {
if (typeof domain !== "string") {
throw new Error("Invalid domain");
}
if (domain.trim() === "") {
continue;
}
// If the domain name is used in another status page, delete it
await trx.exec("DELETE FROM status_page_cname WHERE domain = ?", [
domain,
]);
let mapping = trx.dispense("status_page_cname");
mapping.status_page_id = this.id;
mapping.domain = domain;
await trx.store(mapping);
}
await trx.commit();
} catch (error) {
await trx.rollback();
throw error;
}
}
getDomainNameList() {
let domainList = [];
for (let domain in StatusPage.domainMappingList) {
let s = StatusPage.domainMappingList[domain];
if (this.slug === s) {
domainList.push(domain);
}
}
return domainList;
}
async toJSON() { async toJSON() {
return { return {
id: this.id, id: this.id,
@ -26,6 +91,7 @@ class StatusPage extends BeanModel {
theme: this.theme, theme: this.theme,
published: !!this.published, published: !!this.published,
showTags: !!this.show_tags, showTags: !!this.show_tags,
domainNameList: this.getDomainNameList(),
}; };
} }

@ -12,9 +12,19 @@ let router = express.Router();
let cache = apicache.middleware; let cache = apicache.middleware;
let io = server.io; let io = server.io;
router.get("/api/entry-page", async (_, response) => { router.get("/api/entry-page", async (request, response) => {
allowDevAllOrigin(response); allowDevAllOrigin(response);
response.json(server.entryPage);
let result = { };
if (request.hostname in StatusPage.domainMappingList) {
result.type = "statusPageMatchedDomain";
result.statusPageSlug = StatusPage.domainMappingList[request.hostname];
} else {
result.type = "entryPage";
result.entryPage = server.entryPage;
}
response.json(result);
}); });
router.get("/api/push/:pushToken", async (request, response) => { router.get("/api/push/:pushToken", async (request, response) => {

@ -211,6 +211,7 @@ try {
await initDatabase(testMode); await initDatabase(testMode);
exports.entryPage = await setting("entryPage"); exports.entryPage = await setting("entryPage");
await StatusPage.loadDomainMappingList();
console.log("Adding route"); console.log("Adding route");
@ -219,8 +220,13 @@ try {
// *************************** // ***************************
// Entry Page // Entry Page
app.get("/", async (_request, response) => { app.get("/", async (request, response) => {
if (exports.entryPage && exports.entryPage.startsWith("statusPage-")) { debug(`Request Domain: ${request.hostname}`);
if (request.hostname in StatusPage.domainMappingList) {
debug("This is a status page domain");
response.send(indexHTML);
} else if (exports.entryPage && exports.entryPage.startsWith("statusPage-")) {
response.redirect("/status/" + exports.entryPage.replace("statusPage-", "")); response.redirect("/status/" + exports.entryPage.replace("statusPage-", ""));
} else { } else {
response.redirect("/dashboard"); response.redirect("/dashboard");

@ -85,15 +85,35 @@ module.exports.statusPageSocketHandler = (socket) => {
} }
}); });
socket.on("getStatusPage", async (slug, callback) => {
try {
checkLogin(socket);
let statusPage = await R.findOne("status_page", " slug = ? ", [
slug
]);
if (!statusPage) {
throw new Error("No slug?");
}
callback({
ok: true,
config: await statusPage.toJSON(),
});
} catch (error) {
callback({
ok: false,
msg: error.message,
});
}
});
// Save Status Page // Save Status Page
// imgDataUrl Only Accept PNG! // imgDataUrl Only Accept PNG!
socket.on("saveStatusPage", async (slug, config, imgDataUrl, publicGroupList, callback) => { socket.on("saveStatusPage", async (slug, config, imgDataUrl, publicGroupList, callback) => {
try { try {
checkSlug(config.slug);
checkLogin(socket); checkLogin(socket);
apicache.clear();
// Save Config // Save Config
let statusPage = await R.findOne("status_page", " slug = ? ", [ let statusPage = await R.findOne("status_page", " slug = ? ", [
@ -104,6 +124,8 @@ module.exports.statusPageSocketHandler = (socket) => {
throw new Error("No slug?"); throw new Error("No slug?");
} }
checkSlug(config.slug);
const header = "data:image/png;base64,"; const header = "data:image/png;base64,";
// Check logo format // Check logo format
@ -137,6 +159,9 @@ module.exports.statusPageSocketHandler = (socket) => {
await R.store(statusPage); await R.store(statusPage);
await statusPage.updateDomainNameList(config.domainNameList);
await StatusPage.loadDomainMappingList();
// Save Public Group List // Save Public Group List
const groupIDList = []; const groupIDList = [];
let groupOrder = 1; let groupOrder = 1;
@ -193,6 +218,8 @@ module.exports.statusPageSocketHandler = (socket) => {
await setSetting("entryPage", server.entryPage, "general"); await setSetting("entryPage", server.entryPage, "general");
} }
apicache.clear();
callback({ callback({
ok: true, ok: true,
publicGroupList, publicGroupList,

@ -22,6 +22,18 @@ textarea.form-control {
width: 10px; width: 10px;
} }
.list-group {
border-radius: 0.75rem;
.dark & {
.list-group-item {
background-color: $dark-bg;
color: $dark-font-color;
border-color: $dark-border-color;
}
}
}
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #ccc; background: #ccc;
border-radius: 20px; border-radius: 20px;
@ -412,6 +424,10 @@ textarea.form-control {
background-color: rgba(239, 239, 239, 0.7); background-color: rgba(239, 239, 239, 0.7);
border-radius: 8px; border-radius: 8px;
&.no-bg {
background-color: transparent !important;
}
&:focus { &:focus {
outline: 0 solid #eee; outline: 0 solid #eee;
background-color: rgba(245, 245, 245, 0.9); background-color: rgba(245, 245, 245, 0.9);

@ -37,6 +37,8 @@ import {
faPen, faPen,
faExternalLinkSquareAlt, faExternalLinkSquareAlt,
faSpinner, faSpinner,
faUndo,
faPlusCircle,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
library.add( library.add(
@ -73,6 +75,8 @@ library.add(
faPen, faPen,
faExternalLinkSquareAlt, faExternalLinkSquareAlt,
faSpinner, faSpinner,
faUndo,
faPlusCircle,
); );
export { FontAwesomeIcon }; export { FontAwesomeIcon };

@ -339,7 +339,7 @@ export default {
"Switch to Dark Theme": "切換至深色佈景主題", "Switch to Dark Theme": "切換至深色佈景主題",
"Show Tags": "顯示標籤", "Show Tags": "顯示標籤",
"Hide Tags": "隱藏標籤", "Hide Tags": "隱藏標籤",
Description: "說明", Description: "描述",
"No monitors available.": "沒有可用的監測器。", "No monitors available.": "沒有可用的監測器。",
"Add one": "新增一個", "Add one": "新增一個",
"No Monitors": "無監測器", "No Monitors": "無監測器",
@ -347,7 +347,6 @@ export default {
Services: "服務", Services: "服務",
Discard: "捨棄", Discard: "捨棄",
Cancel: "取消", Cancel: "取消",
"Powered by": "技術支援",
shrinkDatabaseDescription: "觸發 SQLite 的資料庫清理 (VACUUM)。如果您的資料庫是在 1.10.0 版本後建立AUTO_VACUUM 已自動啟用,則無需此操作。", shrinkDatabaseDescription: "觸發 SQLite 的資料庫清理 (VACUUM)。如果您的資料庫是在 1.10.0 版本後建立AUTO_VACUUM 已自動啟用,則無需此操作。",
serwersms: "SerwerSMS.pl", serwersms: "SerwerSMS.pl",
serwersmsAPIUser: "API 使用者名稱 (包括 webapi_ 前綴)", serwersmsAPIUser: "API 使用者名稱 (包括 webapi_ 前綴)",

@ -1,19 +1,44 @@
<template> <template>
<div></div> <div>
<StatusPage v-if="statusPageSlug" :override-slug="statusPageSlug" />
</div>
</template> </template>
<script> <script>
import axios from "axios"; import axios from "axios";
import StatusPage from "./StatusPage.vue";
export default { export default {
components: {
StatusPage,
},
data() {
return {
statusPageSlug: null,
};
},
async mounted() { async mounted() {
let entryPage = (await axios.get("/api/entry-page")).data;
// 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;
} else if (res.type === "entryPage") { // Dev only. For production, the logic is in the server side
const entryPage = res.entryPage;
if (entryPage === "statusPage") { if (entryPage === "statusPage") {
this.$router.push("/status"); this.$router.push("/status");
} else { } else {
this.$router.push("/dashboard"); this.$router.push("/dashboard");
} }
} else {
this.$router.push("/dashboard");
}
}, },
}; };

@ -2,6 +2,7 @@
<div v-if="loadedTheme" class="container mt-3"> <div v-if="loadedTheme" class="container mt-3">
<!-- Sidebar for edit mode --> <!-- Sidebar for edit mode -->
<div v-if="enableEditMode" class="sidebar"> <div v-if="enableEditMode" class="sidebar">
<div class="sidebar-body">
<div class="my-3"> <div class="my-3">
<label for="slug" class="form-label">{{ $t("Slug") }}</label> <label for="slug" class="form-label">{{ $t("Slug") }}</label>
<div class="input-group"> <div class="input-group">
@ -35,9 +36,19 @@
<input id="password" v-model="config.password" disabled type="password" autocomplete="new-password" class="form-control"> <input id="password" v-model="config.password" disabled type="password" autocomplete="new-password" class="form-control">
</div> </div>
<div v-if="false" class="my-3"> <!-- Domain Name List -->
<label for="cname" class="form-label">Domain Names <sup>Coming Soon</sup></label> <div class="my-3">
<textarea id="cname" v-model="config.domanNames" rows="3" disabled class="form-control" :placeholder="domainNamesPlaceholder"></textarea> <label class="form-label">
Domain Names
<font-awesome-icon icon="plus-circle" class="btn-add-domain action text-primary" @click="addDomainField" />
</label>
<ul class="list-group domain-name-list">
<li v-for="(domain, index) in config.domainNameList" :key="index" class="list-group-item">
<input v-model="config.domainNameList[index]" type="text" class="no-bg domain-input" placeholder="example.com" />
<font-awesome-icon icon="times" class="action remove ms-2 me-3 text-danger" @click="removeDomain(index)" />
</li>
</ul>
</div> </div>
<div class="danger-zone"> <div class="danger-zone">
@ -46,6 +57,7 @@
{{ $t("Delete") }} {{ $t("Delete") }}
</button> </button>
</div> </div>
</div>
<!-- Sidebar Footer --> <!-- Sidebar Footer -->
<div class="sidebar-footer"> <div class="sidebar-footer">
@ -55,7 +67,7 @@
</button> </button>
<button class="btn btn-danger me-2" @click="discard"> <button class="btn btn-danger me-2" @click="discard">
<font-awesome-icon icon="save" /> <font-awesome-icon icon="undo" />
{{ $t("Discard") }} {{ $t("Discard") }}
</button> </button>
</div> </div>
@ -120,7 +132,7 @@
<!-- Incident Date --> <!-- Incident Date -->
<div class="date mt-3"> <div class="date mt-3">
{{ $t("Created") }}: {{ $root.datetime(incident.createdDate) }} ({{ dateFromNow(incident.createdDate) }})<br /> {{ $t("Date Created") }}: {{ $root.datetime(incident.createdDate) }} ({{ dateFromNow(incident.createdDate) }})<br />
<span v-if="incident.lastUpdatedDate"> <span v-if="incident.lastUpdatedDate">
{{ $t("Last Updated") }}: {{ $root.datetime(incident.lastUpdatedDate) }} ({{ dateFromNow(incident.lastUpdatedDate) }}) {{ $t("Last Updated") }}: {{ $root.datetime(incident.lastUpdatedDate) }} ({{ dateFromNow(incident.lastUpdatedDate) }})
</span> </span>
@ -259,6 +271,7 @@ const favicon = new Favico({
}); });
export default { export default {
components: { components: {
PublicGroupList, PublicGroupList,
ImageCropUpload, ImageCropUpload,
@ -278,6 +291,14 @@ export default {
next(); next();
}, },
props: {
overrideSlug: {
type: String,
required: false,
default: null,
},
},
data() { data() {
return { return {
slug: null, slug: null,
@ -294,7 +315,6 @@ export default {
loadedData: false, loadedData: false,
baseURL: "", baseURL: "",
clickedEditButton: false, clickedEditButton: false,
domainNamesPlaceholder: "domain1.com\ndomain2.com\n..."
}; };
}, },
computed: { computed: {
@ -389,6 +409,22 @@ export default {
}, },
watch: { watch: {
/**
* If connected to the socket and logged in, request private data of this statusPage
* @param connected
*/
"$root.loggedIn"(loggedIn) {
if (loggedIn) {
this.$root.getSocket().emit("getStatusPage", this.slug, (res) => {
if (res.ok) {
this.config = res.config;
} else {
toast.error(res.msg);
}
});
}
},
/** /**
* Selected a monitor and add to the list. * Selected a monitor and add to the list.
*/ */
@ -449,7 +485,7 @@ export default {
this.baseURL = getResBaseURL(); this.baseURL = getResBaseURL();
}, },
async mounted() { async mounted() {
this.slug = this.$route.params.slug; this.slug = this.overrideSlug || this.$route.params.slug;
if (!this.slug) { if (!this.slug) {
this.slug = "default"; this.slug = "default";
@ -458,6 +494,10 @@ export default {
axios.get("/api/status-page/" + this.slug).then((res) => { axios.get("/api/status-page/" + this.slug).then((res) => {
this.config = res.data.config; this.config = res.data.config;
if (!this.config.domainNameList) {
this.config.domainNameList = [];
}
if (this.config.icon) { if (this.config.icon) {
this.imgDataUrl = this.config.icon; this.imgDataUrl = this.config.icon;
} }
@ -575,6 +615,10 @@ export default {
}); });
}, },
addDomainField() {
this.config.domainNameList.push("");
},
discard() { discard() {
location.href = "/status/" + this.slug; location.href = "/status/" + this.slug;
}, },
@ -657,6 +701,10 @@ export default {
return dayjs.utc(date).fromNow(); return dayjs.utc(date).fromNow();
}, },
removeDomain(index) {
this.config.domainNameList.splice(index, 1);
},
} }
}; };
</script> </script>
@ -705,9 +753,7 @@ h1 {
top: 0; top: 0;
width: 300px; width: 300px;
height: 100vh; height: 100vh;
padding: 15px 15px 68px 15px;
overflow-x: hidden;
overflow-y: auto;
border-right: 1px solid #ededed; border-right: 1px solid #ededed;
.danger-zone { .danger-zone {
@ -715,13 +761,25 @@ h1 {
padding-top: 15px; padding-top: 15px;
} }
.sidebar-body {
padding: 0 10px 10px 10px;
overflow-x: hidden;
overflow-y: auto;
height: calc(100% - 70px);
}
.sidebar-footer { .sidebar-footer {
width: 100%;
bottom: 0;
left: 0;
padding: 15px;
position: absolute;
border-top: 1px solid #ededed; border-top: 1px solid #ededed;
border-right: 1px solid #ededed;
padding: 10px;
width: 300px;
height: 70px;
position: fixed;
left: 0;
bottom: 0;
background-color: white;
display: flex;
align-items: center;
} }
} }
@ -808,7 +866,29 @@ footer {
} }
.sidebar-footer { .sidebar-footer {
border-right-color: $dark-border-color;
border-top-color: $dark-border-color; border-top-color: $dark-border-color;
background-color: $dark-header-bg;
}
}
}
.domain-name-list {
li {
display: flex;
align-items: center;
padding: 10px 0 10px 10px;
.domain-input {
flex-grow: 1;
background-color: transparent;
border: none;
color: $dark-font-color;
outline: none;
&::placeholder {
color: #1d2634;
}
} }
} }
} }

Loading…
Cancel
Save