Merge 35feb7de79 into b638ae48ef
@ -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 = $(`<meta ${attr}="" content="" />`).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 = $('<meta property="og:title" content="" />').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 = $('<meta property="og:description" content="" />').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 = $('<meta property="og:type" content="website" />');
|
||||
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<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
|
||||
* @param {StatusPage} statusPage Status page to get data for
|
||||
|
||||
@ -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;
|
||||
|
||||
320
server/utils/og-image.js
Normal 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, "&")
|
||||
.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 = "<!-- 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,
|
||||
};
|
||||
33
test/backend-test/snapshots/og-images/all-down.svg
Normal 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 |
@ -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 |
@ -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 |
33
test/backend-test/snapshots/og-images/all-up-long-title.svg
Normal 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 |
@ -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 |
32
test/backend-test/snapshots/og-images/all-up-no-branding.svg
Normal 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 |
33
test/backend-test/snapshots/og-images/all-up.svg
Normal 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 |
28
test/backend-test/snapshots/og-images/maintenance.svg
Normal 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 |
37
test/backend-test/snapshots/og-images/partial.svg
Normal 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 |
365
test/backend-test/test-status-page-og-image.js
Normal 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("&"), "&");
|
||||
});
|
||||
|
||||
test("escapeXml() replaces less-than with entity", () => {
|
||||
assert.strictEqual(escapeXml("<"), "<");
|
||||
});
|
||||
|
||||
test("escapeXml() replaces greater-than with entity", () => {
|
||||
assert.strictEqual(escapeXml(">"), ">");
|
||||
});
|
||||
|
||||
test("escapeXml() replaces double quote with entity", () => {
|
||||
assert.strictEqual(escapeXml('"'), """);
|
||||
});
|
||||
|
||||
test("escapeXml() replaces single quote with entity", () => {
|
||||
assert.strictEqual(escapeXml("'"), "'");
|
||||
});
|
||||
|
||||
test("escapeXml() handles multiple special characters in one string", () => {
|
||||
const input = "Company & Services <Status> \"Test\" 'Quote'";
|
||||
const expected = "Company & Services <Status> "Test" 'Quote'";
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
|
||||
|
||||