Merge pull request 'fix(status): read the public Kuma page, with notices scoped by a prefix' (#486) from fix/status-banner-public-page into lotus
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Build & Quality Checks (push) Successful in 3m27s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Successful in 28m11s
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Build & Quality Checks (push) Successful in 3m27s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Successful in 28m11s
This commit was merged in pull request #486.
This commit is contained in:
+2
-1
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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/<slug>: 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<string, string[]>();
|
||||
@@ -155,14 +175,14 @@ export const parseKumaStatus = (
|
||||
Array.isArray(page.maintenanceList) ? page.maintenanceList : []
|
||||
)
|
||||
.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')
|
||||
.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<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) => ({
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user