From bd118ea3ea462d14e31ce78b185ebe1ab9fc5022 Mon Sep 17 00:00:00 2001
From: hadestructhor <60148800+hadestructhor@users.noreply.github.com>
Date: Sat, 8 Feb 2025 22:13:35 +0100
Subject: [PATCH 1/5] Add support for umami analytics in status page
From afae736972babf3017e04d577b0c4a5b470d8993 Mon Sep 17 00:00:00 2001
From: hadestructhor <60148800+hadestructhor@users.noreply.github.com>
Date: Sun, 9 Feb 2025 14:06:25 +0100
Subject: [PATCH 2/5] feat: add support for umami tracking
---
db/knex_init_db.js | 2 ++
.../patch-add-umami-analytics-status-page.sql | 10 ++++++
server/database.js | 1 +
server/model/status_page.js | 10 ++++++
.../status-page-socket-handler.js | 2 ++
server/umami-analytics.js | 36 +++++++++++++++++++
src/lang/en.json | 2 ++
src/pages/StatusPage.vue | 10 ++++++
test/e2e/specs/status-page.spec.js | 6 ++++
9 files changed, 79 insertions(+)
create mode 100644 db/old_migrations/patch-add-umami-analytics-status-page.sql
create mode 100644 server/umami-analytics.js
diff --git a/db/knex_init_db.js b/db/knex_init_db.js
index 46bff4bfa..42b076c45 100644
--- a/db/knex_init_db.js
+++ b/db/knex_init_db.js
@@ -203,6 +203,8 @@ async function createTables() {
table.text("custom_css");
table.boolean("show_powered_by").notNullable().defaultTo(true);
table.string("google_analytics_tag_id");
+ table.string("umami_analytics_domain_url");
+ table.string("umami_analytics_website_id");
});
// maintenance_status_page
diff --git a/db/old_migrations/patch-add-umami-analytics-status-page.sql b/db/old_migrations/patch-add-umami-analytics-status-page.sql
new file mode 100644
index 000000000..e8b15f4d5
--- /dev/null
+++ b/db/old_migrations/patch-add-umami-analytics-status-page.sql
@@ -0,0 +1,10 @@
+-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
+BEGIN TRANSACTION;
+
+ALTER TABLE status_page
+ ADD umami_analytics_domain_url VARCHAR;
+
+ALTER TABLE status_page
+ADD umami_analytics_website_id VARCHAR;
+
+COMMIT;
diff --git a/server/database.js b/server/database.js
index 0e6a7405d..6aa302745 100644
--- a/server/database.js
+++ b/server/database.js
@@ -95,6 +95,7 @@ class Database {
"patch-maintenance-table2.sql": true,
"patch-add-gamedig-monitor.sql": true,
"patch-add-google-analytics-status-page-tag.sql": true,
+ "patch-add-umami-analytics-status-page.sql": true,
"patch-http-body-encoding.sql": true,
"patch-add-description-monitor.sql": true,
"patch-api-key-table.sql": true,
diff --git a/server/model/status_page.js b/server/model/status_page.js
index 38f548ebb..6548effc6 100644
--- a/server/model/status_page.js
+++ b/server/model/status_page.js
@@ -4,6 +4,7 @@ const cheerio = require("cheerio");
const { UptimeKumaServer } = require("../uptime-kuma-server");
const jsesc = require("jsesc");
const googleAnalytics = require("../google-analytics");
+const umamiAnalytics = require("../umami-analytics");
const { marked } = require("marked");
const { Feed } = require("feed");
const config = require("../config");
@@ -125,6 +126,11 @@ class StatusPage extends BeanModel {
head.append($(escapedGoogleAnalyticsScript));
}
+ if (statusPage.umamiAnalyticsDomainUrl && statusPage.umamiAnalyticsWebsiteId) {
+ let escapedUmamiAnalyticsScript = umamiAnalytics.getUmamiAnalyticsScript(statusPage.umamiAnalyticsDomainUrl, statusPage.umamiAnalyticsWebsiteId);
+ head.append($(escapedUmamiAnalyticsScript));
+ }
+
// OG Meta Tags
let ogTitle = $("").attr("content", statusPage.title);
head.append(ogTitle);
@@ -408,6 +414,8 @@ class StatusPage extends BeanModel {
footerText: this.footer_text,
showPoweredBy: !!this.show_powered_by,
googleAnalyticsId: this.google_analytics_tag_id,
+ umamiAnalyticsDomainUrl: this.umami_analytics_domain_url,
+ umamiAnalyticsWebsiteId: this.umami_analytics_website_id,
showCertificateExpiry: !!this.show_certificate_expiry,
};
}
@@ -431,6 +439,8 @@ class StatusPage extends BeanModel {
footerText: this.footer_text,
showPoweredBy: !!this.show_powered_by,
googleAnalyticsId: this.google_analytics_tag_id,
+ umamiAnalyticsDomainUrl: this.umami_analytics_domain_url,
+ umamiAnalyticsWebsiteId: this.umami_analytics_website_id,
showCertificateExpiry: !!this.show_certificate_expiry,
};
}
diff --git a/server/socket-handlers/status-page-socket-handler.js b/server/socket-handlers/status-page-socket-handler.js
index 1114d81fd..c6a474cac 100644
--- a/server/socket-handlers/status-page-socket-handler.js
+++ b/server/socket-handlers/status-page-socket-handler.js
@@ -167,6 +167,8 @@ module.exports.statusPageSocketHandler = (socket) => {
statusPage.show_certificate_expiry = config.showCertificateExpiry;
statusPage.modified_date = R.isoDateTime();
statusPage.google_analytics_tag_id = config.googleAnalyticsId;
+ statusPage.umami_analytics_domain_url = config.umamiAnalyticsDomainUrl;
+ statusPage.umami_analytics_website_id = config.umamiAnalyticsWebsiteId;
await R.store(statusPage);
diff --git a/server/umami-analytics.js b/server/umami-analytics.js
new file mode 100644
index 000000000..5a4a172f9
--- /dev/null
+++ b/server/umami-analytics.js
@@ -0,0 +1,36 @@
+const jsesc = require("jsesc");
+const { escape } = require("html-escaper");
+
+/**
+ * Returns a string that represents the javascript that is required to insert the Umami Analytics script
+ * into a webpage.
+ * @param {string} domainUrl Domain name with tld to use with the Umami Analytics script.
+ * @param {string} websiteId Website ID to use with the Umami Analytics script.
+ * @returns {string} HTML script tags to inject into page
+ */
+function getUmamiAnalyticsScript(domainUrl, websiteId) {
+ let escapedDomainUrlJS = jsesc(domainUrl, { isScriptContext: true });
+ let escapedWebsiteIdJS = jsesc(websiteId, { isScriptContext: true });
+
+ if (escapedDomainUrlJS) {
+ escapedDomainUrlJS = escapedDomainUrlJS.trim();
+ }
+
+ if (escapedWebsiteIdJS) {
+ escapedWebsiteIdJS = escapedWebsiteIdJS.trim();
+ }
+
+ // Escape the domain url for use in an HTML attribute.
+ let escapedDomainUrlHTMLAttribute = escape(escapedDomainUrlJS);
+
+ // Escape the website id for use in an HTML attribute.
+ let escapedWebsiteIdHTMLAttribute = escape(escapedWebsiteIdJS);
+
+ return `
+
+ `;
+}
+
+module.exports = {
+ getUmamiAnalyticsScript,
+};
diff --git a/src/lang/en.json b/src/lang/en.json
index e215f1031..c06547fc5 100644
--- a/src/lang/en.json
+++ b/src/lang/en.json
@@ -782,6 +782,8 @@
"wayToGetClickSendSMSToken": "You can get API Username and API Key from {0} .",
"Custom Monitor Type": "Custom Monitor Type",
"Google Analytics ID": "Google Analytics ID",
+ "Umami Analytics Domain Url": "Umami Analytics Domain Url",
+ "Umami Analytics Website ID": "Umami Analytics Website ID",
"Edit Tag": "Edit Tag",
"Server Address": "Server Address",
"Learn More": "Learn More",
diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue
index 116968282..8edb8f4e1 100644
--- a/src/pages/StatusPage.vue
+++ b/src/pages/StatusPage.vue
@@ -98,6 +98,16 @@
+
+
+
+
+
+
+
+
+
+
{{ $t("Custom CSS") }}
diff --git a/test/e2e/specs/status-page.spec.js b/test/e2e/specs/status-page.spec.js
index f525dfc6f..0bec78c79 100644
--- a/test/e2e/specs/status-page.spec.js
+++ b/test/e2e/specs/status-page.spec.js
@@ -18,6 +18,8 @@ test.describe("Status Page", () => {
const refreshInterval = 30;
const theme = "dark";
const googleAnalyticsId = "G-123";
+ const umamiAnalyticsDomainUrl = "example.com";
+ const umamiAnalyticsWebsiteId = "606487e2-bc25-45f9-9132-fa8b065aad46";
const customCss = "body { background: rgb(0, 128, 128) !important; }";
const descriptionText = "This is an example status page.";
const incidentTitle = "Example Outage Incident";
@@ -58,6 +60,8 @@ test.describe("Status Page", () => {
await page.getByTestId("show-powered-by-checkbox").uncheck();
await page.getByTestId("show-certificate-expiry-checkbox").uncheck();
await page.getByTestId("google-analytics-input").fill(googleAnalyticsId);
+ await page.getByTestId("umami-analytics-domain-url-input").fill(umamiAnalyticsDomainUrl);
+ await page.getByTestId("umami-analytics-website-id-input").fill(umamiAnalyticsWebsiteId);
await page.getByTestId("custom-css-input").getByTestId("textarea").fill(customCss); // Prism
await expect(page.getByTestId("description-editable")).toHaveText(descriptionText);
await expect(page.getByTestId("custom-footer-editable")).toHaveText(footerText);
@@ -101,6 +105,8 @@ test.describe("Status Page", () => {
await expect(page.locator("body")).toHaveClass(theme);
expect(await page.locator("head").innerHTML()).toContain(googleAnalyticsId);
+ expect(await page.locator("head").innerHTML()).toContain(umamiAnalyticsDomainUrl);
+ expect(await page.locator("head").innerHTML()).toContain(umamiAnalyticsWebsiteId);
const backgroundColor = await page.evaluate(() => window.getComputedStyle(document.body).backgroundColor);
expect(backgroundColor).toEqual("rgb(0, 128, 128)");
From e44ec55edae18fdfa3da09787dcd6f887dd05226 Mon Sep 17 00:00:00 2001
From: hadestructhor <60148800+hadestructhor@users.noreply.github.com>
Date: Tue, 18 Feb 2025 00:45:53 +0100
Subject: [PATCH 3/5] chore: cleanup and refactoring
---
db/knex_init_db.js | 7 +--
.../2025-02-17-2142-generalize-analytics.js | 23 +++++++++
.../patch-add-umami-analytics-status-page.sql | 10 ----
server/database.js | 1 -
server/model/status_page.js | 20 ++++----
.../status-page-socket-handler.js | 6 +--
src/lang/en.json | 3 ++
src/pages/StatusPage.vue | 47 +++++++++++++++----
test/e2e/specs/status-page.spec.js | 17 ++++---
9 files changed, 91 insertions(+), 43 deletions(-)
create mode 100644 db/knex_migrations/2025-02-17-2142-generalize-analytics.js
delete mode 100644 db/old_migrations/patch-add-umami-analytics-status-page.sql
diff --git a/db/knex_init_db.js b/db/knex_init_db.js
index 42b076c45..de9997dd4 100644
--- a/db/knex_init_db.js
+++ b/db/knex_init_db.js
@@ -202,9 +202,10 @@ async function createTables() {
table.text("footer_text");
table.text("custom_css");
table.boolean("show_powered_by").notNullable().defaultTo(true);
- table.string("google_analytics_tag_id");
- table.string("umami_analytics_domain_url");
- table.string("umami_analytics_website_id");
+ table.string("analytics_id");
+ table.string("analytics_domain_url");
+ table.enu("analytics_type", [ "google", "umami", "plausible" ]).defaultTo(null);
+
});
// maintenance_status_page
diff --git a/db/knex_migrations/2025-02-17-2142-generalize-analytics.js b/db/knex_migrations/2025-02-17-2142-generalize-analytics.js
new file mode 100644
index 000000000..34a52224f
--- /dev/null
+++ b/db/knex_migrations/2025-02-17-2142-generalize-analytics.js
@@ -0,0 +1,23 @@
+// Udpate status_page table to generalize analytics fields
+exports.up = function (knex) {
+ return knex.schema
+ .alterTable("status_page", function (table) {
+ table.renameColumn("google_analytics_tag_id", "analytics_id");
+ table.string("analytics_domain_url");
+ table.enu("analytics_type", [ "google", "umami", "plausible" ]).defaultTo(null);
+
+ }).then(() => {
+ // After a succesful migration, add google as default for previous pages
+ knex("status_page").whereNotNull("analytics_id").update({
+ "analytics_type": "google",
+ });
+ });
+};
+
+exports.down = function (knex) {
+ return knex.schema.alterTable("status_page", function (table) {
+ table.renameColumn("analytics_id", "google_analytics_tag_id");
+ table.dropColumn("analytics_domain_url");
+ table.dropColumn("analytics_type");
+ });
+};
diff --git a/db/old_migrations/patch-add-umami-analytics-status-page.sql b/db/old_migrations/patch-add-umami-analytics-status-page.sql
deleted file mode 100644
index e8b15f4d5..000000000
--- a/db/old_migrations/patch-add-umami-analytics-status-page.sql
+++ /dev/null
@@ -1,10 +0,0 @@
--- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
-BEGIN TRANSACTION;
-
-ALTER TABLE status_page
- ADD umami_analytics_domain_url VARCHAR;
-
-ALTER TABLE status_page
-ADD umami_analytics_website_id VARCHAR;
-
-COMMIT;
diff --git a/server/database.js b/server/database.js
index 6aa302745..0e6a7405d 100644
--- a/server/database.js
+++ b/server/database.js
@@ -95,7 +95,6 @@ class Database {
"patch-maintenance-table2.sql": true,
"patch-add-gamedig-monitor.sql": true,
"patch-add-google-analytics-status-page-tag.sql": true,
- "patch-add-umami-analytics-status-page.sql": true,
"patch-http-body-encoding.sql": true,
"patch-add-description-monitor.sql": true,
"patch-api-key-table.sql": true,
diff --git a/server/model/status_page.js b/server/model/status_page.js
index 6548effc6..b3cf434cb 100644
--- a/server/model/status_page.js
+++ b/server/model/status_page.js
@@ -121,13 +121,13 @@ class StatusPage extends BeanModel {
const head = $("head");
- if (statusPage.googleAnalyticsTagId) {
- let escapedGoogleAnalyticsScript = googleAnalytics.getGoogleAnalyticsScript(statusPage.googleAnalyticsTagId);
+ if (statusPage.analyticsType === "google" && statusPage.analyticsId) {
+ let escapedGoogleAnalyticsScript = googleAnalytics.getGoogleAnalyticsScript(statusPage.analyticsId);
head.append($(escapedGoogleAnalyticsScript));
}
- if (statusPage.umamiAnalyticsDomainUrl && statusPage.umamiAnalyticsWebsiteId) {
- let escapedUmamiAnalyticsScript = umamiAnalytics.getUmamiAnalyticsScript(statusPage.umamiAnalyticsDomainUrl, statusPage.umamiAnalyticsWebsiteId);
+ if (statusPage.analyticsType === "umami" && statusPage.analyticsDomainUrl && statusPage.analyticsId) {
+ let escapedUmamiAnalyticsScript = umamiAnalytics.getUmamiAnalyticsScript(statusPage.analyticsDomainUrl, statusPage.analyticsId);
head.append($(escapedUmamiAnalyticsScript));
}
@@ -413,9 +413,9 @@ class StatusPage extends BeanModel {
customCSS: this.custom_css,
footerText: this.footer_text,
showPoweredBy: !!this.show_powered_by,
- googleAnalyticsId: this.google_analytics_tag_id,
- umamiAnalyticsDomainUrl: this.umami_analytics_domain_url,
- umamiAnalyticsWebsiteId: this.umami_analytics_website_id,
+ analyticsId: this.analytics_id,
+ analyticsDomainUrl: this.analytics_domain_url,
+ analyticsType: this.analytics_type,
showCertificateExpiry: !!this.show_certificate_expiry,
};
}
@@ -438,9 +438,9 @@ class StatusPage extends BeanModel {
customCSS: this.custom_css,
footerText: this.footer_text,
showPoweredBy: !!this.show_powered_by,
- googleAnalyticsId: this.google_analytics_tag_id,
- umamiAnalyticsDomainUrl: this.umami_analytics_domain_url,
- umamiAnalyticsWebsiteId: this.umami_analytics_website_id,
+ analyticsId: this.analytics_id,
+ analyticsDomainUrl: this.analytics_domain_url,
+ analyticsType: this.analytics_type,
showCertificateExpiry: !!this.show_certificate_expiry,
};
}
diff --git a/server/socket-handlers/status-page-socket-handler.js b/server/socket-handlers/status-page-socket-handler.js
index c6a474cac..60957d49d 100644
--- a/server/socket-handlers/status-page-socket-handler.js
+++ b/server/socket-handlers/status-page-socket-handler.js
@@ -166,9 +166,9 @@ module.exports.statusPageSocketHandler = (socket) => {
statusPage.show_powered_by = config.showPoweredBy;
statusPage.show_certificate_expiry = config.showCertificateExpiry;
statusPage.modified_date = R.isoDateTime();
- statusPage.google_analytics_tag_id = config.googleAnalyticsId;
- statusPage.umami_analytics_domain_url = config.umamiAnalyticsDomainUrl;
- statusPage.umami_analytics_website_id = config.umamiAnalyticsWebsiteId;
+ statusPage.analytics_id = config.analyticsId;
+ statusPage.analytics_domain_url = config.analyticsDomainUrl;
+ statusPage.analytics_type = config.analyticsType;
await R.store(statusPage);
diff --git a/src/lang/en.json b/src/lang/en.json
index c06547fc5..f21c7e6f1 100644
--- a/src/lang/en.json
+++ b/src/lang/en.json
@@ -782,6 +782,9 @@
"wayToGetClickSendSMSToken": "You can get API Username and API Key from {0} .",
"Custom Monitor Type": "Custom Monitor Type",
"Google Analytics ID": "Google Analytics ID",
+ "Analytics Type": "Analytics Type",
+ "Analytics ID": "Analytics ID",
+ "Analytics Domain URL": "Analytics Domain URL",
"Umami Analytics Domain Url": "Umami Analytics Domain Url",
"Umami Analytics Website ID": "Umami Analytics Website ID",
"Edit Tag": "Edit Tag",
diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue
index 8edb8f4e1..983336de3 100644
--- a/src/pages/StatusPage.vue
+++ b/src/pages/StatusPage.vue
@@ -1,3 +1,25 @@
+
+
@@ -92,20 +114,25 @@
-
+
+
-
-
+
+
-
-
-
-
+
+
+
-
-
-
+
+
+
+
diff --git a/test/e2e/specs/status-page.spec.js b/test/e2e/specs/status-page.spec.js
index 0bec78c79..9e5de4a43 100644
--- a/test/e2e/specs/status-page.spec.js
+++ b/test/e2e/specs/status-page.spec.js
@@ -59,9 +59,8 @@ test.describe("Status Page", () => {
await page.getByTestId("show-tags-checkbox").uncheck();
await page.getByTestId("show-powered-by-checkbox").uncheck();
await page.getByTestId("show-certificate-expiry-checkbox").uncheck();
- await page.getByTestId("google-analytics-input").fill(googleAnalyticsId);
- await page.getByTestId("umami-analytics-domain-url-input").fill(umamiAnalyticsDomainUrl);
- await page.getByTestId("umami-analytics-website-id-input").fill(umamiAnalyticsWebsiteId);
+ await page.getByTestId("analytics-type-select").selectOption("google");
+ await page.getByTestId("analytics-id-input").fill(googleAnalyticsId);
await page.getByTestId("custom-css-input").getByTestId("textarea").fill(customCss); // Prism
await expect(page.getByTestId("description-editable")).toHaveText(descriptionText);
await expect(page.getByTestId("custom-footer-editable")).toHaveText(footerText);
@@ -104,14 +103,12 @@ test.describe("Status Page", () => {
expect(updateCountdown).toBeLessThanOrEqual(refreshInterval + 10);
await expect(page.locator("body")).toHaveClass(theme);
- expect(await page.locator("head").innerHTML()).toContain(googleAnalyticsId);
- expect(await page.locator("head").innerHTML()).toContain(umamiAnalyticsDomainUrl);
- expect(await page.locator("head").innerHTML()).toContain(umamiAnalyticsWebsiteId);
const backgroundColor = await page.evaluate(() => window.getComputedStyle(document.body).backgroundColor);
expect(backgroundColor).toEqual("rgb(0, 128, 128)");
await screenshot(testInfo, page);
+ expect(await page.locator("head").innerHTML()).toContain(googleAnalyticsId);
// Flip the "Show Tags" and "Show Powered By" switches:
await page.getByTestId("edit-button").click();
@@ -120,6 +117,11 @@ test.describe("Status Page", () => {
await page.getByTestId("show-powered-by-checkbox").setChecked(true);
await screenshot(testInfo, page);
+
+ // Fill in umami analytics after editing
+ await page.getByTestId("analytics-type-select").selectOption("umami");
+ await page.getByTestId("analytics-domain-url-input").fill(umamiAnalyticsDomainUrl);
+ await page.getByTestId("analytics-id-input").fill(umamiAnalyticsWebsiteId);
await page.getByTestId("save-button").click();
await expect(page.getByTestId("edit-sidebar")).toHaveCount(0);
@@ -127,6 +129,9 @@ test.describe("Status Page", () => {
await expect(page.getByTestId("monitor-tag")).toContainText(tagValue);
await screenshot(testInfo, page);
+
+ expect(await page.locator("head").innerHTML()).toContain(umamiAnalyticsDomainUrl);
+ expect(await page.locator("head").innerHTML()).toContain(umamiAnalyticsWebsiteId);
});
// @todo Test certificate expiry
From 6bade1fe812ef642589145d84ba96ab4955c38e4 Mon Sep 17 00:00:00 2001
From: hadestructhor <60148800+hadestructhor@users.noreply.github.com>
Date: Wed, 19 Feb 2025 00:08:30 +0100
Subject: [PATCH 4/5] feat: add support for plausible, cleanup and refactor
code
---
server/analytics/analytics.js | 44 ++++++++++++++++++++++
server/{ => analytics}/google-analytics.js | 0
server/analytics/plausible-analytics.js | 36 ++++++++++++++++++
server/{ => analytics}/umami-analytics.js | 0
server/model/status_page.js | 14 ++-----
test/e2e/specs/status-page.spec.js | 12 ++++++
6 files changed, 96 insertions(+), 10 deletions(-)
create mode 100644 server/analytics/analytics.js
rename server/{ => analytics}/google-analytics.js (100%)
create mode 100644 server/analytics/plausible-analytics.js
rename server/{ => analytics}/umami-analytics.js (100%)
diff --git a/server/analytics/analytics.js b/server/analytics/analytics.js
new file mode 100644
index 000000000..a5e2c1120
--- /dev/null
+++ b/server/analytics/analytics.js
@@ -0,0 +1,44 @@
+const googleAnalytics = require("./google-analytics");
+const umamiAnalytics = require("./umami-analytics");
+const plausibleAnalytics = require("./plausible-analytics");
+
+/**
+ * Returns a string that represents the javascript that is required to insert the selected Analytics' script
+ * into a webpage.
+ * @param {typeof import("../model/status_page").StatusPage} statusPage Status page populate HTML with
+ * @returns {string} HTML script tags to inject into page
+ */
+function getAnalyticsScript(statusPage) {
+ switch (statusPage.analyticsType) {
+ case "google":
+ return googleAnalytics.getGoogleAnalyticsScript(statusPage.analyticsId);
+ case "umami":
+ return umamiAnalytics.getUmamiAnalyticsScript(statusPage.analyticsDomainUrl, statusPage.analyticsId);
+ case "plausible":
+ return plausibleAnalytics.getPlausibleAnalyticsScript(statusPage.analyticsDomainUrl, statusPage.analyticsId);
+ default:
+ return null;
+ }
+}
+
+/**
+ * Function that checks wether the selected analytics has been configured properly
+ * @param {typeof import("../model/status_page").StatusPage} statusPage Status page populate HTML with
+ * @returns {boolean} Boolean defining if the analytics config is valid
+ */
+function isValidAnalyticsConfig(statusPage) {
+ switch (statusPage.analyticsType) {
+ case "google":
+ return statusPage.analyticsId != null;
+ case "umami":
+ case "plausible":
+ return statusPage.analyticsId != null && statusPage.analyticsDomainUrl != null;
+ default:
+ return false;
+ }
+}
+
+module.exports = {
+ getAnalyticsScript,
+ isValidAnalyticsConfig
+};
diff --git a/server/google-analytics.js b/server/analytics/google-analytics.js
similarity index 100%
rename from server/google-analytics.js
rename to server/analytics/google-analytics.js
diff --git a/server/analytics/plausible-analytics.js b/server/analytics/plausible-analytics.js
new file mode 100644
index 000000000..14df98115
--- /dev/null
+++ b/server/analytics/plausible-analytics.js
@@ -0,0 +1,36 @@
+const jsesc = require("jsesc");
+const { escape } = require("html-escaper");
+
+/**
+ * Returns a string that represents the javascript that is required to insert the Plausible Analytics script
+ * into a webpage.
+ * @param {string} plausibleDomainUrl Domain name with tld to use with the Plausible Analytics script.
+ * @param {string} domainsToMonitor Domains to track seperated by a ',' to add Plausible Analytics script.
+ * @returns {string} HTML script tags to inject into page
+ */
+function getPlausibleAnalyticsScript(plausibleDomainUrl, domainsToMonitor) {
+ let escapedDomainUrlJS = jsesc(plausibleDomainUrl, { isScriptContext: true });
+ let escapedWebsiteIdJS = jsesc(domainsToMonitor, { isScriptContext: true });
+
+ if (escapedDomainUrlJS) {
+ escapedDomainUrlJS = escapedDomainUrlJS.trim();
+ }
+
+ if (escapedWebsiteIdJS) {
+ escapedWebsiteIdJS = escapedWebsiteIdJS.trim();
+ }
+
+ // Escape the domain url for use in an HTML attribute.
+ let escapedDomainUrlHTMLAttribute = escape(escapedDomainUrlJS);
+
+ // Escape the website id for use in an HTML attribute.
+ let escapedWebsiteIdHTMLAttribute = escape(escapedWebsiteIdJS);
+
+ return `
+
+ `;
+}
+
+module.exports = {
+ getPlausibleAnalyticsScript
+};
diff --git a/server/umami-analytics.js b/server/analytics/umami-analytics.js
similarity index 100%
rename from server/umami-analytics.js
rename to server/analytics/umami-analytics.js
diff --git a/server/model/status_page.js b/server/model/status_page.js
index b3cf434cb..32188ffaa 100644
--- a/server/model/status_page.js
+++ b/server/model/status_page.js
@@ -3,8 +3,7 @@ const { R } = require("redbean-node");
const cheerio = require("cheerio");
const { UptimeKumaServer } = require("../uptime-kuma-server");
const jsesc = require("jsesc");
-const googleAnalytics = require("../google-analytics");
-const umamiAnalytics = require("../umami-analytics");
+const analytics = require("../analytics/analytics");
const { marked } = require("marked");
const { Feed } = require("feed");
const config = require("../config");
@@ -121,14 +120,9 @@ class StatusPage extends BeanModel {
const head = $("head");
- if (statusPage.analyticsType === "google" && statusPage.analyticsId) {
- let escapedGoogleAnalyticsScript = googleAnalytics.getGoogleAnalyticsScript(statusPage.analyticsId);
- head.append($(escapedGoogleAnalyticsScript));
- }
-
- if (statusPage.analyticsType === "umami" && statusPage.analyticsDomainUrl && statusPage.analyticsId) {
- let escapedUmamiAnalyticsScript = umamiAnalytics.getUmamiAnalyticsScript(statusPage.analyticsDomainUrl, statusPage.analyticsId);
- head.append($(escapedUmamiAnalyticsScript));
+ if (analytics.isValidAnalyticsConfig(statusPage)) {
+ let escapedAnalyticsScript = analytics.getAnalyticsScript(statusPage);
+ head.append($(escapedAnalyticsScript));
}
// OG Meta Tags
diff --git a/test/e2e/specs/status-page.spec.js b/test/e2e/specs/status-page.spec.js
index 9e5de4a43..43c5d43a6 100644
--- a/test/e2e/specs/status-page.spec.js
+++ b/test/e2e/specs/status-page.spec.js
@@ -20,6 +20,8 @@ test.describe("Status Page", () => {
const googleAnalyticsId = "G-123";
const umamiAnalyticsDomainUrl = "example.com";
const umamiAnalyticsWebsiteId = "606487e2-bc25-45f9-9132-fa8b065aad46";
+ const plausibleAnalyticsDomainUrl = "example.com";
+ const plausibleAnalyticsDomainsUrls = "one.com,two.com";
const customCss = "body { background: rgb(0, 128, 128) !important; }";
const descriptionText = "This is an example status page.";
const incidentTitle = "Example Outage Incident";
@@ -132,6 +134,16 @@ test.describe("Status Page", () => {
expect(await page.locator("head").innerHTML()).toContain(umamiAnalyticsDomainUrl);
expect(await page.locator("head").innerHTML()).toContain(umamiAnalyticsWebsiteId);
+
+ await page.getByTestId("edit-button").click();
+ // Fill in plausible analytics after editing
+ await page.getByTestId("analytics-type-select").selectOption("plausible");
+ await page.getByTestId("analytics-domain-url-input").fill(plausibleAnalyticsDomainUrl);
+ await page.getByTestId("analytics-id-input").fill(plausibleAnalyticsDomainsUrls);
+ await page.getByTestId("save-button").click();
+ await screenshot(testInfo, page);
+ expect(await page.locator("head").innerHTML()).toContain(plausibleAnalyticsDomainUrl);
+ expect(await page.locator("head").innerHTML()).toContain(plausibleAnalyticsDomainsUrls);
});
// @todo Test certificate expiry
From a1e63390ab974c48a2cf6592a60c22a773248868 Mon Sep 17 00:00:00 2001
From: hadestructhor <60148800+hadestructhor@users.noreply.github.com>
Date: Fri, 21 Feb 2025 23:26:44 +0100
Subject: [PATCH 5/5] feat: add Matomo analytics support
---
.../2025-02-17-2142-generalize-analytics.js | 2 +-
server/analytics/analytics.js | 4 ++
server/analytics/matomo-analytics.js | 47 +++++++++++++++++++
src/pages/StatusPage.vue | 4 ++
test/e2e/specs/status-page.spec.js | 12 +++++
5 files changed, 68 insertions(+), 1 deletion(-)
create mode 100644 server/analytics/matomo-analytics.js
diff --git a/db/knex_migrations/2025-02-17-2142-generalize-analytics.js b/db/knex_migrations/2025-02-17-2142-generalize-analytics.js
index 34a52224f..e4924feaf 100644
--- a/db/knex_migrations/2025-02-17-2142-generalize-analytics.js
+++ b/db/knex_migrations/2025-02-17-2142-generalize-analytics.js
@@ -4,7 +4,7 @@ exports.up = function (knex) {
.alterTable("status_page", function (table) {
table.renameColumn("google_analytics_tag_id", "analytics_id");
table.string("analytics_domain_url");
- table.enu("analytics_type", [ "google", "umami", "plausible" ]).defaultTo(null);
+ table.enu("analytics_type", [ "google", "umami", "plausible", "matomo" ]).defaultTo(null);
}).then(() => {
// After a succesful migration, add google as default for previous pages
diff --git a/server/analytics/analytics.js b/server/analytics/analytics.js
index a5e2c1120..79e570b9b 100644
--- a/server/analytics/analytics.js
+++ b/server/analytics/analytics.js
@@ -1,6 +1,7 @@
const googleAnalytics = require("./google-analytics");
const umamiAnalytics = require("./umami-analytics");
const plausibleAnalytics = require("./plausible-analytics");
+const matomoAnalytics = require("./matomo-analytics");
/**
* Returns a string that represents the javascript that is required to insert the selected Analytics' script
@@ -16,6 +17,8 @@ function getAnalyticsScript(statusPage) {
return umamiAnalytics.getUmamiAnalyticsScript(statusPage.analyticsDomainUrl, statusPage.analyticsId);
case "plausible":
return plausibleAnalytics.getPlausibleAnalyticsScript(statusPage.analyticsDomainUrl, statusPage.analyticsId);
+ case "matomo":
+ return matomoAnalytics.getMatomoAnalyticsScript(statusPage.analyticsDomainUrl, statusPage.analyticsId);
default:
return null;
}
@@ -32,6 +35,7 @@ function isValidAnalyticsConfig(statusPage) {
return statusPage.analyticsId != null;
case "umami":
case "plausible":
+ case "matomo":
return statusPage.analyticsId != null && statusPage.analyticsDomainUrl != null;
default:
return false;
diff --git a/server/analytics/matomo-analytics.js b/server/analytics/matomo-analytics.js
new file mode 100644
index 000000000..fdc009e63
--- /dev/null
+++ b/server/analytics/matomo-analytics.js
@@ -0,0 +1,47 @@
+const jsesc = require("jsesc");
+const { escape } = require("html-escaper");
+
+/**
+ * Returns a string that represents the javascript that is required to insert the Matomo Analytics script
+ * into a webpage.
+ * @param {string} matomoUrl Domain name with tld to use with the Matomo Analytics script.
+ * @param {string} siteId Site ID to use with the Matomo Analytics script.
+ * @returns {string} HTML script tags to inject into page
+ */
+function getMatomoAnalyticsScript(matomoUrl, siteId) {
+ let escapedMatomoUrlJS = jsesc(matomoUrl, { isScriptContext: true });
+ let escapedSiteIdJS = jsesc(siteId, { isScriptContext: true });
+
+ if (escapedMatomoUrlJS) {
+ escapedMatomoUrlJS = escapedMatomoUrlJS.trim();
+ }
+
+ if (escapedSiteIdJS) {
+ escapedSiteIdJS = escapedSiteIdJS.trim();
+ }
+
+ // Escape the domain url for use in an HTML attribute.
+ let escapedMatomoUrlHTMLAttribute = escape(escapedMatomoUrlJS);
+
+ // Escape the website id for use in an HTML attribute.
+ let escapedSiteIdHTMLAttribute = escape(escapedSiteIdJS);
+
+ return `
+
+ `;
+}
+
+module.exports = {
+ getMatomoAnalyticsScript,
+};
diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue
index 983336de3..8a35fef60 100644
--- a/src/pages/StatusPage.vue
+++ b/src/pages/StatusPage.vue
@@ -16,6 +16,10 @@ const analyticsOptions = [
{
name: "Plausible",
value: "plausible"
+ },
+ {
+ name: "Matomo",
+ value: "matomo"
}
];
diff --git a/test/e2e/specs/status-page.spec.js b/test/e2e/specs/status-page.spec.js
index 43c5d43a6..e75f3c993 100644
--- a/test/e2e/specs/status-page.spec.js
+++ b/test/e2e/specs/status-page.spec.js
@@ -22,6 +22,8 @@ test.describe("Status Page", () => {
const umamiAnalyticsWebsiteId = "606487e2-bc25-45f9-9132-fa8b065aad46";
const plausibleAnalyticsDomainUrl = "example.com";
const plausibleAnalyticsDomainsUrls = "one.com,two.com";
+ const matomoUrl = "matomo.com";
+ const matomoSiteId = "123456789";
const customCss = "body { background: rgb(0, 128, 128) !important; }";
const descriptionText = "This is an example status page.";
const incidentTitle = "Example Outage Incident";
@@ -144,6 +146,16 @@ test.describe("Status Page", () => {
await screenshot(testInfo, page);
expect(await page.locator("head").innerHTML()).toContain(plausibleAnalyticsDomainUrl);
expect(await page.locator("head").innerHTML()).toContain(plausibleAnalyticsDomainsUrls);
+
+ await page.getByTestId("edit-button").click();
+ // Fill in matomo analytics after editing
+ await page.getByTestId("analytics-type-select").selectOption("matomo");
+ await page.getByTestId("analytics-domain-url-input").fill(matomoUrl);
+ await page.getByTestId("analytics-id-input").fill(matomoSiteId);
+ await page.getByTestId("save-button").click();
+ await screenshot(testInfo, page);
+ expect(await page.locator("head").innerHTML()).toContain(matomoUrl);
+ expect(await page.locator("head").innerHTML()).toContain(matomoSiteId);
});
// @todo Test certificate expiry