fix(status): read the public Kuma page, with notices scoped by a prefix
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Build & Quality Checks (pull_request) Successful in 3m39s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 17m51s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-11-04 21:25:22 -05:00
co-authored by Claude Opus 5.5
parent c73a3f8b31
commit ace084c91c
4 changed files with 103 additions and 7 deletions
+2 -1
View File
@@ -16,7 +16,8 @@
"statusPages": { "statusPages": {
"matrix.lotusguild.org": { "matrix.lotusguild.org": {
"url": "https://isitup.lotusguild.org", "url": "https://isitup.lotusguild.org",
"slug": "matrix", "slug": "public",
"noticePrefix": "Matrix:",
"groups": { "groups": {
"homeserver": "Homeserver", "homeserver": "Homeserver",
"calls": "Voice calls", "calls": "Voice calls",
+1 -1
View File
@@ -62,7 +62,7 @@ export const useServerStatus = (
]); ]);
if (stopped) return; if (stopped) return;
failures = 0; failures = 0;
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups); last = parseKumaStatus(pageJson, heartbeatJson, page!.groups, page!.noticePrefix);
setStatus(last); setStatus(last);
// While this client's connection is lost, keep asking every minute: that // While this client's connection is lost, keep asking every minute: that
// is how it learns Kuma has checked the server since the drop. // is how it learns Kuma has checked the server since the drop.
+72
View File
@@ -285,3 +285,75 @@ test('plain: strips Markdown syntax only, keeps ordinary #, _, > and * (#308)',
'Read the guide docs', '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');
});
+28 -5
View File
@@ -7,6 +7,11 @@
* "login": "Login" } } } * "login": "Login" } } }
* Users of any other homeserver never contact Kuma. * 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: * Kuma 2.x public JSON:
* - GET /api/status-page/<slug>: publicGroupList[{name, monitorList[{id}]}], * - GET /api/status-page/<slug>: publicGroupList[{name, monitorList[{id}]}],
* maintenanceList (only windows UNDER maintenance right now), incidents * maintenanceList (only windows UNDER maintenance right now), incidents
@@ -19,7 +24,12 @@
*/ */
export type StatusGroups = { homeserver?: string; calls?: string; login?: string }; 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 ServiceState = 'up' | 'down' | 'unknown';
export type KumaMaintenance = { id: string; title: string; description: string; end?: number }; 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'; const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) return undefined; if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) return undefined;
const g = obj(entry.groups) ?? {}; const g = obj(entry.groups) ?? {};
const noticePrefix = str(entry.noticePrefix).trim();
return { return {
url: url.origin, url: url.origin,
slug, slug,
...(noticePrefix ? { noticePrefix } : {}),
groups: { groups: {
homeserver: str(g.homeserver) || DEFAULT_GROUPS.homeserver, homeserver: str(g.homeserver) || DEFAULT_GROUPS.homeserver,
calls: str(g.calls) || DEFAULT_GROUPS.calls, calls: str(g.calls) || DEFAULT_GROUPS.calls,
@@ -133,10 +145,18 @@ const groupState = (
return known > 0 ? 'up' : 'unknown'; 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 = ( export const parseKumaStatus = (
pageJson: unknown, pageJson: unknown,
heartbeatJson: unknown, heartbeatJson: unknown,
groupNames: StatusGroups, groupNames: StatusGroups,
noticePrefix?: string,
): ServerStatus => { ): ServerStatus => {
const page = obj(pageJson) ?? {}; const page = obj(pageJson) ?? {};
const groups = new Map<string, string[]>(); const groups = new Map<string, string[]>();
@@ -155,14 +175,14 @@ export const parseKumaStatus = (
Array.isArray(page.maintenanceList) ? page.maintenanceList : [] Array.isArray(page.maintenanceList) ? page.maintenanceList : []
) )
.map((m) => obj(m)) .map((m) => obj(m))
.filter((m): m is Record<string, unknown> => !!m && str(m.title) !== '') .filter((m): m is Record<string, unknown> => !!m && !!noticeTitle(str(m.title), noticePrefix))
.filter((m) => !m.status || m.status === 'under-maintenance') .filter((m) => !m.status || m.status === 'under-maintenance')
.map((m) => { .map((m) => {
const slot = obj((Array.isArray(m.timeslotList) ? m.timeslotList : [])[0]); const slot = obj((Array.isArray(m.timeslotList) ? m.timeslotList : [])[0]);
const end = Date.parse(str(slot?.endDate)); const end = Date.parse(str(slot?.endDate));
return { return {
id: String(m.id ?? str(m.title)), id: String(m.id ?? str(m.title)),
title: str(m.title), title: noticeTitle(str(m.title), noticePrefix) ?? '',
description: str(m.description), description: str(m.description),
...(Number.isFinite(end) ? { end } : {}), ...(Number.isFinite(end) ? { end } : {}),
}; };
@@ -170,10 +190,13 @@ export const parseKumaStatus = (
const incidents: KumaIncident[] = (Array.isArray(page.incidents) ? page.incidents : []) const incidents: KumaIncident[] = (Array.isArray(page.incidents) ? page.incidents : [])
.map((i) => obj(i)) .map((i) => obj(i))
.filter((i): i is Record<string, unknown> => !!i && str(i.title) !== '' && i.active !== false) .filter(
(i): i is Record<string, unknown> =>
!!i && !!noticeTitle(str(i.title), noticePrefix) && i.active !== false,
)
.map((i) => ({ .map((i) => ({
id: String(i.id ?? str(i.title)), id: String(i.id ?? str(i.title)),
title: str(i.title), title: noticeTitle(str(i.title), noticePrefix) ?? '',
content: str(i.content), content: str(i.content),
style: str(i.style) || 'info', style: str(i.style) || 'info',
updated: str(i.lastUpdatedDate) || str(i.createdDate), updated: str(i.lastUpdatedDate) || str(i.createdDate),