This commit is contained in:
Frank Elsinga 2026-01-19 23:32:15 +00:00 committed by GitHub
commit 7dec8e0635
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1170 additions and 4 deletions

View File

@ -8,6 +8,7 @@ const { marked } = require("marked");
const { Feed } = require("feed"); const { Feed } = require("feed");
const config = require("../config"); const config = require("../config");
const { setting } = require("../util-server"); const { setting } = require("../util-server");
const { generateOGImage, getStatusColor } = require("../utils/og-image");
const { const {
STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_DOWN,
@ -106,6 +107,22 @@ class StatusPage extends BeanModel {
return feed.rss2(); 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 = $(`<meta ${attr}="" content="" />`).attr(attr, property).attr("content", content);
head.append(meta);
}
/** /**
* Build RSS feed URL, handling proxy headers * Build RSS feed URL, handling proxy headers
* @param {string} slug Status page slug * @param {string} slug Status page slug
@ -169,11 +186,30 @@ class StatusPage extends BeanModel {
} }
// OG Meta Tags // OG Meta Tags
let ogTitle = $('<meta property="og:title" content="" />').attr("content", statusPage.title); StatusPage._appendMetaTag($, head, "og:title", statusPage.title);
head.append(ogTitle); StatusPage._appendMetaTag($, head, "og:description", description155);
StatusPage._appendMetaTag($, head, "og:type", "website");
let ogDescription = $('<meta property="og:description" content="" />').attr("content", description155); // Add og:url if primaryBaseURL is configured
head.append(ogDescription); 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 = $('<meta property="og:type" content="website" />'); let ogType = $('<meta property="og:type" content="website" />');
head.append(ogType); head.append(ogType);
@ -256,6 +292,61 @@ class StatusPage extends BeanModel {
return "?"; return "?";
} }
/**
* Generate an Open Graph image for the status page
* @param {StatusPage} statusPage Status page object
* @returns {Promise<Buffer>} 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 * Get all data required for RSS
* @param {StatusPage} statusPage Status page to get data for * @param {StatusPage} statusPage Status page to get data for

View File

@ -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; module.exports = router;

320
server/utils/og-image.js Normal file
View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/**
* 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 = "<!-- Monitor Status Summary -->";
if (statusCounts.up > 0) {
svg += `
<circle cx="60" cy="${y - 8}" r="8" fill="#10b981"/>
<text x="80" y="${y}" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">${statusCounts.up} Up</text>`;
y += 35;
}
if (statusCounts.down > 0) {
svg += `
<circle cx="60" cy="${y - 8}" r="8" fill="#ef4444"/>
<text x="80" y="${y}" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">${statusCounts.down} Down</text>`;
y += 35;
}
if (statusCounts.maintenance > 0) {
svg += `
<circle cx="60" cy="${y - 8}" r="8" fill="#3b82f6"/>
<text x="80" y="${y}" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">${statusCounts.maintenance} Maintenance</text>`;
y += 35;
}
if (statusCounts.pending > 0) {
svg += `
<circle cx="60" cy="${y - 8}" r="8" fill="#f59e0b"/>
<text x="80" y="${y}" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">${statusCounts.pending} Pending</text>`;
}
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 = "<!-- Monitor Details -->";
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 += `
<circle cx="60" cy="${y - 8}" r="8" fill="${statusColor}"/>
<text x="80" y="${y}" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">${escapeXml(monitorName)}</text>`;
});
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 `<!-- Monitor count -->
<text x="80" y="${startY}" font-family="Arial, sans-serif" font-size="28" fill="#9ca3af">${monitors.length} Monitor${plural} tracked</text>`;
}
// 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(/<svg[^>]*>([\s\S]*)<\/svg>/i);
if (!svgMatch) {
return "";
}
// Embed icon scaled to ~80px at top right
return `
<!-- Custom Icon -->
<g transform="translate(1100, 180) scale(0.125) translate(-320, -320)">
${svgMatch[1]}
</g>`;
} catch (error) {
// Silently fail - OG image will generate without icon
return "";
}
}
/**
* Generate SVG for Open Graph image
* @param {string} title Status page title
* @param {string} statusDescription Status description text
* @param {string} statusColor Status color hex code
* @param {string|null} icon Custom icon URL
* @param {boolean} showPoweredBy Whether to show "Powered by Uptime Kuma" branding
* @param {number} timestamp Last updated timestamp (Unix timestamp in milliseconds)
* @param {Array} monitors Array of monitor objects with name and status
* @returns {string} SVG markup
*/
function generateOGImageSVG(title, statusDescription, statusColor, icon, showPoweredBy, timestamp, monitors) {
const displayTitle = truncateText(title, MAX_TITLE_LENGTH);
const iconSVG = generateIconSection(icon);
const monitorDetailsSVG = generateMonitorDetailsSection(monitors);
// Footer
const date = new Date(timestamp);
const timestampText = `UPDATED AT ${date.toISOString()}`;
let footerSVG = "<!-- Footer -->";
if (showPoweredBy) {
footerSVG += `
<text x="60" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280">POWERED BY UPTIME KUMA</text>`;
}
footerSVG += `
<text x="1140" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280" text-anchor="end">${timestampText}</text>`;
return `<?xml version="1.0" encoding="UTF-8"?>
<svg width="${OG_IMAGE_WIDTH}" height="${OG_IMAGE_HEIGHT}" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="${OG_IMAGE_WIDTH}" height="${OG_IMAGE_HEIGHT}" fill="#1f2937"/>
<!-- Status indicator bar at top -->
<rect width="${OG_IMAGE_WIDTH}" height="8" fill="${statusColor}"/>
${iconSVG}
<!-- Title -->
<text x="60" y="180" font-family="Arial, sans-serif" font-size="56" font-weight="bold" fill="#ffffff">${escapeXml(displayTitle)}</text>
<!-- Status circle -->
<circle cx="60" cy="320" r="24" fill="${statusColor}"/>
<!-- Status text -->
<text x="100" y="335" font-family="Arial, sans-serif" font-size="42" fill="#ffffff">${escapeXml(statusDescription)}</text>
${monitorDetailsSVG}${footerSVG}
</svg>`;
}
/**
* Generate an Open Graph image for the status page
* @param {object} statusPageData Status page data object containing title, status info, monitors, etc.
* @param {string} statusPageData.title Status page title
* @param {string} statusPageData.statusDescription Status description text
* @param {string} statusPageData.statusColor Status color hex code
* @param {string|null} statusPageData.icon Custom icon URL
* @param {boolean} statusPageData.showPoweredBy Whether to show "Powered by Uptime Kuma" branding
* @param {Array} statusPageData.monitors Array of monitor objects
* @returns {Promise<Buffer>} PNG image buffer
*/
async function generateOGImage(statusPageData) {
const { title, statusDescription, statusColor, icon, showPoweredBy, monitors } = statusPageData;
const svg = generateOGImageSVG(title, statusDescription, statusColor, icon, !!showPoweredBy, Date.now(), monitors);
const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();
return pngBuffer;
}
module.exports = {
generateOGImageSVG,
generateOGImage,
getStatusColor,
escapeXml,
};

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="1200" height="630" fill="#1f2937"/>
<!-- Status indicator bar at top -->
<rect width="1200" height="8" fill="#ef4444"/>
<!-- Title -->
<text x="60" y="180" font-family="Arial, sans-serif" font-size="56" font-weight="bold" fill="#ffffff">
Infrastructure
</text>
<!-- Status circle -->
<circle cx="60" cy="320" r="24" fill="#ef4444"/>
<!-- Status text -->
<text x="100" y="335" font-family="Arial, sans-serif" font-size="42" fill="#ffffff">
Degraded Service
</text>
<!-- Monitor Status Summary -->
<circle cx="60" cy="412" r="8" fill="#10b981"/>
<text x="80" y="420" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
5 Up
</text>
<circle cx="60" cy="447" r="8" fill="#f59e0b"/>
<text x="80" y="455" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
1 Pending
</text><!-- Footer -->
<text x="60" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280">POWERED BY UPTIME KUMA</text>
<text x="1140" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280" text-anchor="end">UPDATED AT 2024-01-01T00:00:00.000Z</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="1200" height="630" fill="#1f2937"/>
<!-- Status indicator bar at top -->
<rect width="1200" height="8" fill="#10b981"/>
<!-- Custom Icon -->
<g transform="translate(1100, 180) scale(0.125) translate(-320, -320)">
<g transform="matrix(1 0 0 1 320 320)">
<linearGradient id="S3" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1 0 0 1 -319.99875 -320.0001577393)" x1="259.78" y1="261.15" x2="463.85" y2="456.49">
<stop stop-color="#5CDD8B"/>
<stop offset="1" stop-color="#86E6A9"/>
</linearGradient>
<path style="stroke: rgb(242,242,242); stroke-opacity: 0.51; stroke-width: 200; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: url(#S3); fill-rule: nonzero; opacity: 1;" transform=" translate(0, 0)" d="M 170.40125 -84.36016 C 224.09125 38.37984 224.09125 115.33984 170.40125 146.49984 C 89.85125000000001 193.23984000000002 -120.03875 207.48984000000002 -180.45875 135.63984 C -220.73875 87.73983999999999 -220.73875 14.399839999999998 -180.45875 -84.36016000000001 C -139.49875 -151.82016 -81.28875000000001 -185.55016 -5.828750000000014 -185.55016 C 69.64124999999999 -185.55016 128.38125 -151.82016000000002 170.40124999999998 -84.36016000000001 z" stroke-linecap="round" />
</g>
</g>
<!-- Title -->
<text x="60" y="180" font-family="Arial, sans-serif" font-size="56" font-weight="bold" fill="#ffffff">
Production
</text>
<!-- Status circle -->
<circle cx="60" cy="320" r="24" fill="#10b981"/>
<!-- Status text -->
<text x="100" y="335" font-family="Arial, sans-serif" font-size="42" fill="#ffffff">
All Systems Operational
</text>
<!-- Monitor Status Summary -->
<circle cx="60" cy="412" r="8" fill="#10b981"/>
<text x="80" y="420" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
6 Up
</text>
<circle cx="60" cy="447" r="8" fill="#f59e0b"/>
<text x="80" y="455" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
2 Pending
</text><!-- Footer -->
<text x="1140" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280" text-anchor="end">UPDATED AT 2024-01-01T00:00:00.000Z</text>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="1200" height="630" fill="#1f2937"/>
<!-- Status indicator bar at top -->
<rect width="1200" height="8" fill="#10b981"/>
<!-- Custom Icon -->
<g transform="translate(1100, 180) scale(0.125) translate(-320, -320)">
<g transform="matrix(1 0 0 1 320 320)">
<linearGradient id="S3" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1 0 0 1 -319.99875 -320.0001577393)" x1="259.78" y1="261.15" x2="463.85" y2="456.49">
<stop stop-color="#5CDD8B"/>
<stop offset="1" stop-color="#86E6A9"/>
</linearGradient>
<path style="stroke: rgb(242,242,242); stroke-opacity: 0.51; stroke-width: 200; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: url(#S3); fill-rule: nonzero; opacity: 1;" transform=" translate(0, 0)" d="M 170.40125 -84.36016 C 224.09125 38.37984 224.09125 115.33984 170.40125 146.49984 C 89.85125000000001 193.23984000000002 -120.03875 207.48984000000002 -180.45875 135.63984 C -220.73875 87.73983999999999 -220.73875 14.399839999999998 -180.45875 -84.36016000000001 C -139.49875 -151.82016 -81.28875000000001 -185.55016 -5.828750000000014 -185.55016 C 69.64124999999999 -185.55016 128.38125 -151.82016000000002 170.40124999999998 -84.36016000000001 z" stroke-linecap="round" />
</g>
</g>
<!-- Title -->
<text x="60" y="180" font-family="Arial, sans-serif" font-size="56" font-weight="bold" fill="#ffffff">
Production
</text>
<!-- Status circle -->
<circle cx="60" cy="320" r="24" fill="#10b981"/>
<!-- Status text -->
<text x="100" y="335" font-family="Arial, sans-serif" font-size="42" fill="#ffffff">
All Systems Operational
</text>
<!-- Monitor Status Summary -->
<circle cx="60" cy="412" r="8" fill="#10b981"/>
<text x="80" y="420" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
6 Up
</text>
<circle cx="60" cy="447" r="8" fill="#f59e0b"/>
<text x="80" y="455" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
2 Pending
</text><!-- Footer -->
<text x="60" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280">POWERED BY UPTIME KUMA</text>
<text x="1140" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280" text-anchor="end">UPDATED AT 2024-01-01T00:00:00.000Z</text>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="1200" height="630" fill="#1f2937"/>
<!-- Status indicator bar at top -->
<rect width="1200" height="8" fill="#10b981"/>
<!-- Title -->
<text x="60" y="180" font-family="Arial, sans-serif" font-size="56" font-weight="bold" fill="#ffffff">
This is an extremely long status page...
</text>
<!-- Status circle -->
<circle cx="60" cy="320" r="24" fill="#10b981"/>
<!-- Status text -->
<text x="100" y="335" font-family="Arial, sans-serif" font-size="42" fill="#ffffff">
All Systems Operational
</text>
<!-- Monitor Status Summary -->
<circle cx="60" cy="412" r="8" fill="#10b981"/>
<text x="80" y="420" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
4 Up
</text>
<circle cx="60" cy="447" r="8" fill="#f59e0b"/>
<text x="80" y="455" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
1 Pending
</text><!-- Footer -->
<text x="60" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280">POWERED BY UPTIME KUMA</text>
<text x="1140" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280" text-anchor="end">UPDATED AT 2024-01-01T00:00:00.000Z</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="1200" height="630" fill="#1f2937"/>
<!-- Status indicator bar at top -->
<rect width="1200" height="8" fill="#10b981"/>
<!-- Title -->
<text x="60" y="180" font-family="Arial, sans-serif" font-size="56" font-weight="bold" fill="#ffffff">
Enterprise
</text>
<!-- Status circle -->
<circle cx="60" cy="320" r="24" fill="#10b981"/>
<!-- Status text -->
<text x="100" y="335" font-family="Arial, sans-serif" font-size="42" fill="#ffffff">
All Systems Operational
</text>
<!-- Monitor Status Summary -->
<circle cx="60" cy="412" r="8" fill="#10b981"/>
<text x="80" y="420" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
170 Up
</text>
<circle cx="60" cy="447" r="8" fill="#ef4444"/>
<text x="80" y="455" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
20 Down
</text>
<circle cx="60" cy="482" r="8" fill="#3b82f6"/>
<text x="80" y="490" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
6 Maintenance
</text>
<circle cx="60" cy="517" r="8" fill="#f59e0b"/>
<text x="80" y="525" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
4 Pending
</text><!-- Footer -->
<text x="60" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280">POWERED BY UPTIME KUMA</text>
<text x="1140" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280" text-anchor="end">UPDATED AT 2024-01-01T00:00:00.000Z</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="1200" height="630" fill="#1f2937"/>
<!-- Status indicator bar at top -->
<rect width="1200" height="8" fill="#10b981"/>
<!-- Title -->
<text x="60" y="180" font-family="Arial, sans-serif" font-size="56" font-weight="bold" fill="#ffffff">
Production
</text>
<!-- Status circle -->
<circle cx="60" cy="320" r="24" fill="#10b981"/>
<!-- Status text -->
<text x="100" y="335" font-family="Arial, sans-serif" font-size="42" fill="#ffffff">
All Systems Operational
</text>
<!-- Monitor Status Summary -->
<circle cx="60" cy="412" r="8" fill="#10b981"/>
<text x="80" y="420" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
6 Up
</text>
<circle cx="60" cy="447" r="8" fill="#f59e0b"/>
<text x="80" y="455" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
2 Pending
</text><!-- Footer -->
<text x="1140" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280" text-anchor="end">UPDATED AT 2024-01-01T00:00:00.000Z</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="1200" height="630" fill="#1f2937"/>
<!-- Status indicator bar at top -->
<rect width="1200" height="8" fill="#10b981"/>
<!-- Title -->
<text x="60" y="180" font-family="Arial, sans-serif" font-size="56" font-weight="bold" fill="#ffffff">
Production
</text>
<!-- Status circle -->
<circle cx="60" cy="320" r="24" fill="#10b981"/>
<!-- Status text -->
<text x="100" y="335" font-family="Arial, sans-serif" font-size="42" fill="#ffffff">
All Systems Operational
</text>
<!-- Monitor Status Summary -->
<circle cx="60" cy="412" r="8" fill="#10b981"/>
<text x="80" y="420" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
6 Up
</text>
<circle cx="60" cy="447" r="8" fill="#f59e0b"/>
<text x="80" y="455" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
2 Pending
</text><!-- Footer -->
<text x="60" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280">POWERED BY UPTIME KUMA</text>
<text x="1140" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280" text-anchor="end">UPDATED AT 2024-01-01T00:00:00.000Z</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="1200" height="630" fill="#1f2937"/>
<!-- Status indicator bar at top -->
<rect width="1200" height="8" fill="#3b82f6"/>
<!-- Title -->
<text x="60" y="180" font-family="Arial, sans-serif" font-size="56" font-weight="bold" fill="#ffffff">
Maintenance
</text>
<!-- Status circle -->
<circle cx="60" cy="320" r="24" fill="#3b82f6"/>
<!-- Status text -->
<text x="100" y="335" font-family="Arial, sans-serif" font-size="42" fill="#ffffff">
Under maintenance
</text>
<!-- Monitor count -->
<text x="80" y="420" font-family="Arial, sans-serif" font-size="28" fill="#9ca3af">
3 Monitors tracked
</text><!-- Footer -->
<text x="60" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280">POWERED BY UPTIME KUMA</text>
<text x="1140" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280" text-anchor="end">UPDATED AT 2024-01-01T00:00:00.000Z</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="1200" height="630" fill="#1f2937"/>
<!-- Status indicator bar at top -->
<rect width="1200" height="8" fill="#f59e0b"/>
<!-- Title -->
<text x="60" y="180" font-family="Arial, sans-serif" font-size="56" font-weight="bold" fill="#ffffff">
API Services
</text>
<!-- Status circle -->
<circle cx="60" cy="320" r="24" fill="#f59e0b"/>
<!-- Status text -->
<text x="100" y="335" font-family="Arial, sans-serif" font-size="42" fill="#ffffff">
Partially Degraded Service
</text>
<!-- Monitor Status Summary -->
<circle cx="60" cy="412" r="8" fill="#10b981"/>
<text x="80" y="420" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
10 Up
</text>
<circle cx="60" cy="447" r="8" fill="#ef4444"/>
<text x="80" y="455" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
1 Down
</text>
<circle cx="60" cy="482" r="8" fill="#f59e0b"/>
<text x="80" y="490" font-family="Arial, sans-serif" font-size="22" fill="#d1d5db">
1 Pending
</text><!-- Footer -->
<text x="60" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280">POWERED BY UPTIME KUMA</text>
<text x="1140" y="570" font-family="Arial, sans-serif" font-size="18" fill="#6b7280" text-anchor="end">UPDATED AT 2024-01-01T00:00:00.000Z</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,365 @@
const { describe, test } = require("node:test");
const assert = require("node:assert");
const sharp = require("sharp");
const StatusPage = require("../../server/model/status_page");
const { generateOGImageSVG, getStatusColor, escapeXml } = require("../../server/utils/og-image");
const fs = require("fs");
const path = require("path");
const { STATUS_PAGE_ALL_UP, STATUS_PAGE_PARTIAL_DOWN, STATUS_PAGE_ALL_DOWN, MAINTENANCE } = require("../../src/util");
const SNAPSHOTS_DIR = path.join(__dirname, "snapshots", "og-images");
// Ensure snapshots directory exists
if (!fs.existsSync(SNAPSHOTS_DIR)) {
fs.mkdirSync(SNAPSHOTS_DIR, { recursive: true });
}
// Fixed timestamp for consistent snapshots (2024-01-01 00:00:00 UTC)
const FIXED_TIMESTAMP = 1704067200000;
const TEST_SCENARIOS = [
{
name: "all-up",
title: "Production",
statusCode: STATUS_PAGE_ALL_UP,
monitorCount: 8,
},
{
name: "all-down",
title: "Infrastructure",
statusCode: STATUS_PAGE_ALL_DOWN,
monitorCount: 6,
},
{
name: "maintenance",
title: "Maintenance",
statusCode: MAINTENANCE,
monitorCount: 3,
},
{
name: "partial",
title: "API Services",
statusCode: STATUS_PAGE_PARTIAL_DOWN,
monitorCount: 12,
},
{
name: "all-up-many-monitors",
title: "Enterprise",
statusCode: STATUS_PAGE_ALL_UP,
monitorCount: 200,
},
{
name: "all-up-no-branding",
title: "Production",
statusCode: STATUS_PAGE_ALL_UP,
monitorCount: 8,
showPoweredBy: false,
},
{
name: "all-up-custom-icon-no-branding",
title: "Production",
statusCode: STATUS_PAGE_ALL_UP,
monitorCount: 8,
icon: "/icon.svg",
showPoweredBy: false,
},
{
name: "all-up-custom-icon-with-branding",
title: "Production",
statusCode: STATUS_PAGE_ALL_UP,
monitorCount: 8,
icon: "/icon.svg",
showPoweredBy: true,
},
{
name: "all-up-long-title",
title: "This is an extremely long status page title that will need to be truncated",
statusCode: STATUS_PAGE_ALL_UP,
monitorCount: 5,
},
];
/**
* Compare SVG with snapshot, create if doesn't exist
* @param {string} svg SVG content
* @param {string} snapshotName Snapshot filename
* @returns {void}
*/
function assertMatchesSnapshot(svg, snapshotName) {
const snapshotPath = path.join(SNAPSHOTS_DIR, snapshotName);
if (fs.existsSync(snapshotPath)) {
const snapshot = fs.readFileSync(snapshotPath, "utf8");
assert.strictEqual(svg, snapshot, `Expected SVG to match snapshot: ${snapshotName}`);
} else {
fs.writeFileSync(snapshotPath, svg, "utf8");
console.log(`Created snapshot: ${snapshotName}`);
}
}
/**
* Creates a mock status page with RSS data
* @param {object} overrides Override default values
* @param {object} rssData RSS data to return
* @returns {object} Mock status page and cleanup function
*/
function createMockStatusPage(overrides = {}, rssData = null) {
const mockPage = {
id: 1,
slug: "test",
title: "Test Page",
description: "Test Description",
...overrides,
};
const originalGetRSSData = StatusPage.getRSSPageData;
if (rssData) {
StatusPage.getRSSPageData = async () => rssData;
}
const cleanup = () => {
StatusPage.getRSSPageData = originalGetRSSData;
};
return { mockPage, cleanup };
}
/**
* Verify PNG buffer structure and metadata
* @param {Buffer} buffer PNG buffer to verify
* @returns {Promise<void>}
*/
async function assertValidPNG(buffer) {
assert.ok(buffer instanceof Buffer, "Expected Buffer instance");
assert.ok(buffer.length > 0, "Expected non-empty buffer");
// Check PNG signature (first 8 bytes)
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const actualSignature = buffer.slice(0, 8);
assert.deepStrictEqual(actualSignature, pngSignature, "Expected valid PNG signature");
// Use sharp to verify metadata
const metadata = await sharp(buffer).metadata();
assert.strictEqual(metadata.format, "png", "Expected PNG format");
assert.strictEqual(metadata.width, 1200, "Expected width of 1200");
assert.strictEqual(metadata.height, 630, "Expected height of 630");
}
describe("OG Image Helper Functions", () => {
describe("escapeXml()", () => {
test("escapeXml() replaces ampersand with entity", () => {
assert.strictEqual(escapeXml("&"), "&amp;");
});
test("escapeXml() replaces less-than with entity", () => {
assert.strictEqual(escapeXml("<"), "&lt;");
});
test("escapeXml() replaces greater-than with entity", () => {
assert.strictEqual(escapeXml(">"), "&gt;");
});
test("escapeXml() replaces double quote with entity", () => {
assert.strictEqual(escapeXml('"'), "&quot;");
});
test("escapeXml() replaces single quote with entity", () => {
assert.strictEqual(escapeXml("'"), "&apos;");
});
test("escapeXml() handles multiple special characters in one string", () => {
const input = "Company & Services <Status> \"Test\" 'Quote'";
const expected = "Company &amp; Services &lt;Status&gt; &quot;Test&quot; &apos;Quote&apos;";
assert.strictEqual(escapeXml(input), expected);
});
});
describe("getStatusColor()", () => {
test("getStatusColor() returns green when all systems are up", () => {
assert.strictEqual(getStatusColor(STATUS_PAGE_ALL_UP), "#10b981");
});
test("getStatusColor() returns yellow when service is partially degraded", () => {
assert.strictEqual(getStatusColor(STATUS_PAGE_PARTIAL_DOWN), "#f59e0b");
});
test("getStatusColor() returns red when all systems are down", () => {
assert.strictEqual(getStatusColor(STATUS_PAGE_ALL_DOWN), "#ef4444");
});
test("getStatusColor() returns blue when under maintenance", () => {
assert.strictEqual(getStatusColor(MAINTENANCE), "#3b82f6");
});
test("getStatusColor() returns gray when there are no services", () => {
assert.strictEqual(getStatusColor(-1), "#6b7280");
});
});
});
describe("generateOGImageSVG()", () => {
test("generateOGImageSVG() starts with XML declaration", () => {
const svg = generateOGImageSVG("Test", "All OK", "#10b981", null, true, FIXED_TIMESTAMP, Array(5).fill({}));
assert.ok(svg.startsWith("<?xml"), "Expected XML declaration at start");
});
test("generateOGImageSVG() includes SVG namespace", () => {
const svg = generateOGImageSVG("Test", "All OK", "#10b981", null, true, FIXED_TIMESTAMP, Array(5).fill({}));
assert.ok(svg.includes('xmlns="http://www.w3.org/2000/svg"'), "Expected SVG namespace");
});
test("generateOGImageSVG() sets width to 1200", () => {
const svg = generateOGImageSVG("Test", "All OK", "#10b981", null, true, FIXED_TIMESTAMP, Array(5).fill({}));
assert.ok(svg.includes('width="1200"'), "Expected width of 1200");
});
test("generateOGImageSVG() sets height to 630", () => {
const svg = generateOGImageSVG("Test", "All OK", "#10b981", null, true, FIXED_TIMESTAMP, Array(5).fill({}));
assert.ok(svg.includes('height="630"'), "Expected height of 630");
});
test("generateOGImageSVG() includes closing SVG tag", () => {
const svg = generateOGImageSVG("Test", "All OK", "#10b981", null, true, FIXED_TIMESTAMP, Array(5).fill({}));
assert.ok(svg.includes("</svg>"), "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();
}
});
});
});

View File

@ -210,6 +210,43 @@ test.describe("Status Page", () => {
expect(await page.locator("head").innerHTML()).toContain(matomoSiteId); 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 certificate expiry
// @todo Test domain names // @todo Test domain names