Merge 35feb7de79 into b638ae48ef
@ -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
|
||||||
|
|||||||
@ -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
@ -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);
|
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
|
||||||
|
|
||||||
|
|||||||