mirror of
https://github.com/rajnandan1/kener.git
synced 2026-08-07 07:14:56 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "BEREIT",
|
||||
"Recurring": "Wiederkehrend",
|
||||
"RESOLVED": "BEHOBEN",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "GEPLANT",
|
||||
"Scheduled Events (%count)": "Geplante Ereignisse (%count)",
|
||||
"Script": "Skript",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "KLAR",
|
||||
"Recurring": "Tilbagevendende",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PLANLAGT",
|
||||
"Scheduled Events (%count)": "Planlagte begivenheder (%count)",
|
||||
"Script": "Manuskript",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "READY",
|
||||
"Recurring": "Recurring",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "SCHEDULED",
|
||||
"Scheduled Events (%count)": "Scheduled Events (%count)",
|
||||
"Script": "Script",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "LISTO",
|
||||
"Recurring": "Recurrente",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PROGRAMADO",
|
||||
"Scheduled Events (%count)": "Eventos programados (%count)",
|
||||
"Script": "Guion",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "آماده",
|
||||
"Recurring": "دورهای",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "زمانبندی شده",
|
||||
"Scheduled Events (%count)": "رویدادهای زمانبندی شده (%count)",
|
||||
"Script": "اسکریپت",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "तैयार",
|
||||
"Recurring": "आवर्ती",
|
||||
"RESOLVED": "सुलझा हुआ",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "अनुसूचित",
|
||||
"Scheduled Events (%count)": "अनुसूचित कार्यक्रम (%count)",
|
||||
"Script": "स्क्रिप्ट",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "PRONTO",
|
||||
"Recurring": "Ricorrente",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PROGRAMMATO",
|
||||
"Scheduled Events (%count)": "Eventi programmati (%count)",
|
||||
"Script": "Copione",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "準備完了",
|
||||
"Recurring": "繰り返し",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "予定済み",
|
||||
"Scheduled Events (%count)": "予定イベント (%count)",
|
||||
"Script": "スクリプト",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "준비됨",
|
||||
"Recurring": "반복",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "예정됨",
|
||||
"Scheduled Events (%count)": "예정된 이벤트 (%count)",
|
||||
"Script": "스크립트",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "KLAR",
|
||||
"Recurring": "Gjentakende",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PLANLAGT",
|
||||
"Scheduled Events (%count)": "Planlagte hendelser (%count)",
|
||||
"Script": "Manus",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "GEREED",
|
||||
"Recurring": "Terugkerend",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "GEPLAND",
|
||||
"Scheduled Events (%count)": "Geplande evenementen (%count)",
|
||||
"Script": "Script",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "GOTOWE",
|
||||
"Recurring": "Cykliczne",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "ZAPLANOWANE",
|
||||
"Scheduled Events (%count)": "Zaplanowane wydarzenia (%count)",
|
||||
"Script": "Scenariusz",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "PRONTO",
|
||||
"Recurring": "Recorrente",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "AGENDADO",
|
||||
"Scheduled Events (%count)": "Eventos agendados (%count)",
|
||||
"Script": "Roteiro",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"READY": "ГОТОВО",
|
||||
"Recurring": "Повторяющийся",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "ЗАПЛАНИРОВАНО",
|
||||
"Scheduled Events (%count)": "Запланированные события (%count)",
|
||||
"Script": "Скрипт",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "HAZIR",
|
||||
"Recurring": "Tekrarlayan",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PLANLANMIŞ",
|
||||
"Scheduled Events (%count)": "Planlanan etkinlikler (%count)",
|
||||
"Script": "Senaryo",
|
||||
|
||||
@@ -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-значний код на"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "就绪",
|
||||
"Recurring": "周期性",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "已安排",
|
||||
"Scheduled Events (%count)": "计划事件 (%count)",
|
||||
"Script": "脚本",
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -135,6 +135,7 @@ const seedSiteData = {
|
||||
subMenuOptions: {
|
||||
showShareBadgeMonitor: true,
|
||||
showShareEmbedMonitor: true,
|
||||
showRssFeed: true,
|
||||
},
|
||||
dataRetentionPolicy: {
|
||||
enabled: true,
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -89,6 +89,7 @@ export interface SiteSubscriptionsSettings {
|
||||
export interface SiteSubMenuOptions {
|
||||
showShareBadgeMonitor: boolean;
|
||||
showShareEmbedMonitor: boolean;
|
||||
showRssFeed: boolean;
|
||||
}
|
||||
|
||||
export interface DataRetentionPolicy {
|
||||
|
||||
@@ -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
|
||||
@@ -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`,
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user