From ace084c91c1e0f6e0a59d1f0723bd72191405f28 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 4 Nov 2026 21:25:22 -0500 Subject: [PATCH] fix(status): read the public Kuma page, with notices scoped by a prefix The banner read Kuma's "matrix" status page, which was merged into "public" on 2026-10-11, so it has returned 404 since and the banner stayed silent (by design it fails quiet). Point it at "public", whose Matrix group is now split into Homeserver / Voice calls / Discord. That page is shared with other services, so add an optional noticePrefix: only incidents and maintenance titled "Matrix: ..." reach Lotus users, shown without the prefix. A media server's maintenance can't say messages may be interrupted, and the Discord group can't raise a banner at all. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- config.json | 3 +- src/app/hooks/useServerStatus.ts | 2 +- src/app/utils/kumaStatus.test.ts | 72 ++++++++++++++++++++++++++++++++ src/app/utils/kumaStatus.ts | 33 ++++++++++++--- 4 files changed, 103 insertions(+), 7 deletions(-) diff --git a/config.json b/config.json index b7bb2d3b1..857bfa8f8 100644 --- a/config.json +++ b/config.json @@ -16,7 +16,8 @@ "statusPages": { "matrix.lotusguild.org": { "url": "https://isitup.lotusguild.org", - "slug": "matrix", + "slug": "public", + "noticePrefix": "Matrix:", "groups": { "homeserver": "Homeserver", "calls": "Voice calls", diff --git a/src/app/hooks/useServerStatus.ts b/src/app/hooks/useServerStatus.ts index b4996ab39..d2bf43057 100644 --- a/src/app/hooks/useServerStatus.ts +++ b/src/app/hooks/useServerStatus.ts @@ -62,7 +62,7 @@ export const useServerStatus = ( ]); if (stopped) return; failures = 0; - last = parseKumaStatus(pageJson, heartbeatJson, page!.groups); + last = parseKumaStatus(pageJson, heartbeatJson, page!.groups, page!.noticePrefix); setStatus(last); // While this client's connection is lost, keep asking every minute: that // is how it learns Kuma has checked the server since the drop. diff --git a/src/app/utils/kumaStatus.test.ts b/src/app/utils/kumaStatus.test.ts index 93088a411..79b4929bb 100644 --- a/src/app/utils/kumaStatus.test.ts +++ b/src/app/utils/kumaStatus.test.ts @@ -285,3 +285,75 @@ test('plain: strips Markdown syntax only, keeps ordinary #, _, > and * (#308)', 'Read the guide docs', ); }); + +test('[noticePrefix] resolveStatusPage keeps it only when set', () => { + const base = { url: 'https://kuma.example', slug: 'public' }; + assert.equal( + resolveStatusPage({ hs: { ...base, noticePrefix: ' Matrix: ' } }, 'hs')?.noticePrefix, + 'Matrix:', + ); + assert.equal(resolveStatusPage({ hs: base }, 'hs')?.noticePrefix, undefined); +}); + +test('[noticePrefix] on a shared page only prefixed notices reach users, without the prefix', () => { + const page = { + publicGroupList: [], + maintenanceList: [ + { id: 1, title: 'Jellyfin upgrade', status: 'under-maintenance' }, + { id: 2, title: 'matrix: Synapse upgrade', status: 'under-maintenance' }, + ], + incidents: [ + { id: 3, title: 'Storage redundancy restored', content: 'x', style: 'info' }, + { id: 4, title: 'Matrix: Calls are flaky', content: 'y', style: 'warning' }, + { id: 5, title: 'Matrix:', content: 'nothing left after the prefix', style: 'info' }, + { id: 6, title: 'Matrixish news', content: 'z', style: 'info' }, + ], + }; + const s = parseKumaStatus(page, {}, DEFAULT_GROUPS, 'Matrix:'); + assert.deepEqual( + s.maintenance.map((m) => [m.id, m.title]), + [['2', 'Synapse upgrade']], + ); + assert.deepEqual( + s.incidents.map((i) => [i.id, i.title]), + [['4', 'Calls are flaky']], + ); +}); + +test('[noticePrefix] without one, every notice shows as before', () => { + const page = { + publicGroupList: [], + maintenanceList: [{ id: 1, title: 'Jellyfin upgrade', status: 'under-maintenance' }], + incidents: [{ id: 3, title: 'Storage redundancy restored', content: 'x', style: 'info' }], + }; + const s = parseKumaStatus(page, {}, DEFAULT_GROUPS); + assert.equal(s.maintenance[0].title, 'Jellyfin upgrade'); + assert.equal(s.incidents[0].title, 'Storage redundancy restored'); +}); + +test('[split public page] a Discord group going down touches neither homeserver nor calls', () => { + const up = [ + { status: 1, time: '2026-11-05 01:00:00' }, + { status: 1, time: '2026-11-05 01:01:00' }, + ]; + const down = [ + { status: 0, time: '2026-11-05 01:00:00' }, + { status: 0, time: '2026-11-05 01:01:00' }, + ]; + const page = { + publicGroupList: [ + { name: 'Homeserver', monitorList: [{ id: 30 }] }, + { name: 'Voice calls', monitorList: [{ id: 37 }] }, + { name: 'Discord', monitorList: [{ id: 49 }] }, + { name: 'Services', monitorList: [{ id: 27 }] }, + ], + }; + const s = parseKumaStatus( + page, + { heartbeatList: { 30: up, 37: up, 49: down, 27: down } }, + DEFAULT_GROUPS, + ); + assert.equal(s.homeserver, 'up'); + assert.equal(s.calls, 'up'); + assert.equal(s.login, 'unknown'); +}); diff --git a/src/app/utils/kumaStatus.ts b/src/app/utils/kumaStatus.ts index 7e23274c2..7b3a79319 100644 --- a/src/app/utils/kumaStatus.ts +++ b/src/app/utils/kumaStatus.ts @@ -7,6 +7,11 @@ * "login": "Login" } } } * Users of any other homeserver never contact Kuma. * + * Optional "noticePrefix": on a status page shared with other services, only + * incidents and maintenance titled with this prefix (e.g. "Matrix:") reach + * Lotus users, shown without it, so a media server's maintenance doesn't say + * messages may be interrupted. Without it, every notice on the page shows. + * * Kuma 2.x public JSON: * - GET /api/status-page/: publicGroupList[{name, monitorList[{id}]}], * maintenanceList (only windows UNDER maintenance right now), incidents @@ -19,7 +24,12 @@ */ export type StatusGroups = { homeserver?: string; calls?: string; login?: string }; -export type StatusPageConfig = { url: string; slug: string; groups: StatusGroups }; +export type StatusPageConfig = { + url: string; + slug: string; + groups: StatusGroups; + noticePrefix?: string; +}; export type ServiceState = 'up' | 'down' | 'unknown'; export type KumaMaintenance = { id: string; title: string; description: string; end?: number }; @@ -69,9 +79,11 @@ export const resolveStatusPage = ( const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1'; if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) return undefined; const g = obj(entry.groups) ?? {}; + const noticePrefix = str(entry.noticePrefix).trim(); return { url: url.origin, slug, + ...(noticePrefix ? { noticePrefix } : {}), groups: { homeserver: str(g.homeserver) || DEFAULT_GROUPS.homeserver, calls: str(g.calls) || DEFAULT_GROUPS.calls, @@ -133,10 +145,18 @@ const groupState = ( return known > 0 ? 'up' : 'unknown'; }; +/** The title without the prefix, or undefined when it doesn't carry it. */ +const noticeTitle = (title: string, prefix: string | undefined): string | undefined => { + if (!prefix) return title; + if (title.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()) return undefined; + return title.slice(prefix.length).trim() || undefined; +}; + export const parseKumaStatus = ( pageJson: unknown, heartbeatJson: unknown, groupNames: StatusGroups, + noticePrefix?: string, ): ServerStatus => { const page = obj(pageJson) ?? {}; const groups = new Map(); @@ -155,14 +175,14 @@ export const parseKumaStatus = ( Array.isArray(page.maintenanceList) ? page.maintenanceList : [] ) .map((m) => obj(m)) - .filter((m): m is Record => !!m && str(m.title) !== '') + .filter((m): m is Record => !!m && !!noticeTitle(str(m.title), noticePrefix)) .filter((m) => !m.status || m.status === 'under-maintenance') .map((m) => { const slot = obj((Array.isArray(m.timeslotList) ? m.timeslotList : [])[0]); const end = Date.parse(str(slot?.endDate)); return { id: String(m.id ?? str(m.title)), - title: str(m.title), + title: noticeTitle(str(m.title), noticePrefix) ?? '', description: str(m.description), ...(Number.isFinite(end) ? { end } : {}), }; @@ -170,10 +190,13 @@ export const parseKumaStatus = ( const incidents: KumaIncident[] = (Array.isArray(page.incidents) ? page.incidents : []) .map((i) => obj(i)) - .filter((i): i is Record => !!i && str(i.title) !== '' && i.active !== false) + .filter( + (i): i is Record => + !!i && !!noticeTitle(str(i.title), noticePrefix) && i.active !== false, + ) .map((i) => ({ id: String(i.id ?? str(i.title)), - title: str(i.title), + title: noticeTitle(str(i.title), noticePrefix) ?? '', content: str(i.content), style: str(i.style) || 'info', updated: str(i.lastUpdatedDate) || str(i.createdDate),