Merge pull request #731 from Kukks/feat/rss-feed

Add public RSS 2.0 feed for incidents and scheduled maintenance
This commit is contained in:
Raj Nandan Sharma
2026-06-15 10:30:59 +05:30
committed by GitHub
37 changed files with 493 additions and 3 deletions
+26
View File
@@ -10,6 +10,7 @@
import Sun from "@lucide/svelte/icons/sun";
import Moon from "@lucide/svelte/icons/moon";
import Share from "@lucide/svelte/icons/share-2";
import Rss from "@lucide/svelte/icons/rss";
import { format } from "date-fns";
import SubscribeMenu from "$lib/components/SubscribeMenu.svelte";
import CopyButton from "$lib/components/CopyButton.svelte";
@@ -35,6 +36,13 @@
let shareLink = $state("");
const eventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
const rssHref = $derived.by(() => {
const params = page.params;
if (params.monitor_tag) return clientResolver(resolve, `/monitors/${params.monitor_tag}/rss.xml`);
if (params.page_path) return clientResolver(resolve, `/${params.page_path}/rss.xml`);
return clientResolver(resolve, "/rss.xml");
});
const loginDetails = $derived.by((): { label: string; url: string } | null => {
if (!page.data?.loggedInUser) return null;
@@ -95,6 +103,24 @@
</ButtonGroup.Root>
{/if}
{#if page.data.subMenuOptions?.showRssFeed !== false}
<ButtonGroup.Root class="rounded-btn-grp shrink-0">
<Button
variant="outline"
size="icon-sm"
href={rssHref}
target="_blank"
rel="alternate"
aria-label={$t("RSS feed")}
title={$t("RSS feed")}
class="bg-background/80 dark:bg-background/70 border-foreground/10 cursor-pointer rounded-full border shadow-none backdrop-blur-md"
onclick={() => trackEvent("rss_opened", { source: "theme_plus" })}
>
<Rss />
</Button>
</ButtonGroup.Root>
{/if}
<ButtonGroup.Root class="rounded-btn-grp shrink-0">
<CopyButton
variant="outline"
+1
View File
@@ -116,6 +116,7 @@
"Recent Incidents": "Nedávné incidenty",
"Recurring": "Opakující se",
"RESOLVED": "VYŘEŠENO",
"RSS feed": "RSS feed",
"SCHEDULED": "NAPLÁNOVÁNO",
"Scheduled Events (%count)": "Plánované události (%count)",
"Scheduled Windows": "Naplánované úlohy",
+1
View File
@@ -100,6 +100,7 @@
"READY": "BEREIT",
"Recurring": "Wiederkehrend",
"RESOLVED": "BEHOBEN",
"RSS feed": "RSS feed",
"SCHEDULED": "GEPLANT",
"Scheduled Events (%count)": "Geplante Ereignisse (%count)",
"Script": "Skript",
+1
View File
@@ -100,6 +100,7 @@
"READY": "KLAR",
"Recurring": "Tilbagevendende",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PLANLAGT",
"Scheduled Events (%count)": "Planlagte begivenheder (%count)",
"Script": "Manuskript",
+1
View File
@@ -100,6 +100,7 @@
"READY": "READY",
"Recurring": "Recurring",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "SCHEDULED",
"Scheduled Events (%count)": "Scheduled Events (%count)",
"Script": "Script",
+1
View File
@@ -100,6 +100,7 @@
"READY": "LISTO",
"Recurring": "Recurrente",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PROGRAMADO",
"Scheduled Events (%count)": "Eventos programados (%count)",
"Script": "Guion",
+1
View File
@@ -100,6 +100,7 @@
"READY": "آماده",
"Recurring": "دوره‌ای",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "زمان‌بندی شده",
"Scheduled Events (%count)": "رویدادهای زمان‌بندی شده (%count)",
"Script": "اسکریپت",
+1
View File
@@ -100,6 +100,7 @@
"READY": "PRÊT",
"Recurring": "Récurrent",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PLANIFIÉ",
"Scheduled Events (%count)": "Événements planifiés (%count)",
"Script": "Scénario",
+1
View File
@@ -100,6 +100,7 @@
"READY": "तैयार",
"Recurring": "आवर्ती",
"RESOLVED": "सुलझा हुआ",
"RSS feed": "RSS feed",
"SCHEDULED": "अनुसूचित",
"Scheduled Events (%count)": "अनुसूचित कार्यक्रम (%count)",
"Script": "स्क्रिप्ट",
+1
View File
@@ -100,6 +100,7 @@
"READY": "PRONTO",
"Recurring": "Ricorrente",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PROGRAMMATO",
"Scheduled Events (%count)": "Eventi programmati (%count)",
"Script": "Copione",
+1
View File
@@ -100,6 +100,7 @@
"READY": "準備完了",
"Recurring": "繰り返し",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "予定済み",
"Scheduled Events (%count)": "予定イベント (%count)",
"Script": "スクリプト",
+1
View File
@@ -100,6 +100,7 @@
"READY": "준비됨",
"Recurring": "반복",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "예정됨",
"Scheduled Events (%count)": "예정된 이벤트 (%count)",
"Script": "스크립트",
+1
View File
@@ -100,6 +100,7 @@
"READY": "KLAR",
"Recurring": "Gjentakende",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PLANLAGT",
"Scheduled Events (%count)": "Planlagte hendelser (%count)",
"Script": "Manus",
+1
View File
@@ -100,6 +100,7 @@
"READY": "GEREED",
"Recurring": "Terugkerend",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "GEPLAND",
"Scheduled Events (%count)": "Geplande evenementen (%count)",
"Script": "Script",
+1
View File
@@ -100,6 +100,7 @@
"READY": "GOTOWE",
"Recurring": "Cykliczne",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "ZAPLANOWANE",
"Scheduled Events (%count)": "Zaplanowane wydarzenia (%count)",
"Script": "Scenariusz",
+1
View File
@@ -100,6 +100,7 @@
"READY": "PRONTO",
"Recurring": "Recorrente",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "AGENDADO",
"Scheduled Events (%count)": "Eventos agendados (%count)",
"Script": "Roteiro",
+1
View File
@@ -101,6 +101,7 @@
"READY": "ГОТОВО",
"Recurring": "Повторяющийся",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "ЗАПЛАНИРОВАНО",
"Scheduled Events (%count)": "Запланированные события (%count)",
"Script": "Скрипт",
+1
View File
@@ -116,6 +116,7 @@
"Recent Incidents": "Nedávne incidenty",
"Recurring": "Opakujúce sa",
"RESOLVED": "VYRIEŠENÉ",
"RSS feed": "RSS feed",
"SCHEDULED": "NAPLÁNOVANÉ",
"Scheduled Events (%count)": "Plánované udalosti (%count)",
"Scheduled Windows": "Naplánované úlohy",
+1
View File
@@ -100,6 +100,7 @@
"READY": "HAZIR",
"Recurring": "Tekrarlayan",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PLANLANMIŞ",
"Scheduled Events (%count)": "Planlanan etkinlikler (%count)",
"Script": "Senaryo",
+2 -1
View File
@@ -101,6 +101,7 @@
"READY": "ГОТОВО",
"Recurring": "Повторюване",
"RESOLVED": "ВИРІШЕНО",
"RSS feed": "RSS feed",
"SCHEDULED": "ЗАПЛАНОВАНО",
"Scheduled Events (%count)": "Заплановані події (%count)",
"Script": "Скрипт",
@@ -135,4 +136,4 @@
"Verifying": "Перевірка",
"We sent a 6-digit code to": "Ми надіслали 6-значний код на"
}
}
}
+1
View File
@@ -100,6 +100,7 @@
"READY": "SẴN SÀNG",
"Recurring": "Định kỳ",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "ĐÃ LÊN LỊCH",
"Scheduled Events (%count)": "Sự kiện đã lên lịch (%count)",
"Script": "Kịch bản",
+1
View File
@@ -100,6 +100,7 @@
"READY": "就绪",
"Recurring": "周期性",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "已安排",
"Scheduled Events (%count)": "计划事件 (%count)",
"Script": "脚本",
+1
View File
@@ -96,6 +96,7 @@
"READY": "就緒",
"Recurring": "週期性",
"RESOLVED": "已解決",
"RSS feed": "RSS feed",
"SCHEDULED": "已排程",
"Scheduled Events (%count)": "排程事件 (%count)",
"Script": "程式碼",
@@ -48,6 +48,7 @@ export interface LayoutServerData {
subMenuOptions: {
showShareBadgeMonitor: boolean;
showShareEmbedMonitor: boolean;
showRssFeed: boolean;
};
isTimezoneEnabled: boolean;
isThemeToggleEnabled: boolean;
@@ -483,6 +483,7 @@ export class MaintenancesRepository extends BaseRepository {
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances_events.status as status",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -516,6 +517,7 @@ export class MaintenancesRepository extends BaseRepository {
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances_events.status as status",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -564,6 +566,7 @@ export class MaintenancesRepository extends BaseRepository {
"monitors.is_hidden as monitor_is_hidden",
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -606,6 +609,7 @@ export class MaintenancesRepository extends BaseRepository {
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances_events.status as status",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -648,6 +652,7 @@ export class MaintenancesRepository extends BaseRepository {
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances_events.status as status",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -686,6 +691,7 @@ export class MaintenancesRepository extends BaseRepository {
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances_events.status as status",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -714,6 +720,7 @@ export class MaintenancesRepository extends BaseRepository {
start_date_time: row.start_date_time,
end_date_time: row.end_date_time,
status: row.status,
is_global: row.is_global,
created_at: row.created_at,
updated_at: row.updated_at,
monitors: [],
+1
View File
@@ -135,6 +135,7 @@ const seedSiteData = {
subMenuOptions: {
showShareBadgeMonitor: true,
showShareEmbedMonitor: true,
showRssFeed: true,
},
dataRetentionPolicy: {
enabled: true,
+243
View File
@@ -0,0 +1,243 @@
/**
* RSS 2.0 feed for Kener status pages.
*
* Two layers live in this file:
* 1. buildRssFeed — pure XML formatter (no I/O).
* 2. renderRssFeedResponse — fetches recent incidents + maintenances for a
* page, shapes them into items, and returns an HTTP Response. Shared by
* both the default-page and named-page route handlers.
*/
import db from "$lib/server/db/db.js";
import { GetAllSiteData } from "$lib/server/controllers/siteDataController.js";
import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db.js";
export type RssFeedItemType = "incident" | "maintenance";
export interface RssFeedItem {
type: RssFeedItemType;
id: number;
title: string;
link: string;
pubDate: number;
description: string;
}
export interface BuildRssFeedArgs {
siteName: string;
siteURL: string;
basePath: string;
feedPath: string;
items: RssFeedItem[];
}
const TYPE_TITLE_PREFIX: Record<RssFeedItemType, string> = {
incident: "[Incident]",
maintenance: "[Maintenance]",
};
export function buildRssFeed(args: BuildRssFeedArgs): string {
const channelLink = joinUrl(args.siteURL, args.basePath);
const selfLink = joinUrl(args.siteURL, args.basePath, args.feedPath);
const channelTitle = `${args.siteName} — Incidents & Maintenance`;
const channelDescription = `Latest incidents and scheduled maintenance for ${args.siteName}`;
const lastBuildSeconds = args.items.length > 0 ? Math.max(...args.items.map((i) => i.pubDate)) : nowSeconds();
const itemsXml = args.items.map(renderItem).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(channelTitle)}</title>
<link>${escapeXml(channelLink)}</link>
<description>${escapeXml(channelDescription)}</description>
<language>en</language>
<lastBuildDate>${formatRfc822(lastBuildSeconds)}</lastBuildDate>
<atom:link href="${escapeXml(selfLink)}" rel="self" type="application/rss+xml" />
${itemsXml}
</channel>
</rss>
`;
}
function renderItem(item: RssFeedItem): string {
const prefixedTitle = `${TYPE_TITLE_PREFIX[item.type]} ${item.title}`;
const guid = `${item.type}-${item.id}`;
return ` <item>
<title>${escapeXml(prefixedTitle)}</title>
<link>${escapeXml(item.link)}</link>
<guid isPermaLink="false">${escapeXml(guid)}</guid>
<pubDate>${formatRfc822(item.pubDate)}</pubDate>
<description>${cdata(item.description)}</description>
</item>`;
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function cdata(value: string): string {
// Split any literal ]]> sequence so the CDATA section terminates only at ours.
const safe = value.replace(/]]>/g, "]]]]><![CDATA[>");
return `<![CDATA[${safe}]]>`;
}
function formatRfc822(seconds: number): string {
// Date#toUTCString returns RFC-1123 form, which RSS 2.0 readers accept as RFC-822.
return new Date(seconds * 1000).toUTCString();
}
function nowSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function joinUrl(...parts: string[]): string {
const [origin, ...rest] = parts;
const trimmedOrigin = origin.replace(/\/+$/, "");
const path = rest
.map((p) => p.trim())
.filter((p) => p.length > 0)
.map((p) => "/" + p.replace(/^\/+/, "").replace(/\/+$/, ""))
.join("");
return trimmedOrigin + path;
}
// Window for items pulled into the feed. Bounded by both: last 90 days AND
// the most recent 50 entries after merging. Matches typical reader expectations
// without scanning the entire incident history.
const FEED_WINDOW_DAYS = 90;
const FEED_MAX_ITEMS = 50;
// Scope determines which monitors the feed covers.
// - page: scoped to a status page's monitor list (honors hidden-monitor
// stripping and globalPageVisibilitySettings.forceExclusivity when pagePath is null)
// - monitor: scoped to a single monitor by tag (404 if hidden, inactive, or unknown)
export type RenderRssFeedScope = { type: "page"; pagePath: string | null } | { type: "monitor"; tag: string };
export interface RenderRssFeedArgs {
scope: RenderRssFeedScope;
// Path of THIS feed under basePath, e.g. "/rss.xml" or "/monitors/foo/rss.xml".
feedPath: string;
}
export async function renderRssFeedResponse(args: RenderRssFeedArgs): Promise<Response> {
const siteData = await GetAllSiteData();
const siteURL = siteData.siteURL;
if (!siteURL) {
return new Response("Not found", { status: 404 });
}
const siteName = siteData.siteName || "Status";
const basePath = process.env.KENER_BASE_PATH || "";
let monitorTags: string[] | undefined = undefined;
if (args.scope.type === "monitor") {
const monitor = await db.getMonitorByTag(args.scope.tag);
if (!monitor || monitor.is_hidden === "YES" || monitor.status !== "ACTIVE") {
return new Response("Not found", { status: 404 });
}
monitorTags = [args.scope.tag];
} else {
let pagePath = args.scope.pagePath;
if (!!siteData.globalPageVisibilitySettings?.forceExclusivity && pagePath === null) {
pagePath = "";
}
if (pagePath !== null) {
const page = await db.getPageByPath(pagePath);
if (!page) {
return new Response("Not found", { status: 404 });
}
const pageMonitors = await db.getPageMonitorsExcludeHidden(page.id);
monitorTags = pageMonitors.map((m) => m.monitor_tag);
}
}
const nowTs = nowSeconds();
const startTs = nowTs - FEED_WINDOW_DAYS * 24 * 60 * 60;
// Maintenances need a future end too: a SCHEDULED maintenance has its
// start_date_time in the future, and we want subscribers to learn about
// upcoming windows. Incidents are always past, so they keep nowTs as end.
const futureTs = nowTs + FEED_WINDOW_DAYS * 24 * 60 * 60;
const [incidents, maintenances] = await Promise.all([
db.getIncidentsForEventsByDateRange(startTs, nowTs, monitorTags),
db.getMaintenanceEventsForEventsByDateRange(startTs, futureTs, monitorTags),
]);
const items: RssFeedItem[] = [];
for (const incident of incidents) {
items.push({
type: "incident",
id: incident.id,
title: incident.title,
link: joinUrl(siteURL, basePath, `/incidents/${incident.id}`),
pubDate: incident.comments[0]?.commented_at ?? incident.start_date_time,
description: buildIncidentDescription(incident),
});
}
for (const maintenance of maintenances) {
// Drop events whose affected monitors were all hidden: the DB layer strips
// hidden monitors from the row; a now-empty monitors[] means the public
// shouldn't see this on the events page either. A global maintenance is the
// exception — it has no per-monitor rows by design (it affects every
// monitor), so its empty monitors[] is expected and must still be published.
if (maintenance.monitors.length === 0 && maintenance.is_global !== "YES") continue;
items.push({
type: "maintenance",
id: maintenance.id,
title: maintenance.title,
link: joinUrl(siteURL, basePath, `/maintenances/${maintenance.id}`),
pubDate: maintenance.start_date_time,
description: buildMaintenanceDescription(maintenance),
});
}
items.sort((a, b) => b.pubDate - a.pubDate);
const capped = items.slice(0, FEED_MAX_ITEMS);
const xml = buildRssFeed({ siteName, siteURL, basePath, feedPath: args.feedPath, items: capped });
return new Response(xml, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=300",
},
});
}
function buildIncidentDescription(incident: IncidentForMonitorListWithComments): string {
const lines: string[] = [];
lines.push(`Status: ${incident.state}`);
if (incident.monitors.length > 0) {
const names = incident.monitors.map((m) => m.monitor_name).join(", ");
lines.push(`Affected: ${names}`);
}
const latest = incident.comments[0];
if (latest) {
lines.push("");
lines.push(latest.comment);
}
return lines.join("\n");
}
function buildMaintenanceDescription(maintenance: MaintenanceEventsMonitorList): string {
const lines: string[] = [];
lines.push(`Status: ${maintenance.status}`);
const start = formatRfc822(maintenance.start_date_time);
const end = maintenance.end_date_time != null ? formatRfc822(maintenance.end_date_time) : "open-ended";
lines.push(`Scheduled: ${start}${end}`);
if (maintenance.is_global === "YES") {
lines.push("Affected: All monitors");
} else if (maintenance.monitors.length > 0) {
const names = maintenance.monitors.map((m) => m.monitor_name).join(", ");
lines.push(`Affected: ${names}`);
}
if (maintenance.description) {
lines.push("");
lines.push(maintenance.description);
}
return lines.join("\n");
}
+1
View File
@@ -647,6 +647,7 @@ export interface MaintenanceEventsMonitorList {
description: string | null;
start_date_time: number; // Unix timestamp - when the first occurrence starts
end_date_time: number; // Unix timestamp - when the first occurrence ends
is_global: YesNoType; // "YES" when the maintenance affects all monitors (no per-monitor rows)
monitors: MaintenanceMonitorImpact[];
created_at: Date;
updated_at: Date;
+1
View File
@@ -89,6 +89,7 @@ export interface SiteSubscriptionsSettings {
export interface SiteSubMenuOptions {
showShareBadgeMonitor: boolean;
showShareEmbedMonitor: boolean;
showRssFeed: boolean;
}
export interface DataRetentionPolicy {
+4
View File
@@ -213,6 +213,10 @@
"title": "User Subscriptions",
"content": "v4/subscriptions"
},
{
"title": "RSS Feed",
"content": "v4/rss-feed"
},
{
"title": "User Management",
"content": "v4/user-management"
@@ -0,0 +1,56 @@
---
title: RSS Feed
description: Public RSS 2.0 feed of recent incidents and scheduled maintenance
---
Kener exposes a public RSS 2.0 feed of incidents and scheduled maintenance so feed readers, bots, and dashboards can pick up status changes without polling the API.
## Feed URLs {#feed-urls}
| URL | Scope |
| ---------------------- | ------------------------------------------------------------ |
| `/rss.xml` | Default page (root). Honors `forceExclusivity` when enabled. |
| `/{page_path}/rss.xml` | A named status page — only items for that page's monitors. |
URLs respect `KENER_BASE_PATH`, so a deployment at `https://status.example.com/status` exposes the feed at `https://status.example.com/status/rss.xml`.
Both routes return:
- `Content-Type: application/rss+xml; charset=utf-8`
- `Cache-Control: public, max-age=300`
## What's in the feed {#whats-in-the-feed}
The feed includes incidents and maintenance events from the last 90 days, capped at 50 most-recent items, sorted by date descending.
Items inherit the same visibility rules as the events page:
- Items tied only to hidden monitors are excluded.
- Unknown `page_path` returns `404`.
- If `siteURL` is not configured, the feed returns `404`.
## Item shape {#item-shape}
| Field | Notes |
| ------------- | --------------------------------------------------------------------------------------------------- |
| `title` | `[Incident] ...` or `[Maintenance] ...` |
| `link` | Absolute URL to `/incidents/{id}` or `/maintenances/{id}` |
| `guid` | `incident-{id}` or `maintenance-{event_id}` — stable, not a permalink |
| `pubDate` | RFC-822. Incidents use latest comment time (fallback start time); maintenances use scheduled start. |
| `description` | CDATA-wrapped text: status, affected monitors, latest update or maintenance description |
For maintenances, `{event_id}` is the maintenance **event** id (one item per recurrence), matching the URLs used elsewhere in the app.
## Verify your feed {#verify-your-feed}
```bash
curl -i https://status.example.com/rss.xml
```
You should see `200 OK`, `application/rss+xml`, and a `<channel>` block with one `<item>` per recent event.
## Related pages {#related-pages}
- [User Subscriptions](/docs/v4/subscriptions) — email-based delivery
- [Pages](/docs/v4/pages) — how page scoping works
- [Base Path Deployment](/docs/v4/guides/base-path) — `KENER_BASE_PATH` setup
+9
View File
@@ -7,9 +7,17 @@
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { Toaster } from "$lib/components/ui/sonner/index.js";
import { resolve } from "$app/paths";
import { page } from "$app/state";
import clientResolver from "$lib/client/resolver.js";
let { children, data } = $props();
const rssHref = $derived.by(() => {
const params = page.params;
if (params.monitor_tag) return clientResolver(resolve, `/monitors/${params.monitor_tag}/rss.xml`);
if (params.page_path) return clientResolver(resolve, `/${params.page_path}/rss.xml`);
return clientResolver(resolve, "/rss.xml");
});
</script>
<ModeWatcher />
@@ -18,6 +26,7 @@
<svelte:head>
<link rel="icon" href={data.favicon} />
<link rel="alternate" type="application/rss+xml" title="RSS feed" href={rssHref} />
{#if data.font?.cssSrc}
<link rel="stylesheet" href={data.font.cssSrc} />
{/if}
@@ -0,0 +1,8 @@
import type { RequestHandler } from "./$types";
import { renderRssFeedResponse } from "$lib/server/rss.js";
export const GET: RequestHandler = ({ params }) =>
renderRssFeedResponse({
scope: { type: "page", pagePath: params.page_path },
feedPath: `/${params.page_path}/rss.xml`,
});
@@ -0,0 +1,8 @@
import type { RequestHandler } from "./$types";
import { renderRssFeedResponse } from "$lib/server/rss.js";
export const GET: RequestHandler = ({ params }) =>
renderRssFeedResponse({
scope: { type: "monitor", tag: params.monitor_tag },
feedPath: `/monitors/${params.monitor_tag}/rss.xml`,
});
+5
View File
@@ -0,0 +1,5 @@
import type { RequestHandler } from "./$types";
import { renderRssFeedResponse } from "$lib/server/rss.js";
export const GET: RequestHandler = () =>
renderRssFeedResponse({ scope: { type: "page", pagePath: null }, feedPath: "/rss.xml" });
@@ -78,7 +78,8 @@
// Sub Menu Options
let subMenuOptions = $state({
showShareBadgeMonitor: true,
showShareEmbedMonitor: true
showShareEmbedMonitor: true,
showRssFeed: true
});
const defaultGlobalPageVisibilitySettings: GlobalPageVisibilitySettings =
@@ -172,7 +173,8 @@
if (data.subMenuOptions) {
subMenuOptions = {
showShareBadgeMonitor: data.subMenuOptions.showShareBadgeMonitor ?? true,
showShareEmbedMonitor: data.subMenuOptions.showShareEmbedMonitor ?? true
showShareEmbedMonitor: data.subMenuOptions.showShareEmbedMonitor ?? true,
showRssFeed: data.subMenuOptions.showRssFeed ?? true
};
}
@@ -1128,6 +1130,15 @@
</div>
<Switch bind:checked={subMenuOptions.showShareEmbedMonitor} />
</div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>RSS Feed</Label>
<p class="text-muted-foreground text-xs">
Show an RSS feed link in the page header. The feed itself is always reachable at /rss.xml.
</p>
</div>
<Switch bind:checked={subMenuOptions.showRssFeed} />
</div>
</Card.Content>
<Card.Footer class="flex justify-end">
<Button onclick={saveSubMenuOptions} disabled={savingSubMenuOptions} class="cursor-pointer">
@@ -22,6 +22,7 @@
import AlertTriangle from "@lucide/svelte/icons/alert-triangle";
import Wrench from "@lucide/svelte/icons/wrench";
import Mail from "@lucide/svelte/icons/mail";
import Rss from "@lucide/svelte/icons/rss";
import type { SubscriptionsConfig } from "$lib/server/types/db.js";
import AlertCircleIcon from "@lucide/svelte/icons/octagon-alert";
@@ -39,6 +40,64 @@
let loadingConfig = $state(true);
let savingConfig = $state(false);
// RSS feed visibility — same site_data key as Site Configurations →
// Sub Menu Options. Loaded/saved separately because it's independent of
// the email-subscription toggles below.
let subMenuOptions = $state<{
showShareBadgeMonitor: boolean;
showShareEmbedMonitor: boolean;
showRssFeed: boolean;
}>({
showShareBadgeMonitor: true,
showShareEmbedMonitor: true,
showRssFeed: true
});
let savingRssToggle = $state(false);
async function fetchSubMenuOptions() {
try {
const res = await fetch(clientResolver(resolve, "/manage/api"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "getSiteDataByKey", data: { key: "subMenuOptions" } })
});
const data = await res.json();
if (data && !data.error) {
subMenuOptions = {
showShareBadgeMonitor: data.showShareBadgeMonitor ?? true,
showShareEmbedMonitor: data.showShareEmbedMonitor ?? true,
showRssFeed: data.showRssFeed ?? true
};
}
} catch {
// Defaults already in state; silently fall through.
}
}
async function toggleRssFeed(value: boolean) {
const previous = subMenuOptions.showRssFeed;
subMenuOptions.showRssFeed = value;
savingRssToggle = true;
try {
const res = await fetch(clientResolver(resolve, "/manage/api"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "storeSiteData",
data: { subMenuOptions: JSON.stringify(subMenuOptions) }
})
});
const result = await res.json();
if (result.error) throw new Error(result.error);
toast.success("RSS feed preference saved");
} catch {
subMenuOptions.showRssFeed = previous;
toast.error("Failed to save RSS feed preference");
} finally {
savingRssToggle = false;
}
}
// Subscribers state
interface Subscriber {
user_id: number;
@@ -276,6 +335,7 @@
onMount(() => {
fetchConfig();
fetchSubMenuOptions();
fetchSubscribers();
});
</script>
@@ -363,6 +423,33 @@
</div>
</div>
{/if}
<!-- RSS feed visibility — independent of the email enable flag.
Mirrors the same site_data key used in Site Configurations →
Sub Menu Options, so toggling here updates both places. -->
<div class="space-y-4 border-l-2 pl-4">
<p class="flex items-center gap-2 text-sm font-semibold">
<Rss class="h-4 w-4" />
RSS Feed
</p>
<div class="space-y-4 pl-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label for="enable-rss-feed" class="mb-0">Show RSS feed link</Label>
<p class="text-muted-foreground text-xs">
Adds an RSS icon to the public page header. The feed routes
(<code class="text-xs">/rss.xml</code>) remain reachable either way.
</p>
</div>
<Switch
id="enable-rss-feed"
checked={subMenuOptions.showRssFeed}
disabled={savingRssToggle}
onCheckedChange={toggleRssFeed}
/>
</div>
</div>
</div>
</div>
{/if}
</Card.Content>