Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement very crude and bare-bones RSS feed #5047

Merged
merged 5 commits into from
Sep 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"express": "~4.19.2",
"express-basic-auth": "~1.2.1",
"express-static-gzip": "~2.1.7",
"feed": "^4.2.2",
"form-data": "~4.0.0",
"gamedig": "^4.2.0",
"html-escaper": "^3.0.3",
Expand Down
157 changes: 157 additions & 0 deletions server/model/status_page.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ const { UptimeKumaServer } = require("../uptime-kuma-server");
const jsesc = require("jsesc");
const googleAnalytics = require("../google-analytics");
const { marked } = require("marked");
const { Feed } = require("feed");
const config = require("../config");

const { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE, DOWN } = require("../../src/util");

class StatusPage extends BeanModel {

Expand All @@ -14,6 +18,24 @@ class StatusPage extends BeanModel {
*/
static domainMappingList = { };

/**
* Handle responses to RSS pages
* @param {Response} response Response object
* @param {string} slug Status page slug
* @returns {Promise<void>}
*/
static async handleStatusPageRSSResponse(response, slug) {
let statusPage = await R.findOne("status_page", " slug = ? ", [
MrYakobo marked this conversation as resolved.
Show resolved Hide resolved
slug
]);

if (statusPage) {
response.send(await StatusPage.renderRSS(statusPage, slug));
} else {
response.status(404).send(UptimeKumaServer.getInstance().indexHTML);
}
}

/**
* Handle responses to status page
* @param {Response} response Response object
Expand All @@ -39,6 +61,38 @@ class StatusPage extends BeanModel {
}
}

/**
* SSR for RSS feed
* @param {statusPage} statusPage object
* @param {slug} slug from router
* @returns {Promise<string>} the rendered html
*/
static async renderRSS(statusPage, slug) {
const { heartbeats, statusDescription } = await StatusPage.getRSSPageData(statusPage);

let proto = config.isSSL ? "https" : "http";
let host = `${proto}://${config.hostname || "localhost"}:${config.port}/status/${slug}`;

const feed = new Feed({
title: "uptime kuma rss feed",
description: `current status: ${statusDescription}`,
link: host,
language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
updated: new Date(), // optional, default = today
});

heartbeats.forEach(heartbeat => {
feed.addItem({
title: `${heartbeat.name} is down`,
description: `${heartbeat.name} has been down since ${heartbeat.time}`,
Copy link
Collaborator

@CommanderStorm CommanderStorm Aug 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite true.
That is just the last heartbeat that failed.
That description however can be misinterpreted as the first DOWN heartbeat (=> the total downtime for said heatbeat).

Also: I don't think that it has to be down, right? (we also have MAINTENANCE, UP and Pending as statuses, but I would need to look up if they can be heartbeats)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found this code in StatusPage.vue that interprets the status field from the database:

            let status = STATUS_PAGE_ALL_UP;
            let hasUp = false;

            for (let id in this.$root.publicLastHeartbeatList) {
                let beat = this.$root.publicLastHeartbeatList[id];

                if (beat.status === MAINTENANCE) {
                    return STATUS_PAGE_MAINTENANCE;
                } else if (beat.status === UP) {
                    hasUp = true;
                } else {
                    status = STATUS_PAGE_PARTIAL_DOWN;
                }
            }

            if (! hasUp) {
                status = STATUS_PAGE_ALL_DOWN;
            }

            return status;

I'll do something similar for the RSS feed 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To your questions,

That is just the last heartbeat that failed.
That description however can be misinterpreted as the first DOWN heartbeat (=> the total downtime for said heatbeat).

in getRSSPageData, I'm filtering for the last heartbeat of monitor_id=?. If that heartbeat is falsy, its added to the RSS feed. When the monitor is UP again, the RSS feed won't publish it anymore. I think this is the desired behaviour.

Copy link
Collaborator

@CommanderStorm CommanderStorm Aug 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filtering for DOWN sounds fine (I don't use RSS-Feeds, your call).

Have you tested that a falsy heartbeat means what you think it does?
Only the following values are falsy: https://developer.mozilla.org/en-US/docs/Glossary/Falsy

=> you are not filtering for the things you think you are..

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great call mate. I've not worked with js for many years, so I'm a little bit rusty on these quirks 👍

id: heartbeat.monitorID,
date: new Date(heartbeat.time),
});
});

return feed.rss2();
}

/**
* SSR for status pages
* @param {string} indexHTML HTML page to render
Expand Down Expand Up @@ -98,6 +152,109 @@ class StatusPage extends BeanModel {
return $.root().html();
}

/**
* @param {heartbeats} heartbeats from getRSSPageData
* @returns {number} status_page constant from util.ts
*/
static overallStatus(heartbeats) {
if (heartbeats.length === 0) {
return -1;
}

let status = STATUS_PAGE_ALL_UP;
let hasUp = false;

for (let beat of heartbeats) {
if (beat.status === MAINTENANCE) {
return STATUS_PAGE_MAINTENANCE;
} else if (beat.status === UP) {
hasUp = true;
} else {
status = STATUS_PAGE_PARTIAL_DOWN;
}
}

if (! hasUp) {
status = STATUS_PAGE_ALL_DOWN;
}

return status;
}

/**
* @param {number} status from overallStatus
* @returns {string} description
*/
static getStatusDescription(status) {
if (status === -1) {
return "No Services";
}

if (status === STATUS_PAGE_ALL_UP) {
return "All Systems Operational";
}

if (status === STATUS_PAGE_PARTIAL_DOWN) {
return "Partially Degraded Service";
}

if (status === STATUS_PAGE_ALL_DOWN) {
return "Degraded Service";
}

// TODO: show the real maintenance information: title, description, time
if (status === MAINTENANCE) {
return "Under maintenance";
}

return "?";
}

/**
* Get all data required for RSS
* @param {StatusPage} statusPage Status page to get data for
* @returns {object} Status page data
*/
static async getRSSPageData(statusPage) {
// get all heartbeats that correspond to this statusPage
const config = await statusPage.toPublicJSON();

// Public Group List
const showTags = !!statusPage.show_tags;

const list = await R.find("group", " public = 1 AND status_page_id = ? ORDER BY weight ", [
statusPage.id
]);

let heartbeats = [];

for (let groupBean of list) {
let monitorGroup = await groupBean.toPublicJSON(showTags, config?.showCertificateExpiry);
for (const monitor of monitorGroup.monitorList) {
const heartbeat = await R.findOne("heartbeat", "monitor_id = ? ORDER BY time DESC", [ monitor.id ]);
if (heartbeat) {
heartbeats.push({
...monitor,
status: heartbeat.status,
time: heartbeat.time
});
}
}
}

// calculate RSS feed description
let status = StatusPage.overallStatus(heartbeats);
let statusDescription = StatusPage.getStatusDescription(status);

// keep only DOWN heartbeats in the RSS feed
heartbeats = heartbeats.filter(heartbeat => heartbeat.status === DOWN);

return {
heartbeats,
statusDescription
};
}

/**
* Get all status page data in one call
* @param {StatusPage} statusPage Status page to get data for
Expand Down
5 changes: 5 additions & 0 deletions server/routers/status-page-router.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ router.get("/status/:slug", cache("5 minutes"), async (request, response) => {
await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug);
});

router.get("/status/:slug/rss", cache("5 minutes"), async (request, response) => {
let slug = request.params.slug;
await StatusPage.handleStatusPageRSSResponse(response, slug);
});

router.get("/status", cache("5 minutes"), async (request, response) => {
let slug = "default";
await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug);
Expand Down
Loading