diff --git a/server/model/status_page.js b/server/model/status_page.js
index 9952d56a8..fe986fee3 100644
--- a/server/model/status_page.js
+++ b/server/model/status_page.js
@@ -8,6 +8,7 @@ const { marked } = require("marked");
const { Feed } = require("feed");
const config = require("../config");
const { setting } = require("../util-server");
+const { generateOGImage, getStatusColor } = require("../utils/og-image");
const {
STATUS_PAGE_ALL_DOWN,
@@ -106,6 +107,22 @@ class StatusPage extends BeanModel {
return feed.rss2();
}
+ /**
+ * Helper function to create and append meta tags
+ * @param {object} $ Cheerio instance
+ * @param {object} head The head element
+ * @param {string} property The property name (or name for non-OG tags)
+ * @param {string} content The content value
+ * @param {boolean} isOG Whether this is an Open Graph tag (default: true)
+ * @returns {void}
+ * @private
+ */
+ static _appendMetaTag($, head, property, content, isOG = true) {
+ const attr = isOG ? "property" : "name";
+ const meta = $(``).attr(attr, property).attr("content", content);
+ head.append(meta);
+ }
+
/**
* Build RSS feed URL, handling proxy headers
* @param {string} slug Status page slug
@@ -169,11 +186,30 @@ class StatusPage extends BeanModel {
}
// OG Meta Tags
- let ogTitle = $('').attr("content", statusPage.title);
- head.append(ogTitle);
+ StatusPage._appendMetaTag($, head, "og:title", statusPage.title);
+ StatusPage._appendMetaTag($, head, "og:description", description155);
+ StatusPage._appendMetaTag($, head, "og:type", "website");
- let ogDescription = $('').attr("content", description155);
- head.append(ogDescription);
+ // Add og:url if primaryBaseURL is configured
+ const primaryBaseURL = await setting("primaryBaseURL");
+ if (primaryBaseURL) {
+ StatusPage._appendMetaTag($, head, "og:url", `${primaryBaseURL}/status/${statusPage.slug}`);
+ }
+
+ // og:image needs an absolute URL to work
+ if (primaryBaseURL) {
+ const imageUrl = `${primaryBaseURL}/api/status-page/${statusPage.slug}/image`;
+ StatusPage._appendMetaTag($, head, "og:image", imageUrl);
+ StatusPage._appendMetaTag($, head, "og:image:width", "1200");
+ StatusPage._appendMetaTag($, head, "og:image:height", "630");
+ StatusPage._appendMetaTag($, head, "og:image:alt", `${statusPage.title} - Status Page`);
+ StatusPage._appendMetaTag($, head, "twitter:image", imageUrl, false);
+ }
+
+ // Twitter Card Meta Tags
+ StatusPage._appendMetaTag($, head, "twitter:card", "summary_large_image", false);
+ StatusPage._appendMetaTag($, head, "twitter:title", statusPage.title, false);
+ StatusPage._appendMetaTag($, head, "twitter:description", description155, false);
let ogType = $('');
head.append(ogType);
@@ -256,6 +292,61 @@ class StatusPage extends BeanModel {
return "?";
}
+ /**
+ * Generate an Open Graph image for the status page
+ * @param {StatusPage} statusPage Status page object
+ * @returns {Promise} PNG image buffer
+ */
+ static async generateOGImage(statusPage) {
+ // Get status data
+ const { heartbeats, statusDescription } = await StatusPage.getRSSPageData(statusPage);
+ const status = StatusPage.overallStatus(heartbeats);
+ const statusColor = getStatusColor(status);
+
+ // Collect monitor information for display (top monitors with their status)
+ const monitors = [];
+
+ // Try to get full status page data if available (for real instances)
+ // This will gracefully fail for mock objects in tests
+ try {
+ if (statusPage.toPublicJSON && statusPage.id) {
+ const statusPageData = await StatusPage.getStatusPageData(statusPage);
+ if (statusPageData.publicGroupList) {
+ for (const group of statusPageData.publicGroupList) {
+ if (group.monitorList) {
+ for (const monitor of group.monitorList) {
+ monitors.push({
+ name: monitor.name,
+ status: monitor.status || 2, // 1=up, 0=down, 2=pending
+ });
+ }
+ }
+ }
+ }
+ }
+ } catch (error) {
+ // If getting monitor details fails, continue without them
+ // This allows the function to work with mock objects
+ }
+
+ // Get icon - use getIcon() method if available, otherwise use icon property
+ const icon =
+ statusPage.getIcon && typeof statusPage.getIcon === "function" ? statusPage.getIcon() : statusPage.icon;
+
+ // If no detailed monitor data, create array with count for display
+ const monitorsForDisplay = monitors.length > 0 ? monitors : heartbeats.map(() => ({}));
+
+ // Generate OG image using utility function
+ return await generateOGImage({
+ title: statusPage.title,
+ statusDescription,
+ statusColor,
+ icon,
+ showPoweredBy: !!statusPage.show_powered_by,
+ monitors: monitorsForDisplay,
+ });
+ }
+
/**
* Get all data required for RSS
* @param {StatusPage} statusPage Status page to get data for
diff --git a/server/routers/status-page-router.js b/server/routers/status-page-router.js
index 75e8fdea8..792e5abd0 100644
--- a/server/routers/status-page-router.js
+++ b/server/routers/status-page-router.js
@@ -237,4 +237,31 @@ router.get("/api/status-page/:slug/badge", cache("5 minutes"), async (request, r
}
});
+// Status page Open Graph image
+router.get("/api/status-page/:slug/image", cache("5 minutes"), async (request, response) => {
+ allowDevAllOrigin(response);
+ let slug = request.params.slug;
+ slug = slug.toLowerCase();
+
+ try {
+ // Get Status Page
+ let statusPage = await R.findOne("status_page", " slug = ? ", [slug]);
+
+ if (!statusPage) {
+ sendHttpError(response, "Status Page Not Found");
+ return;
+ }
+
+ // Generate PNG image
+ const pngBuffer = await StatusPage.generateOGImage(statusPage);
+
+ // Set appropriate headers
+ response.type("image/png");
+ response.setHeader("Cache-Control", "public, max-age=300"); // 5 minutes
+ response.send(pngBuffer);
+ } catch (error) {
+ sendHttpError(response, error.message);
+ }
+});
+
module.exports = router;
diff --git a/server/utils/og-image.js b/server/utils/og-image.js
new file mode 100644
index 000000000..35654fb84
--- /dev/null
+++ b/server/utils/og-image.js
@@ -0,0 +1,320 @@
+const sharp = require("sharp");
+const fs = require("fs");
+const path = require("path");
+
+const {
+ STATUS_PAGE_ALL_DOWN,
+ STATUS_PAGE_ALL_UP,
+ STATUS_PAGE_PARTIAL_DOWN,
+ MAINTENANCE,
+ UP,
+ DOWN,
+ PENDING,
+} = require("../util-server");
+
+// Image dimensions (Open Graph standard)
+const OG_IMAGE_WIDTH = 1200;
+const OG_IMAGE_HEIGHT = 630;
+
+// Display limits
+const MAX_TITLE_LENGTH = 40;
+const MAX_MONITOR_NAME_LENGTH = 40;
+const MAX_INDIVIDUAL_MONITORS = 3;
+
+/**
+ * Get status color based on status code
+ * @param {number} status Status code
+ * @returns {string} Hex color code
+ */
+function getStatusColor(status) {
+ switch (status) {
+ case STATUS_PAGE_ALL_UP:
+ return "#10b981"; // green
+ case STATUS_PAGE_PARTIAL_DOWN:
+ return "#f59e0b"; // amber
+ case STATUS_PAGE_ALL_DOWN:
+ return "#ef4444"; // red
+ case MAINTENANCE:
+ return "#3b82f6"; // blue
+ default:
+ return "#6b7280"; // grey
+ }
+}
+
+/**
+ * Get monitor status color
+ * @param {number} status Monitor status code
+ * @returns {string} Hex color code
+ */
+function getMonitorStatusColor(status) {
+ switch (status) {
+ case UP:
+ return "#10b981"; // green
+ case DOWN:
+ return "#ef4444"; // red
+ case MAINTENANCE:
+ return "#3b82f6"; // blue
+ case PENDING:
+ return "#f59e0b"; // amber
+ default:
+ return "#6b7280"; // grey
+ }
+}
+
+/**
+ * Escape XML special characters
+ * @param {string} text Text to escape
+ * @returns {string} Escaped text
+ */
+function escapeXml(text) {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+/**
+ * Truncate text with ellipsis if too long
+ * @param {string} text Text to truncate
+ * @param {number} maxLength Maximum length
+ * @returns {string} Truncated text
+ */
+function truncateText(text, maxLength) {
+ if (text.length <= maxLength) {
+ return text;
+ }
+ return text.substring(0, maxLength - 3) + "...";
+}
+
+/**
+ * Count monitors by status
+ * @param {Array} monitors Array of monitor objects
+ * @returns {object} Status counts
+ */
+function countMonitorsByStatus(monitors) {
+ const counts = { up: 0, down: 0, pending: 0, maintenance: 0 };
+
+ monitors.forEach((monitor) => {
+ if (monitor.status === UP) {
+ counts.up++;
+ } else if (monitor.status === DOWN) {
+ counts.down++;
+ } else if (monitor.status === PENDING) {
+ counts.pending++;
+ } else if (monitor.status === MAINTENANCE) {
+ counts.maintenance++;
+ }
+ });
+
+ return counts;
+}
+
+/**
+ * Generate SVG for monitor status summary (when more than 3 monitors)
+ * @param {object} statusCounts Status counts object
+ * @param {number} startY Starting Y coordinate
+ * @returns {string} SVG markup
+ */
+function generateMonitorStatusSummary(statusCounts, startY) {
+ let y = startY;
+ let svg = "";
+
+ if (statusCounts.up > 0) {
+ svg += `
+
+ ${statusCounts.up} Up`;
+ y += 35;
+ }
+
+ if (statusCounts.down > 0) {
+ svg += `
+
+ ${statusCounts.down} Down`;
+ y += 35;
+ }
+
+ if (statusCounts.maintenance > 0) {
+ svg += `
+
+ ${statusCounts.maintenance} Maintenance`;
+ y += 35;
+ }
+
+ if (statusCounts.pending > 0) {
+ svg += `
+
+ ${statusCounts.pending} Pending`;
+ }
+
+ return svg;
+}
+
+/**
+ * Generate SVG for individual monitor details (3 or fewer monitors)
+ * @param {Array} monitors Array of monitor objects
+ * @param {number} startY Starting Y coordinate
+ * @returns {string} SVG markup
+ */
+function generateIndividualMonitorDetails(monitors, startY) {
+ let svg = "";
+ const displayMonitors = monitors.slice(0, MAX_INDIVIDUAL_MONITORS);
+
+ displayMonitors.forEach((monitor, index) => {
+ const y = startY + index * 35;
+ const statusColor = getMonitorStatusColor(monitor.status);
+ const monitorName = truncateText(monitor.name, MAX_MONITOR_NAME_LENGTH);
+
+ svg += `
+
+ ${escapeXml(monitorName)}`;
+ });
+
+ return svg;
+}
+
+/**
+ * Generate monitor details section based on monitor data
+ * @param {Array} monitors Array of monitor objects
+ * @returns {string} SVG markup
+ */
+function generateMonitorDetailsSection(monitors) {
+ const startY = 420;
+
+ // No monitors or monitors without names - show count
+ if (monitors.length === 0 || !monitors[0].name) {
+ const plural = monitors.length !== 1 ? "s" : "";
+ return `
+ ${monitors.length} Monitor${plural} tracked`;
+ }
+
+ // Show summary for more than 3 monitors
+ if (monitors.length > MAX_INDIVIDUAL_MONITORS) {
+ const statusCounts = countMonitorsByStatus(monitors);
+ return generateMonitorStatusSummary(statusCounts, startY);
+ }
+
+ // Show individual monitors for 3 or fewer
+ return generateIndividualMonitorDetails(monitors, startY);
+}
+
+/**
+ * Load and embed icon SVG
+ * @param {string|null} icon Icon URL or path
+ * @returns {string} SVG markup for icon
+ */
+function generateIconSection(icon) {
+ if (!icon) {
+ return "";
+ }
+
+ try {
+ let filePath = "";
+
+ if (icon.startsWith("/upload/")) {
+ filePath = path.join(__dirname, "../../data", icon);
+ } else {
+ filePath = path.join(__dirname, "../../public/icon.svg");
+ }
+
+ if (!fs.existsSync(filePath) || !icon.endsWith(".svg")) {
+ return "";
+ }
+
+ const iconContent = fs.readFileSync(filePath, "utf8");
+ const svgMatch = iconContent.match(/"), "Expected closing svg tag");
+ });
+
+ test("generateOGImageSVG() generates matching snapshots for all test scenarios", () => {
+ TEST_SCENARIOS.forEach((scenario) => {
+ const statusDescription = StatusPage.getStatusDescription(scenario.statusCode);
+ const statusColor = getStatusColor(scenario.statusCode);
+ const icon = scenario.icon || null;
+ const showPoweredBy = scenario.showPoweredBy !== undefined ? scenario.showPoweredBy : true;
+
+ // For scenarios with many monitors (>3), show status count summary
+ // Otherwise show individual monitors or total count
+ let monitors;
+ if (scenario.monitorCount > 3) {
+ // Create realistic monitor mix for status summary
+ monitors = [];
+ const upCount = Math.floor(scenario.monitorCount * 0.85); // 85% up
+ const downCount = Math.floor(scenario.monitorCount * 0.1); // 10% down
+ const maintenanceCount = Math.floor(scenario.monitorCount * 0.03); // 3% maintenance
+ const pendingCount = scenario.monitorCount - upCount - downCount - maintenanceCount; // Rest pending
+
+ for (let i = 0; i < upCount; i++) {
+ monitors.push({ name: `Monitor ${i + 1}`, status: 1 });
+ }
+ for (let i = 0; i < downCount; i++) {
+ monitors.push({ name: `Monitor ${upCount + i + 1}`, status: 0 });
+ }
+ for (let i = 0; i < maintenanceCount; i++) {
+ monitors.push({ name: `Monitor ${upCount + downCount + i + 1}`, status: 3 });
+ }
+ for (let i = 0; i < pendingCount; i++) {
+ monitors.push({ name: `Monitor ${upCount + downCount + maintenanceCount + i + 1}`, status: 2 });
+ }
+ } else {
+ monitors = Array(scenario.monitorCount).fill({}); // Show count
+ }
+
+ const svg = generateOGImageSVG(
+ scenario.title,
+ statusDescription,
+ statusColor,
+ icon,
+ showPoweredBy,
+ FIXED_TIMESTAMP,
+ monitors
+ );
+ assertMatchesSnapshot(svg, `${scenario.name}.svg`);
+ });
+ });
+});
+
+describe("StatusPage Model", () => {
+ describe("overallStatus()", () => {
+ test("overallStatus() returns -1 when heartbeats array is empty", () => {
+ const status = StatusPage.overallStatus([]);
+ assert.strictEqual(status, -1);
+ });
+
+ test("overallStatus() returns ALL_UP when all monitors are up", () => {
+ const status = StatusPage.overallStatus([{ status: 1 }, { status: 1 }]);
+ assert.strictEqual(status, STATUS_PAGE_ALL_UP);
+ });
+
+ test("overallStatus() returns PARTIAL_DOWN when monitors have mixed statuses", () => {
+ const status = StatusPage.overallStatus([{ status: 1 }, { status: 0 }]);
+ assert.strictEqual(status, STATUS_PAGE_PARTIAL_DOWN);
+ });
+
+ test("overallStatus() returns ALL_DOWN when all monitors are down", () => {
+ const status = StatusPage.overallStatus([{ status: 0 }, { status: 0 }]);
+ assert.strictEqual(status, STATUS_PAGE_ALL_DOWN);
+ });
+ });
+
+ describe("getStatusDescription()", () => {
+ test("getStatusDescription() returns 'No Services' when status is -1", () => {
+ assert.strictEqual(StatusPage.getStatusDescription(-1), "No Services");
+ });
+
+ test("getStatusDescription() returns 'All Systems Operational' when all systems are up", () => {
+ assert.strictEqual(StatusPage.getStatusDescription(STATUS_PAGE_ALL_UP), "All Systems Operational");
+ });
+
+ test("getStatusDescription() returns 'Partially Degraded Service' when service is partially down", () => {
+ assert.strictEqual(StatusPage.getStatusDescription(STATUS_PAGE_PARTIAL_DOWN), "Partially Degraded Service");
+ });
+
+ test("getStatusDescription() returns 'Degraded Service' when all systems are down", () => {
+ assert.strictEqual(StatusPage.getStatusDescription(STATUS_PAGE_ALL_DOWN), "Degraded Service");
+ });
+
+ test("getStatusDescription() returns 'Under maintenance' when under maintenance", () => {
+ assert.strictEqual(StatusPage.getStatusDescription(MAINTENANCE), "Under maintenance");
+ });
+ });
+ describe("StatusPage.generateOGImage()", () => {
+ test("generateOGImage() generates PNG between 1KB and 100KB", async () => {
+ const { mockPage, cleanup } = createMockStatusPage(
+ {},
+ {
+ heartbeats: [{ status: 1 }],
+ statusDescription: "All Systems Operational",
+ }
+ );
+
+ try {
+ const buffer = await StatusPage.generateOGImage(mockPage);
+ await assertValidPNG(buffer);
+ assert.ok(buffer.length > 1000, "Expected PNG to be greater than 1KB");
+ assert.ok(buffer.length < 100 * 1024, "Expected PNG to be less than 100KB");
+ } finally {
+ cleanup();
+ }
+ });
+
+ test("generateOGImage() handles status page with custom icon and no branding", async () => {
+ const { mockPage, cleanup } = createMockStatusPage(
+ {
+ title: "Production",
+ show_powered_by: false,
+ icon: "/icon.svg",
+ },
+ {
+ heartbeats: [{ status: 1 }, { status: 0 }],
+ statusDescription: "Partially Degraded Service",
+ }
+ );
+
+ try {
+ const buffer = await StatusPage.generateOGImage(mockPage);
+ await assertValidPNG(buffer);
+ } finally {
+ cleanup();
+ }
+ });
+ });
+});
diff --git a/test/e2e/specs/status-page.spec.js b/test/e2e/specs/status-page.spec.js
index 60de8ef1a..600f854bf 100644
--- a/test/e2e/specs/status-page.spec.js
+++ b/test/e2e/specs/status-page.spec.js
@@ -210,6 +210,43 @@ test.describe("Status Page", () => {
expect(await page.locator("head").innerHTML()).toContain(matomoSiteId);
});
+ test("OG image and meta tags", async ({ page, request }) => {
+ test.setTimeout(60000);
+
+ await page.goto("./dashboard");
+ await login(page);
+
+ // Navigate to default status page
+ await page.goto("./status/default");
+ await page.waitForTimeout(1000);
+
+ // Verify OG meta tags exist
+ const ogTitle = await page.locator("meta[property='og:title']").getAttribute("content");
+ expect(ogTitle).toBeTruthy();
+
+ const ogType = await page.locator("meta[property='og:type']").getAttribute("content");
+ expect(ogType).toBe("website");
+
+ const twitterCard = await page.locator("meta[name='twitter:card']").getAttribute("content");
+ expect(twitterCard).toBe("summary_large_image");
+
+ // Test OG image API endpoint
+ const imageResponse = await request.get("/api/status-page/default/image");
+ expect(imageResponse.status()).toBe(200);
+ expect(imageResponse.headers()["content-type"]).toBe("image/png");
+
+ const buffer = await imageResponse.body();
+ expect(buffer.length).toBeGreaterThan(0);
+
+ // Verify PNG signature
+ const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ expect(buffer.subarray(0, 8)).toEqual(pngSignature);
+
+ // Test 404 for non-existent page
+ const notFoundResponse = await request.get("/api/status-page/does-not-exist/image");
+ expect(notFoundResponse.status()).toBe(404);
+ });
+
// @todo Test certificate expiry
// @todo Test domain names